diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index 51b98369cd..95a3d6b7ab 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -349,6 +349,8 @@ const LIVE_DEMO_IDS = [ "doom", "modeset", "sdl2", + "wayland", + "hyprland", ] as const; type LiveDemoId = (typeof LIVE_DEMO_IDS)[number]; @@ -451,6 +453,14 @@ const LIVE_DEMO_SPECS: Record = { image: "shell", features: ["kms"], }, + wayland: { + image: "shell", + features: ["kms"], + }, + hyprland: { + image: "shell", + features: ["kms"], + }, }; const DEFAULT_DEMO_FOR_VFS_IMAGE: Record = { @@ -516,6 +526,22 @@ interface LiveProfile { * other sound demo uses. */ sdl2Demo: boolean; + /** + * Stage the Wayland stack — `wlcompositor` (a wl_shm/xdg_shell server + * that drives /dev/dri/card0 via KMS) plus its `wlterm` client (a + * libkwl VT100 terminal running a forkpty'd dash) — attach a + * `BrowserInputSource`, then spawn the compositor and the terminal. + * The compositor composites the client to card0; the Modeset pane + * picks up PAGE_FLIP, and keyboard input routes through the compositor + * to the shell. Runs until the terminal's shell exits. + */ + waylandDemo: boolean; + /** + * Like waylandDemo, but boots `wlcompositor` with WLC_LAYOUT=dwindle so the + * clients (two `wlterm` + a `wlclock`) tile into borderless slots and resize + * to fill them. Browser-only page wiring; runs until the shell exits. + */ + hyprlandDemo: boolean; } interface WebReadinessState { @@ -614,6 +640,47 @@ const SHELL_PROFILES: Record = { node: { env: NODE_SHELL_ENV, cwd: DEMO_HOME }, }; +// Staged to /etc/kandelo/wlcompositor.conf and read via WLC_CONFIG. The demo +// gate asserts the compositor loaded it (BINDS_LOADED source=…); the dwindle +// layout is selected separately by WLC_LAYOUT (the parser only reads binds). +// SUPER mirrors real Hyprland, but a browser reserves it (Cmd/Win), so every +// bind is duplicated on CTRL — the modifier that actually reaches the page. +const HYPRLAND_WLCOMPOSITOR_CONF = `# Kandelo wlcompositor — Hyprland-class tiling desktop (layout via WLC_LAYOUT). +# App-launch binds (the "new pane" chooser, done Hyprland-style with per-app +# keys rather than a launcher UI): Return spawns a terminal, K a clock (K as in +# clocK — Ctrl+C is left to the terminal's SIGINT), P a paint canvas. A browser +# reserves SUPER (=Cmd/Win), so every action is bound on CTRL too — that's the +# modifier users actually press in-browser. Note the compositor grabs bound +# combos before the focused client, so CTRL+W here shadows the terminal's +# werase (see docs/browser-support.md). +bind = SUPER, Return, exec, /usr/local/bin/wlterm +bind = CTRL, Return, exec, /usr/local/bin/wlterm +bind = SUPER, K, exec, /usr/local/bin/wlclock +bind = CTRL, K, exec, /usr/local/bin/wlclock +bind = SUPER, P, exec, /usr/local/bin/wlpaint +bind = CTRL, P, exec, /usr/local/bin/wlpaint +bind = SUPER, W, killactive +bind = CTRL, W, killactive +bind = SUPER, 1, workspace, 1 +bind = SUPER, 2, workspace, 2 +bind = SUPER, 3, workspace, 3 +bind = SUPER, 4, workspace, 4 +bind = SUPER, 5, workspace, 5 +bind = SUPER, 6, workspace, 6 +bind = SUPER, 7, workspace, 7 +bind = SUPER, 8, workspace, 8 +bind = SUPER, 9, workspace, 9 +bind = CTRL, 1, workspace, 1 +bind = CTRL, 2, workspace, 2 +bind = CTRL, 3, workspace, 3 +bind = CTRL, 4, workspace, 4 +bind = CTRL, 5, workspace, 5 +bind = CTRL, 6, workspace, 6 +bind = CTRL, 7, workspace, 7 +bind = CTRL, 8, workspace, 8 +bind = CTRL, 9, workspace, 9 +`; + const INIT_ENV_PROFILES: Record string[]> = { service: () => SERVICE_ENV, wordpress: () => [ @@ -790,7 +857,11 @@ export async function createLiveHost( // fallback if the probe or a GL frame fails. GL demos (modeset.c, // sdl2) keep the webgl2 default — the GL bridge claims their canvas on // eglCreateContext, and the pump never touches it. - h.setKmsDisplayMode(profile.waylandDemo ? "webgl2-scanout" : null); + h.setKmsDisplayMode( + profile.waylandDemo || profile.hyprlandDemo + ? "webgl2-scanout" + : null, + ); const bootStartedAt = performance.now(); try { @@ -965,6 +1036,8 @@ function customVfsProfile( maxVfsByteLength: CUSTOM_VFS_PROFILE_MAX_BYTES, framebufferTest: fb === "test", sdl2Demo: false, + waylandDemo: false, + hyprlandDemo: false, }; } @@ -1014,6 +1087,8 @@ function profileFor(id: string, fb?: FbDemo): LiveProfile { }, framebufferTest: fb === "test", sdl2Demo: normalized === "sdl2", + waylandDemo: normalized === "wayland", + hyprlandDemo: normalized === "hyprland", }; } @@ -1703,13 +1778,180 @@ async function bootProfile( spawnBg(paintBytes, "wlpaint"); tick("running wlterm..."); - await host.runShellCommand("/usr/local/bin/wlterm"); - tick("wlterm exited"); + // Keep-alive foreground client. Launch it through the non-forking + // `spawn` path (like the clock + paint clients) instead of + // runShellCommand, which makes the pts/0 shell fork()+exec the client. + // That shell-fork intermittently fails to start the client under CI's + // Linux headless-chromium worker scheduling, so it never connects + // (CLIENT_CONNECTED count=3 never fires). `spawn` resolves on process + // EXIT (it is used as an exitPromise in kernel-host.ts), so awaiting + // it keeps the demo alive exactly as the foreground shell command did. + await kernelForWayland.spawn(termBytes, ["wlterm"], { + env: SHELL_ENV, + cwd: DEMO_HOME, + uid: DEMO_UID, + gid: DEMO_GID, + }).then( + () => tick("wlterm exited"), + (err: unknown) => + tick(`wlterm failed: ${err instanceof Error ? err.message : String(err)}`), + ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); tick(`wayland failed: ${msg}`); } })(); + } else if (profile.hyprlandDemo) { + // Like waylandDemo but with WLC_LAYOUT=dwindle: the compositor tiles its + // clients and dictates each one's size via xdg configure, which the + // libkwl/vt100 clients honor by rebuilding at the tile size (KWL_RESIZE). + // That client-side resize is the crux — floating clients never resize. + const kernelForHyprland = kernel; + void (async () => { + try { + const compositorUrl = await optionalBinaryUrl([ + "../../../../../local-binaries/programs/wasm32/wlcompositor.wasm", + "../../../../../binaries/programs/wasm32/wlcompositor.wasm", + ], "wlcompositor.wasm"); + const wltermUrl = await optionalBinaryUrl([ + "../../../../../local-binaries/programs/wasm32/wlterm.wasm", + "../../../../../binaries/programs/wasm32/wlterm.wasm", + ], "wlterm.wasm"); + const wlclockUrl = await optionalBinaryUrl([ + "../../../../../local-binaries/programs/wasm32/wlclock.wasm", + "../../../../../binaries/programs/wasm32/wlclock.wasm", + ], "wlclock.wasm"); + // wlpaint is staged so the CTRL+P launch bind can exec it on demand; + // unlike the wayland demo it is not auto-spawned into the initial + // layout (the user opens it via the keybind, the "new pane" flow). + const wlpaintUrl = await optionalBinaryUrl([ + "../../../../../local-binaries/programs/wasm32/wlpaint.wasm", + "../../../../../binaries/programs/wasm32/wlpaint.wasm", + ], "wlpaint.wasm"); + tick("staging hyprland binaries..."); + const [compBytes, termBytes, clockBytes, paintBytes] = await Promise.all([ + fetch(compositorUrl).then(failOn("wlcompositor.wasm")).then((r) => r.arrayBuffer()), + fetch(wltermUrl).then(failOn("wlterm.wasm")).then((r) => r.arrayBuffer()), + fetch(wlclockUrl).then(failOn("wlclock.wasm")).then((r) => r.arrayBuffer()), + fetch(wlpaintUrl).then(failOn("wlpaint.wasm")).then((r) => r.arrayBuffer()), + ]); + ensureDirRecursive(kernelForHyprland.fs, "/usr/local/bin"); + writeVfsBinary( + kernelForHyprland.fs, + "/usr/local/bin/wlcompositor", + new Uint8Array(compBytes), + 0o755, + ); + writeVfsBinary( + kernelForHyprland.fs, + "/usr/local/bin/wlterm", + new Uint8Array(termBytes), + 0o755, + ); + writeVfsBinary( + kernelForHyprland.fs, + "/usr/local/bin/wlclock", + new Uint8Array(clockBytes), + 0o755, + ); + writeVfsBinary( + kernelForHyprland.fs, + "/usr/local/bin/wlpaint", + new Uint8Array(paintBytes), + 0o755, + ); + + ensureDirRecursive(kernelForHyprland.fs, "/etc/kandelo"); + writeVfsFile( + kernelForHyprland.fs, + "/etc/kandelo/wlcompositor.conf", + HYPRLAND_WLCOMPOSITOR_CONF, + 0o644, + ); + + // Pointer is owned by the Modeset pane (event1); feed keyboard only. + tick("attaching input source..."); + const WL_FB_W = 1920; + const WL_FB_H = 1080; + kernelForHyprland.attachInputSource( + new BrowserInputSource(window, { pointer: false, wheel: false }), + { width: WL_FB_W, height: WL_FB_H }, + ); + + // Size the desktop from the Modeset pane's canvas exactly like + // the wayland demo (see that block for the rationale). + tick("sizing display mode..."); + const sizeDeadline = performance.now() + 1500; + let displaySize = host.getKmsDisplaySize(1); + while (!displaySize && performance.now() < sizeDeadline) { + const paneCanvas = document.querySelector( + ".kmachine-primary-slot:not(.is-hidden) canvas", + ); + const rect = paneCanvas?.getBoundingClientRect(); + if (rect && rect.width >= 1 && rect.height >= 1) { + const dpr = window.devicePixelRatio || 1; + displaySize = { width: rect.width * dpr, height: rect.height * dpr }; + kernelForHyprland.kmsSetDisplaySize( + 1, + displaySize.width, + displaySize.height, + ); + break; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + displaySize = host.getKmsDisplaySize(1); + } + + // Clients retry their connect to /tmp/wayland-0, so the compositor + // and clients can be spawned without an ordering barrier. + tick("running wlcompositor..."); + const spawnBg = (bytes: ArrayBuffer, name: string, extraEnv: string[] = []) => + void kernelForHyprland.spawn(bytes, [name], { + env: extraEnv.length ? [...SHELL_ENV, ...extraEnv] : SHELL_ENV, + cwd: DEMO_HOME, + uid: DEMO_UID, + gid: DEMO_GID, + }).then( + () => tick(`${name} exited`), + (err: unknown) => + tick(`${name} failed: ${err instanceof Error ? err.message : String(err)}`), + ); + spawnBg(compBytes, "wlcompositor", [ + "WLC_LAYOUT=dwindle", + "WLC_CONFIG=/etc/kandelo/wlcompositor.conf", + ]); + + // The clock + first terminal run in the background; the foreground + // terminal's shell keeps the demo alive (as waylandDemo does). + tick("running wlclock + wlterm..."); + spawnBg(clockBytes, "wlclock"); + spawnBg(termBytes, "wlterm"); + + tick("running wlterm..."); + // Keep-alive 3rd tiling client. Launch it through the non-forking + // `spawn` path (like the clock + first terminal) instead of + // runShellCommand, which makes the pts/0 shell fork()+exec the client. + // That shell-fork races the first terminal's forkpty under CI's Linux + // headless-chromium worker scheduling and intermittently fails to + // start the client, so it never connects (CLIENT_CONNECTED count=3 + // never fires). `spawn` resolves on process EXIT (it is used as an + // exitPromise in kernel-host.ts), so awaiting it keeps the demo alive + // exactly as the foreground shell command did. + await kernelForHyprland.spawn(termBytes, ["wlterm"], { + env: SHELL_ENV, + cwd: DEMO_HOME, + uid: DEMO_UID, + gid: DEMO_GID, + }).then( + () => tick("wlterm exited"), + (err: unknown) => + tick(`wlterm failed: ${err instanceof Error ? err.message : String(err)}`), + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + tick(`hyprland failed: ${msg}`); + } + })(); } else if (presentation?.autoCommand) { tick("starting configured command from the default shell..."); void host.runShellCommand(presentation.autoCommand).catch((err) => { diff --git a/apps/browser-demos/pages/kandelo/presets.ts b/apps/browser-demos/pages/kandelo/presets.ts index 6a08ab98f6..30c1a985e8 100644 --- a/apps/browser-demos/pages/kandelo/presets.ts +++ b/apps/browser-demos/pages/kandelo/presets.ts @@ -150,4 +150,15 @@ export const PRESET_LIBRARY: Preset[] = [ bootCommand: ["bash", "-l", "-i"], estimatedUrlBytes: 612, }, + { + id: "hyprland", + title: "Hyprland tiling WM", + summary: "wlcompositor in dwindle mode — a Hyprland-class tiling window manager on /dev/dri/card0. Two wlterm terminals and a wlclock tile into gapped, borderless (server-side-decorated) frames; each client honors the compositor's xdg configure to resize into its tile. Open new panes Hyprland-style with per-app launch keybinds: CTRL+Return spawns a terminal, CTRL+K a clock, CTRL+P a paint canvas; CTRL+W kills the focused window and CTRL+1..9 switch workspaces. Every bind is mirrored on SUPER (real Hyprland) and CTRL, which a browser (unlike SUPER=Cmd/Win) doesn't reserve. Bindings from /etc/kandelo/wlcompositor.conf.", + base: SHELL_BASE, + packages: ["bash@local", "coreutils@local"], + accent: "#00aaff", + glyph: "H", + bootCommand: ["bash", "-l", "-i"], + estimatedUrlBytes: 612, + }, ]; diff --git a/apps/browser-demos/test/kandelo-hyprland.spec.ts b/apps/browser-demos/test/kandelo-hyprland.spec.ts new file mode 100644 index 0000000000..6df4a1391a --- /dev/null +++ b/apps/browser-demos/test/kandelo-hyprland.spec.ts @@ -0,0 +1,321 @@ +import { expect, test, type Page } from "@playwright/test"; + +const appUrl = (path: string): string => { + const baseUrl = process.env.KANDELO_TEST_BASE_URL; + return baseUrl ? new URL(path, baseUrl).href : path; +}; + +async function gotoOrSkip(page: Page, path: string) { + await page.goto(appUrl(path), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2_000); + if (await page.locator("vite-error-overlay").count()) { + test.skip(true, "Required binary not built - Vite import error"); + } +} + +async function openSurface(page: Page, label: string) { + const btn = page.locator("button.kmachine-switch-btn", { hasText: label }); + await btn.waitFor({ state: "visible", timeout: 30_000 }); + await btn.click(); +} + +async function syslogText(page: Page): Promise { + const lines = await page.locator(".ksys-line").allInnerTexts(); + return lines.join("\n"); +} + +// A printf marker can split across two .ksys-line entries, so join only the +// .ksys-msg spans — otherwise the next line's `[timestamp]LEVEL` prefix +// interleaves into the marker and the regex misses. +async function syslogStream(page: Page): Promise { + const msgs = await page.locator(".ksys-line .ksys-msg").allInnerTexts(); + return msgs.join(""); +} + +const canvasLocator = (page: Page) => + page.locator(".kmachine-primary-slot:not(.is-hidden) canvas").first(); + +const SETUP_FAILURE = + /hyprland failed|wlcompositor failed|wlclock failed|wlterm failed/; + +/** + * End-to-end browser gate for the `/?demo=hyprland` tiling desktop: + * wlcompositor (WLC_LAYOUT=dwindle) composites three clients — a wlclock and + * two wlterm terminals — into gapped, server-side-decorated tiles, with each + * client resizing to fill its tile. Skips (via gotoOrSkip) when the binaries + * aren't built — Vite fails the `?url` import and shows an error overlay. + */ +test("Kandelo hyprland tiles three clients, resizes them into tiles, and honors CTRL keybinds (incl. app-launch binds)", async ({ page }) => { + test.setTimeout(300_000); + + await gotoOrSkip(page, "/?demo=hyprland"); + + // Boot is heavy (three wasm programs + a forkpty'd shell); wait for the tick + // that fires once the foreground wlterm launches, then check for failure. + await openSurface(page, "Internals"); + await expect + .poll(() => syslogText(page), { timeout: 180_000 }) + .toMatch(/running wlterm/); + expect(await syslogText(page), "hyprland setup reported failure") + .not.toMatch(SETUP_FAILURE); + + // Gate 1: dwindle layout + the staged Hyprland keybind config loaded. + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/WLC_LAYOUT dwindle/); + expect(await syslogStream(page), "compositor did not load the staged config") + .toMatch(/BINDS_LOADED n=\d+ source=\/etc\/kandelo\/wlcompositor\.conf/); + + // Gate 2: all three clients connected and the dwindle tiler placed all + // three tiles. The third map produces `TILE n=3 i=0..2` markers. + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/CLIENT_CONNECTED count=3/); + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/TILE n=3 i=2 /); + + // Gate 3: the clients honored their dictated tile size. This is the demo's + // crux — the compositor sends xdg configure(w,h) on retile, and the + // libkwl/vt100 clients rebuild their buffers to match (floating clients in + // /?demo=wayland never resize, so these markers are unique to tiling). + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/WLCLOCK_RESIZE w=\d+ h=\d+/); + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/WLTERM_RESIZE cols=\d+ rows=\d+/); + + // Gate 4: the tiled desktop composited to the canvas. The Modeset pane + // uses transferControlToOffscreen, so PNG byteLength stands in for pixel + // readback — a blank frame is ~3 KB; wallpaper + three tiled windows is + // far larger. + await openSurface(page, "Demo"); + const canvas = canvasLocator(page); + await expect(canvas).toBeVisible({ timeout: 30_000 }); + await expect + .poll( + async () => (await canvas.screenshot()).byteLength, + { timeout: 120_000, intervals: [1_000, 2_000, 5_000] }, + ) + .toBeGreaterThan(12_000); + + // Gate 5: CTRL keybinds reach the compositor. A real browser reserves SUPER + // (=Cmd/Win), so the demo binds every action on CTRL too — that's the path + // users actually press. Exercise both a named key and a digit, since they + // resolve differently (a letter/named keysym is case-folded to match the + // base-level keysym; a digit isn't). Focus off the canvas placeholder first + // (BrowserInputSource listens on window). + await page.locator("body").click({ position: { x: 5, y: 5 } }); + + // CTRL+Return execs a fourth client (`bind = CTRL, Return, exec, wlterm`) — + // the exact combo a user presses to spawn a terminal. + await page.keyboard.down("Control"); + await page.keyboard.press("Enter"); + await page.keyboard.up("Control"); + await openSurface(page, "Internals"); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/CLIENT_CONNECTED count=4/); + + // CTRL+2 switches workspace (`bind = CTRL, 2, workspace, 2`). + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + await page.keyboard.press("2"); + await page.keyboard.up("Control"); + await openSurface(page, "Internals"); + await expect + .poll(() => syslogStream(page), { timeout: 30_000 }) + .toMatch(/WORKSPACE active=2/); + + // Gate 6: the "new pane" launch keybinds. Hyprland-style, each app has its + // own exec bind rather than a launcher UI: CTRL+P execs wlpaint, CTRL+K execs + // wlclock (`bind = CTRL, P/K, exec, /usr/local/bin/wl{paint,clock}`; K, not + // C, so the terminal keeps SIGINT). The compositor grabs the combo, runs + // posix_spawnp, and the new client connects — so each press bumps + // CLIENT_CONNECTED. wlpaint is staged only for this path (not auto-spawned + // into the initial layout). preventDefault in BrowserInputSource suppresses + // the browser's own Ctrl+P print default. + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + await page.keyboard.press("KeyP"); + await page.keyboard.up("Control"); + await openSurface(page, "Internals"); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/CLIENT_CONNECTED count=5/); + // ...and it fills its tile: the compositor retiles to fit the new window and + // wlpaint honors the dictated size (WLPAINT_RESIZE), rather than drawing a + // fixed 640×420 island in the corner. + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/WLPAINT_RESIZE w=\d+ h=\d+/); + + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + await page.keyboard.press("KeyK"); + await page.keyboard.up("Control"); + await openSurface(page, "Internals"); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/CLIENT_CONNECTED count=6/); + + // Gate 7: closing a pane with CTRL+W (killactive) actually removes it. This + // regresses a hang where the compositor sent xdg_toplevel.close but wlterm + // blocked in waitpid() reaping its shell (closing the pty master didn't hang + // dash up), so the surface was never destroyed and the tile stayed on screen + // forever. Spawn a fresh terminal, focus it (newly mapped windows take + // keyboard focus), then CTRL+W it and assert it exits (WLTERM_EXIT) — pre-fix + // that marker never arrived. No terminal exits earlier in the demo, so its + // mere presence proves the close path completed. + // + // killactive targets the *focused* window, and keyboard focus only moves to a + // new window once its first commit maps it (surface_commit) — which lands + // well after CLIENT_CONNECTED (socket connect) and after the client's own + // WLTERM_READY (queued, not-yet-processed first commit). So don't race the + // map: after the spawn connects, wait for the compositor's authoritative + // KBD_FOCUS marker to land on the fresh wlterm before pressing CTRL+W. + // Otherwise killactive closes whatever held focus before it mapped (the + // wlclock from Gate 6), and WLTERM_EXIT never arrives. + const wltermFocusBefore = ( + (await syslogStream(page)).match(/KBD_FOCUS app_id=wlterm/g) ?? [] + ).length; + + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + await page.keyboard.press("Enter"); + await page.keyboard.up("Control"); + await openSurface(page, "Internals"); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/CLIENT_CONNECTED count=7/); + // The fresh terminal has connected; now wait until it actually maps and takes + // keyboard focus (one more KBD_FOCUS app_id=wlterm than before the spawn) + // before we killactive it. + await expect + .poll( + async () => + ((await syslogStream(page)).match(/KBD_FOCUS app_id=wlterm/g) ?? []) + .length, + { timeout: 60_000 }, + ) + .toBeGreaterThan(wltermFocusBefore); + + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + await page.keyboard.press("KeyW"); + await page.keyboard.up("Control"); + await openSurface(page, "Internals"); + await expect + .poll(() => syslogStream(page), { timeout: 30_000 }) + .toMatch(/WLTERM_EXIT/); + + expect(await syslogText(page), "hyprland reported failure after input") + .not.toMatch(SETUP_FAILURE); +}); + +/** + * Regression gate for the kernel SCM_RIGHTS fd-delivery coalescing bug. + * + * Launching windows rapidly makes dwindle retile every existing window on each + * new map — an O(N²) storm of `wl_shm.create_pool` messages, each carrying a + * gbm prime-fd over the Unix socket as SCM_RIGHTS ancillary data. The kernel + * used to pop only ONE ancillary fd-group per recvmsg, but a single recvmsg can + * drain the coalesced bytes of several create_pool messages — so only the first + * message's fd was delivered and the rest were stranded. libwayland then + * demarshalled a later create_pool with a MISSING fd, the server posted + * `invalid arguments for wl_shm.create_pool`, and killed that client. The result + * was rate-dependent: launched slowly, all windows mapped (TILE n=8); hammered, + * clients died mid-storm (TILE n=5). The kernel fix tags each ancillary group + * with the byte-stream offset of its send and caps each recvmsg at the next + * boundary, so a single recvmsg never spans two sends' fds. This gate hammers + * eight launches back-to-back and asserts all eight map with no create_pool + * error — pre-fix it stalled at TILE n=5. + */ +test("Kandelo hyprland survives a rapid 8-window launch storm without SCM_RIGHTS fd loss", async ({ page }) => { + test.setTimeout(300_000); + + await gotoOrSkip(page, "/?demo=hyprland"); + + await openSurface(page, "Internals"); + await expect + .poll(() => syslogText(page), { timeout: 180_000 }) + .toMatch(/running wlterm/); + expect(await syslogText(page), "hyprland setup reported failure") + .not.toMatch(SETUP_FAILURE); + + // Wait for the initial three-client dwindle layout to settle. + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/CLIENT_CONNECTED count=3/); + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/TILE n=3 i=2 /); + + // Switch to an empty workspace so the storm's window count is unambiguous + // (workspace 1 keeps the three initial clients). + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + await page.keyboard.press("2"); + await page.keyboard.up("Control"); + await openSurface(page, "Internals"); + await expect + .poll(() => syslogStream(page), { timeout: 30_000 }) + .toMatch(/WORKSPACE active=2/); + + // Hammer eight wlclock launches back-to-back (CTRL+K, no delay) — the fd + // coalescing storm that used to drop clients. Each press execs a new wlclock, + // which creates a wl_shm pool with a prime-fd; the rapid cadence coalesces the + // create_pool sends in the socket byte stream. + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + for (let i = 0; i < 8; i++) { + await page.keyboard.press("KeyK", { delay: 0 }); + } + await page.keyboard.up("Control"); + + await openSurface(page, "Internals"); + // All eight windows must map and tile on workspace 2. Pre-fix (fd coalescing) + // this stalled at TILE n=5 while CLIENT_CONNECTED still reached 11 — connected + // but killed before mapping because their create_pool fd was lost. + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/TILE n=8 /); + + // Then close two panes (killactive) — the exact sequence a user hits after a + // launch storm — and let the survivors retile/redraw. + for (let i = 0; i < 2; i++) { + await openSurface(page, "Demo"); + await page.locator("body").click({ position: { x: 5, y: 5 } }); + await page.keyboard.down("Control"); + await page.keyboard.press("KeyW", { delay: 0 }); + await page.keyboard.up("Control"); + await page.waitForTimeout(600); + } + await openSurface(page, "Internals"); + await page.waitForTimeout(2000); + + const log = await syslogText(page); + // No client was killed by a demarshal error from a missing prime-fd + // (the fd-coalescing bug). + expect(log, "a client hit an SCM_RIGHTS fd-loss create_pool error") + .not.toMatch(/invalid arguments for wl_shm/); + expect(log, "a client connection was killed during the launch storm") + .not.toMatch(/error in client communication/); + // AND every mapped buffer imported/mapped: a prime-bo whose channel refcount + // was not held would tombstone before the compositor imports it, and every + // subsequent composite floods `gbm_bo_map failed: Invalid argument` — the + // user-visible "freeze". This is the assertion the geometry-only TILE check + // missed. Must stay green through the close/retile above, not just the launch. + expect(log, "the compositor failed to map/import a client buffer (prime-bo lifetime bug)") + .not.toMatch(/gbm_bo_map failed|gbm_bo_import/); + expect(log, "hyprland reported failure after the launch storm") + .not.toMatch(SETUP_FAILURE); +}); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 4a60ed839c..f4bfc4547e 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -199,6 +199,14 @@ unsafe extern "C" { height: u32, stride: u32, ) -> i32; + fn host_gbm_gpu_bo_create( + pid: i32, + bo_id: u32, + width: u32, + height: u32, + format: u32, + usage: u32, + ) -> i32; fn host_gbm_bo_destroy(pid: i32, bo_id: u32); fn host_gbm_bo_bind(pid: i32, bo_id: u32, addr: usize, len: usize) -> i32; fn host_gbm_bo_unbind(pid: i32, bo_id: u32, addr: usize, len: usize); @@ -979,6 +987,18 @@ impl HostIO for WasmHostIO { unsafe { host_gbm_bo_create(pid, bo_id, size, width, height, stride) } } + fn gbm_gpu_bo_create( + &mut self, + pid: i32, + bo_id: u32, + width: u32, + height: u32, + format: u32, + usage: u32, + ) -> i32 { + unsafe { host_gbm_gpu_bo_create(pid, bo_id, width, height, format, usage) } + } + fn gbm_bo_destroy(&mut self, pid: i32, bo_id: u32) { unsafe { host_gbm_bo_destroy(pid, bo_id) } } diff --git a/crates/runtime-core/src/dri/bo.rs b/crates/runtime-core/src/dri/bo.rs index 4fbce8b056..68cc61dc6b 100644 --- a/crates/runtime-core/src/dri/bo.rs +++ b/crates/runtime-core/src/dri/bo.rs @@ -29,6 +29,18 @@ pub type BoId = u32; /// that doesn't match it gets `EACCES`. pub type PrimeCookie = u64; +/// Storage tier of a bo. `CpuShared` bos (`MODE_CREATE_DUMB`) are backed +/// by a host SAB, LINEAR and CPU-mmap'able. `GpuTexture` bos +/// (`WPK_CREATE_GPU_BO`, PR10) are backed by a host `WebGLTexture` on the +/// shared multiplexer context — unmappable on the CPU side; the compositor +/// samples them and their producer renders into an FBO whose color +/// attachment IS the texture (zero-copy dmabuf semantics). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoTier { + CpuShared, + GpuTexture, +} + #[derive(Debug, Clone)] pub struct GbmBo { pub id: BoId, @@ -36,6 +48,7 @@ pub struct GbmBo { pub size: u64, pub refcount: u32, pub prime_cookie: Option, + pub tier: BoTier, } pub struct BoRegistry { @@ -54,6 +67,18 @@ impl BoRegistry { } pub fn try_alloc(&mut self, width: u32, height: u32, bpp: u32) -> Option<&mut GbmBo> { + self.try_alloc_tier(width, height, bpp, BoTier::CpuShared) + } + + /// Allocate a GPU-tier bo (`WPK_CREATE_GPU_BO`). The stride/size are + /// still computed for the LINEAR-equivalent layout so `gbm_bo_get_stride` + /// returns a sensible value, but the bo carries no CPU-side SAB and + /// cannot be `mmap`'d — the host backs it with a `WebGLTexture`. + pub fn try_alloc_gpu(&mut self, width: u32, height: u32, bpp: u32) -> Option<&mut GbmBo> { + self.try_alloc_tier(width, height, bpp, BoTier::GpuTexture) + } + + fn try_alloc_tier(&mut self, width: u32, height: u32, bpp: u32, tier: BoTier) -> Option<&mut GbmBo> { let id = self.next_id; // Stride rounded up to a 4-byte boundary so every row is // u32-aligned (matches Mesa's `gbm_bo_get_stride` for the @@ -69,6 +94,7 @@ impl BoRegistry { size, refcount: 1, prime_cookie: None, + tier, }; self.map.insert(id, bo); Some(self.map.get_mut(&id).unwrap()) @@ -257,6 +283,44 @@ mod tests { }); } + #[test] + fn alloc_defaults_to_cpu_tier() { + let _g = fresh(); + with_registry(|r| { + let bo = r.alloc(64, 64, 32); + assert_eq!(bo.tier, BoTier::CpuShared); + let id = bo.id; + r.decref(id); + }); + } + + #[test] + fn alloc_gpu_marks_gpu_tier_and_keeps_stride() { + let _g = fresh(); + with_registry(|r| { + // GPU-tier bos still compute a LINEAR-equivalent stride/size + // so gbm_bo_get_stride returns a sane value. + let bo = r.try_alloc_gpu(17, 3, 32).unwrap(); + assert_eq!(bo.tier, BoTier::GpuTexture); + assert_eq!(bo.stride, 68); + assert_eq!(bo.size, 68 * 3); + let id = bo.id; + r.decref(id); + }); + } + + #[test] + fn gpu_and_cpu_bos_share_the_id_space() { + let _g = fresh(); + with_registry(|r| { + let a = r.alloc(64, 64, 32).id; + let b = r.try_alloc_gpu(64, 64, 32).unwrap().id; + assert!(b > a, "gpu bo draws the next monotonic id"); + r.decref(a); + r.decref(b); + }); + } + #[test] fn freed_id_is_not_reused() { let _g = fresh(); diff --git a/crates/runtime-core/src/dri/mod.rs b/crates/runtime-core/src/dri/mod.rs index c2f5004a9c..8e967e6b13 100644 --- a/crates/runtime-core/src/dri/mod.rs +++ b/crates/runtime-core/src/dri/mod.rs @@ -8,7 +8,7 @@ pub mod bo; pub mod master; -pub use bo::{BoId, BoRegistry, GbmBo, PrimeCookie, with_registry}; +pub use bo::{BoId, BoRegistry, BoTier, GbmBo, PrimeCookie, with_registry}; use core::sync::atomic::{AtomicU32, AtomicU64, Ordering}; diff --git a/crates/runtime-core/src/pipe.rs b/crates/runtime-core/src/pipe.rs index 27b11f1d23..0f8d10619f 100644 --- a/crates/runtime-core/src/pipe.rs +++ b/crates/runtime-core/src/pipe.rs @@ -128,6 +128,7 @@ impl InFlightFd { file_type: self.file_type, host_handle: self.host_handle, pipe_ref_kind: self.pipe_ref_kind, + prime_bo_id: self.prime_bo.as_ref().map(|pb| pb.bo_id), } } @@ -227,6 +228,7 @@ pub struct DeferredInFlightFdRelease { file_type: FileType, host_handle: i64, pipe_ref_kind: Option, + prime_bo_id: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -234,6 +236,10 @@ pub struct ReleasedInFlightFd { pub ofd_id: OfdId, pub final_ofd_reference: bool, pub host_close: Option, + /// A queued prime-bo reference whose release drove the bo refcount to + /// zero. The caller must forward it to `host_io.gbm_bo_destroy` so the + /// backing SAB/texture is dropped. + pub bo_destroy: Option, } /// One SCM_RIGHTS batch attached to an exact byte range in a stream pipe. @@ -331,6 +337,26 @@ pub fn deferred_in_flight_release_state() -> (usize, usize, usize) { } fn retain_in_flight_resource(release: DeferredInFlightFdRelease) -> Result<(), Errno> { + // A queued prime-bo fd owns a registry reference for the whole hop: + // libwayland closes the pool fd right after wl_shm.create_pool, so the + // sender's own reference can vanish before the receiver drains the + // socket. The reference transfers to the receiver's OFD on install and + // is dropped through the deferred queue otherwise. Rollback below never + // reaches refcount zero because the snapshot's source OFD (or, for a + // MSG_PEEK clone, the original queued entry) still holds a reference. + if let Some(bo_id) = release.prime_bo_id { + crate::dri::with_registry(|r| r.incref(bo_id)).ok_or(Errno::EBADF)?; + } + if let Err(err) = retain_in_flight_backing(release) { + if let Some(bo_id) = release.prime_bo_id { + crate::dri::with_registry(|r| r.decref(bo_id)); + } + return Err(err); + } + Ok(()) +} + +fn retain_in_flight_backing(release: DeferredInFlightFdRelease) -> Result<(), Errno> { if crate::descriptor_backing::add_ref_for_ofd(release.file_type, release.host_handle)? { return Ok(()); } @@ -437,10 +463,18 @@ pub fn release_deferred_in_flight_resource( } } + let mut bo_destroy = None; + if let Some(bo_id) = release.prime_bo_id { + if crate::dri::with_registry(|r| r.decref(bo_id)) == Some(0) { + bo_destroy = Some(bo_id); + } + } + ReleasedInFlightFd { ofd_id: release.ofd_id, final_ofd_reference, host_close, + bo_destroy, } } @@ -1280,6 +1314,7 @@ impl PipeBuffer { pub fn has_ancillary(&self) -> bool { !self.ancillary_fds.is_empty() } + } /// Table of pipe buffers shared across all processes. @@ -1986,6 +2021,39 @@ mod tests { assert!(pipe.is_fully_closed()); } + #[test] + fn test_in_flight_prime_bo_reference_lifecycle() { + let _g = crate::dri::bo::TEST_REGISTRY_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::dri::bo::reset_registry(); + let bo_id = + crate::dri::with_registry(|r| r.try_alloc(4, 4, 32).map(|bo| bo.id)).unwrap(); + + let release = DeferredInFlightFdRelease { + ofd_id: crate::lock::OfdId(u64::MAX), + file_type: FileType::CharDevice, + host_handle: crate::ofd::PRIME_FD_HOST_HANDLE, + pipe_ref_kind: None, + prime_bo_id: Some(bo_id), + }; + retain_in_flight_resource(release).unwrap(); + + // The sender closes its prime fd while the batch is still queued. + // The queued reference keeps the bo alive. + crate::dri::with_registry(|r| r.decref(bo_id)); + assert!(crate::dri::with_registry(|r| r.get(bo_id).map(|_| ())).is_some()); + + // Discarding the queued batch releases the last reference and + // reports the bo for host-side destruction. + let released = release_deferred_in_flight_resource(release); + assert_eq!(released.bo_destroy, Some(bo_id)); + assert!(crate::dri::with_registry(|r| r.get(bo_id).map(|_| ())).is_none()); + + // A retain against an evicted bo fails instead of resurrecting it. + assert_eq!(retain_in_flight_resource(release), Err(Errno::EBADF)); + } + #[test] fn test_orderly_read_close_discards_until_last_writer_closes() { let mut pipe = PipeBuffer::new(8); diff --git a/crates/runtime-core/src/process.rs b/crates/runtime-core/src/process.rs index 019e4dfa52..c15ba9f86f 100644 --- a/crates/runtime-core/src/process.rs +++ b/crates/runtime-core/src/process.rs @@ -248,8 +248,32 @@ pub trait HostIO { -(Errno::ENOSYS as i32) } - /// Free host-side SAB backing for a bo whose refcount has reached - /// zero. Idempotent: calling on an unknown `bo_id` is a no-op. + /// Allocate host-side `WebGLTexture` backing for a freshly-created + /// GPU-tier bo (`DRM_IOCTL_WPK_CREATE_GPU_BO`, PR10). Unlike + /// `gbm_bo_create`, there is no SAB: the bo lives as a texture (+FBO) + /// on the shared multiplexer context, sampled zero-copy by the + /// compositor and rendered into by its producer. `format` is a + /// `DRM_FORMAT_*` and `usage` a `GBM_BO_USE_*` bitmask, both passed + /// through from the guest. Returns ≥ 0 on success, negative errno on + /// failure (e.g. no WebGL backing on a headless host). Released via + /// `gbm_bo_destroy` when the refcount reaches zero — the same path as + /// CPU-tier bos. + #[allow(unused_variables)] + fn gbm_gpu_bo_create( + &mut self, + pid: i32, + bo_id: u32, + width: u32, + height: u32, + format: u32, + usage: u32, + ) -> i32 { + -(Errno::ENOSYS as i32) + } + + /// Free host-side backing for a bo whose refcount has reached zero + /// (SAB for CPU-tier, `WebGLTexture`+FBO for GPU-tier). Idempotent: + /// calling on an unknown `bo_id` is a no-op. #[allow(unused_variables)] fn gbm_bo_destroy(&mut self, pid: i32, bo_id: u32) {} @@ -3235,6 +3259,168 @@ mod tests { ); } + #[test] + fn spawn_child_increfs_inherited_dri_bos() { + // Regression (hyprland tiling desktop freeze): a posix_spawn'd client + // inherits the compositor's O_CLOEXEC card0 OFD, which carries the + // scanout bo's GEM handle + KMS framebuffer. spawn must incref those + // bos exactly as fork does (read_dri_fd_state / read_kms_fd_state), + // otherwise the child's exec-time close (dri_release_ofd_state) decrefs + // the compositor's still-live scanout bo to zero, tombstoning it in the + // global BoRegistry and freezing the desktop under a gbm_bo_map EINVAL + // flood. + use crate::ofd::{DriFdState, DriOfdState, KmsFb, KmsFdState}; + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; + + let _g = crate::dri::bo::TEST_REGISTRY_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::dri::bo::reset_registry(); + let bo = crate::dri::with_registry(|r| r.alloc(64, 64, 32).id); + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let parent = table.get_mut(parent_pid).unwrap(); + let ofd_idx = parent.ofd_table.create( + crate::ofd::FileType::CharDevice, + 0, + -9, + b"/dev/dri/card0".to_vec(), + ); + // Mirror the compositor's card0 fd: one GEM handle + one framebuffer, + // both referencing the scanout bo. + let mut dri = DriFdState::default(); + dri.handles.insert(5, bo); + dri.next_handle = 6; + let mut kms = KmsFdState::default(); + kms.fbs.insert( + 42, + KmsFb { + bo_id: bo, + width: 64, + height: 64, + pixel_format: 0x34325241, // AR24 + stride: 64 * 4, + }, + ); + kms.next_fb_id = 43; + parent.ofd_table.get_mut(ofd_idx).unwrap().dri_state = + Some(alloc::boxed::Box::new(DriOfdState::Card { dri, kms })); + parent + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + assert_eq!( + crate::dri::with_registry(|r| r.get(bo).map(|b| b.refcount)), + Some(1), + "synthetic parent setup leaves the registry at the alloc refcount" + ); + + let mut host = test_host::NoopHost; + table + .spawn_child_for_caller( + parent_pid, + parent_pid, + &[b"wlclock".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .expect("spawn_child_for_caller"); + + // Child inherited the card0 OFD's one GEM handle + one framebuffer; + // each must have taken its own registry ref so the child's eventual + // close-path decref is balanced. + assert_eq!( + crate::dri::with_registry(|r| r.get(bo).map(|b| b.refcount)), + Some(3), + "spawn child must incref the inherited scanout bo once per handle + fb" + ); + + crate::dri::with_registry(|r| { + r.decref(bo); + r.decref(bo); + r.decref(bo); + }); + } + + #[test] + fn fork_process_increfs_inherited_dri_bos_exactly_once() { + // Guard the spawn-vs-fork incref split. DRI bos are increfed on the + // fork path inside deserialize (read_dri_fd_state / read_kms_fd_state), + // NOT in the shared bump helper — spawn uses `bump_inherited_dri_bos` + // instead. If a future change moved the DRI incref into + // `bump_inherited_resource_refcounts`, fork_process (deserialize + bump) + // would double-incref and leak the bo. This asserts exactly one child + // ref per inherited GEM handle + framebuffer. + use crate::ofd::{DriFdState, DriOfdState, KmsFb, KmsFdState}; + use crate::process_table::ProcessTable; + + let _g = crate::dri::bo::TEST_REGISTRY_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::dri::bo::reset_registry(); + let bo = crate::dri::with_registry(|r| r.alloc(64, 64, 32).id); + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let parent = table.get_mut(parent_pid).unwrap(); + let ofd_idx = parent.ofd_table.create( + crate::ofd::FileType::CharDevice, + 0, + -9, + b"/dev/dri/card0".to_vec(), + ); + let mut dri = DriFdState::default(); + dri.handles.insert(5, bo); + dri.next_handle = 6; + let mut kms = KmsFdState::default(); + kms.fbs.insert( + 42, + KmsFb { + bo_id: bo, + width: 64, + height: 64, + pixel_format: 0x34325241, // AR24 + stride: 64 * 4, + }, + ); + kms.next_fb_id = 43; + parent.ofd_table.get_mut(ofd_idx).unwrap().dri_state = + Some(alloc::boxed::Box::new(DriOfdState::Card { dri, kms })); + parent + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + assert_eq!( + crate::dri::with_registry(|r| r.get(bo).map(|b| b.refcount)), + Some(1), + "synthetic parent setup leaves the registry at the alloc refcount" + ); + + table + .fork_process_for_caller(parent_pid, parent_pid) + .expect("fork_process_for_caller"); + + // Parent's ref (1, synthetic) + child's one incref per handle + fb (2). + // A deserialize+bump double-count would show 5. + assert_eq!( + crate::dri::with_registry(|r| r.get(bo).map(|b| b.refcount)), + Some(3), + "fork must incref each inherited DRI bo exactly once (no deserialize + bump double-count)" + ); + + crate::dri::with_registry(|r| { + r.decref(bo); + r.decref(bo); + r.decref(bo); + }); + } + #[test] fn fork_and_spawn_bump_host_net_handle_refcount() { // Regression: connected AF_INET sockets were value-cloned across diff --git a/crates/runtime-core/src/process_table.rs b/crates/runtime-core/src/process_table.rs index 8f60a9f9b2..f1089084a3 100644 --- a/crates/runtime-core/src/process_table.rs +++ b/crates/runtime-core/src/process_table.rs @@ -358,6 +358,51 @@ pub fn bump_inherited_resource_refcounts( Ok(()) } +/// Incref every DRI bo referenced by the child's inherited card0 / renderD128 +/// / prime-fd OFDs — GEM-handle maps, KMS framebuffers, and prime-bo bindings. +/// +/// **Spawn-only**, deliberately NOT folded into +/// [`bump_inherited_resource_refcounts`]: unlike pipes/sockets/PTYs (whose +/// refcount bumps live solely in that shared helper), DRI bos are increfed on +/// the *fork* path inside deserialize (`fork::read_dri_fd_state` / +/// `read_kms_fd_state` / the PrimeBo arm). `spawn_child`, however, builds the +/// child by value-cloning the parent's `ofd_table` and never deserializes, so +/// its inherited DRI OFDs carry no registry ref. Calling this from +/// `bump_inherited_resource_refcounts` would double-incref on `fork_process` +/// (deserialize + bump). Keeping it spawn-local balances the child's +/// eventual close-path decref (`dri_release_ofd_state`) exactly once. +/// +/// Without this, a `posix_spawn`'d client that inherits the compositor's +/// `O_CLOEXEC` card0 fd (carrying the scanout bo's GEM handle + KMS +/// framebuffer) decrefs those bos on exec with no matching incref, tombstoning +/// the compositor's still-live scanout bo in the global `BoRegistry` and +/// freezing the desktop under a `gbm_bo_map` EINVAL flood. +fn bump_inherited_dri_bos(child: &Process) { + for (_idx, ofd) in child.ofd_table.iter() { + let Some(dri_state) = ofd.dri_state.as_deref() else { + continue; + }; + crate::dri::with_registry(|reg| match dri_state { + crate::ofd::DriOfdState::PrimeBo(p) => { + reg.incref(p.bo_id); + } + crate::ofd::DriOfdState::RenderNode(dri) => { + for bo_id in dri.handles.values() { + reg.incref(*bo_id); + } + } + crate::ofd::DriOfdState::Card { dri, kms } => { + for bo_id in dri.handles.values() { + reg.incref(*bo_id); + } + for fb in kms.fbs.values() { + reg.incref(fb.bo_id); + } + } + }); + } +} + /// Build the fork-only `fork_pipe_replay` table: a list of (read_fd, /// write_fd) pairs so that when the child resumes through fork rewind, /// `sys_pipe` returns the same fd numbers the parent saw. @@ -1264,6 +1309,11 @@ impl ProcessTable { // Bump cross-process refcounts on the inherited fd state. The same // helper fork uses — this is the genuinely-shared concern. bump_inherited_resource_refcounts(parent_pid, &child)?; + // DRI bos are the one inherited resource fork increfs during + // deserialize rather than in the shared helper, so spawn (which + // value-clones the fd tables and never deserializes) must incref them + // here — see `bump_inherited_dri_bos`. + bump_inherited_dri_bos(&child); // The child is a real kernel process and signal target, but the // parent has not received a successful posix_spawn result yet. Wait diff --git a/crates/runtime-core/src/syscalls.rs b/crates/runtime-core/src/syscalls.rs index ee98bf0579..8e3625d3e0 100644 --- a/crates/runtime-core/src/syscalls.rs +++ b/crates/runtime-core/src/syscalls.rs @@ -1205,6 +1205,69 @@ fn handle_dri_ioctl( } Ok(()) } + DRM_IOCTL_WPK_CREATE_GPU_BO => { + if buf.len() < core::mem::size_of::() { + return Err(Errno::EINVAL); + } + let mut req: WpkDrmGpuBoCreate = + unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; + if req.width == 0 || req.height == 0 { + return Err(Errno::EINVAL); + } + // GPU-tier bos are always 32bpp (ARGB/XRGB8888). Allocate in + // the registry first so the id/stride are known before asking + // the host to build the texture. Roll back on host failure. + let (bo_id, stride) = crate::dri::with_registry(|r| { + r.try_alloc_gpu(req.width, req.height, 32) + .map(|bo| (bo.id, bo.stride)) + }) + .ok_or(Errno::EINVAL)?; + let host_rc = + host.gbm_gpu_bo_create(pid, bo_id, req.width, req.height, req.format, req.usage); + if host_rc < 0 { + crate::dri::with_registry(|r| { + r.decref(bo_id); + }); + return Err(Errno::ENOMEM); + } + // Register a fresh per-fd handle. On EMFILE, roll back the bo + // and its host texture. + let handle = match dri_state_mut(proc, ofd_idx) { + Ok(dri) => { + let h = dri.next_handle; + match dri.next_handle.checked_add(1) { + Some(n) => { + dri.next_handle = n; + dri.handles.insert(h, bo_id); + h + } + None => { + crate::dri::with_registry(|r| { + r.decref(bo_id); + }); + host.gbm_bo_destroy(pid, bo_id); + return Err(Errno::EMFILE); + } + } + } + Err(e) => { + crate::dri::with_registry(|r| { + r.decref(bo_id); + }); + host.gbm_bo_destroy(pid, bo_id); + return Err(e); + } + }; + // Write back over the same 16-byte buffer: width/height are + // echoed unchanged, `format`/`usage` slots become + // `handle`/`stride` outputs (see WpkDrmGpuBoCreate docs). + req.format = handle; + req.usage = stride; + unsafe { + core::ptr::write_unaligned(buf.as_mut_ptr() as *mut _, req); + } + Ok(()) + } DRM_IOCTL_MODE_MAP_DUMB => { if buf.len() < core::mem::size_of::() { return Err(Errno::EINVAL); @@ -1215,6 +1278,16 @@ fn handle_dri_ioctl( .handles .get(&req.handle) .ok_or(Errno::ENOENT)?; + // GPU-tier bos have no CPU-side SAB, so they cannot be mapped. + // Reject here (matching a real driver's EINVAL on a + // scanout/render-only bo) rather than handing back an offset + // that the mmap path would then fail to decode. + let is_gpu = crate::dri::with_registry(|r| { + r.get(bo_id).map(|b| b.tier == crate::dri::BoTier::GpuTexture) + }); + if is_gpu == Some(true) { + return Err(Errno::EINVAL); + } // The "mmap offset" is just the BoId page-shifted so it // can't collide with file offsets. The mmap path decodes // the offset back to a BoId. @@ -1453,15 +1526,28 @@ fn handle_dri_ioctl( Ok(()) } gl::GLIO_CREATE_SURFACE => { - if buf.len() < core::mem::size_of::() { + let attrs_size = core::mem::size_of::(); + if buf.len() < attrs_size { return Err(Errno::EINVAL); } - let attrs_bytes = &buf[..core::mem::size_of::()]; let attrs: gl::GlSurfaceAttrs = - unsafe { core::ptr::read_unaligned(attrs_bytes.as_ptr() as *const _) }; + unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const _) }; if attrs.kind != gl::WPK_SURFACE_DEFAULT && attrs.kind != gl::WPK_SURFACE_PBUFFER { return Err(Errno::EINVAL); } + // GPU-tier producer targeting (PR10 §7.1): `reserved[0]` carries + // the target bo HANDLE (eglCreateWindowSurface's + // EGL_WPK_TARGET_BO attrib) — the bo whose FBO this window + // surface renders into. Translate it to the global bo_id here + // (the host can't resolve a per-fd handle), exactly as + // BIND_FOREIGN_TEXTURE does. 0 = no target (an ordinary canvas + // / scanout surface), the common case. + let target_bo_id: u32 = if attrs.reserved[0] != 0 { + let dri = dri_state(proc, ofd_idx)?; + *dri.handles.get(&attrs.reserved[0]).ok_or(Errno::ENOENT)? + } else { + 0 + }; let surface_id; { let dri = dri_state_mut(proc, ofd_idx)?; @@ -1475,6 +1561,12 @@ fn handle_dri_ioctl( surface_id = 1u32; gls.surface_id = Some(surface_id); } + // Overwrite `reserved[0]` in place with the resolved global + // bo_id so the host — which reads these bytes by pointer — + // sees an id it can look up, not the per-fd handle. reserved[0] + // is at byte offset 16 in the 32-byte GlSurfaceAttrs. + buf[16..20].copy_from_slice(&target_bo_id.to_le_bytes()); + let attrs_bytes = &buf[..attrs_size]; host.gl_create_surface(pid, surface_id, attrs_bytes); Ok(()) } @@ -3508,6 +3600,11 @@ pub fn drain_deferred_scm_rights_releases( if let Some(handle) = released.host_close { let _ = host.host_close(handle); } + if let Some(bo_id) = released.bo_destroy { + // The host bo registry keys by bo_id; the pid argument only + // reaches diagnostics listeners, and no owning process remains. + host.gbm_bo_destroy(0, bo_id); + } } } @@ -3744,11 +3841,11 @@ pub fn install_scm_rights_fds_with_flags( ); match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { Ok(new_fd) => { - // Take a bo refcount for the receiver's new fd; its close - // drops it (dri_release_ofd_state). The sender's own - // reference keeps the bo alive across the hop. + // The queued entry's in-flight bo reference (taken at send + // retain) transfers to the receiver's new fd; its close drops + // it (dri_release_ofd_state). No incref here — the sender's + // own reference may already be gone. if let Some(pb) = entry.prime_bo.clone() { - crate::dri::with_registry(|r| r.incref(pb.bo_id)); if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dri_state = Some(alloc::boxed::Box::new( crate::ofd::DriOfdState::PrimeBo(pb), @@ -4030,6 +4127,7 @@ fn release_ofd_reference_impl( } unsafe { crate::pipe::global_pipe_table().free_if_closed(recv_idx) }; } + unsafe { crate::pipe::global_pipe_table().free_if_closed(recv_idx) }; } } // peer_idx is a process-local socket-table identity used by @@ -9905,8 +10003,15 @@ pub fn sys_mmap( return Err(Errno::EINVAL); } let bo_id = bo_id_u64 as crate::dri::BoId; - let bo_size = - crate::dri::with_registry(|r| r.get(bo_id).map(|b| b.size)).ok_or(Errno::EINVAL)?; + let (bo_size, bo_tier) = + crate::dri::with_registry(|r| r.get(bo_id).map(|b| (b.size, b.tier))) + .ok_or(Errno::EINVAL)?; + // GPU-tier bos have no CPU-side SAB. MAP_DUMB already refuses + // to hand out an offset for them, but guard the mmap path too + // (a caller could forge the encoded offset directly). + if bo_tier == crate::dri::BoTier::GpuTexture { + return Err(Errno::EINVAL); + } let has_local_handle = ofd .dri() .map(|dri| dri_fd_has_bo_handle(dri, bo_id)) @@ -18242,6 +18347,18 @@ mod tests { /// Return value for `gl_bind_foreign_texture` (> 0 = texture id, /// <= 0 = failure → the ioctl surfaces EIO). gl_bind_foreign_texture_rc: i32, + /// Recorded `(pid, bo_id, width, height, format, usage)` for every + /// `gbm_gpu_bo_create` call (WPK_CREATE_GPU_BO). + gbm_gpu_bo_create_calls: Vec<(i32, u32, u32, u32, u32, u32)>, + /// Return value for `gbm_gpu_bo_create` (>= 0 = success, negative = + /// errno → the ioctl surfaces ENOMEM). Defaults to 0. + gbm_gpu_bo_create_rc: i32, + /// Recorded `(pid, bo_id)` for every `gbm_bo_destroy` call. + gbm_bo_destroy_calls: Vec<(i32, u32)>, + /// Recorded `(pid, surface_id, attrs_bytes)` for every + /// `gl_create_surface` call — lets tests assert the kernel handed + /// the host a resolved target bo_id in `attrs.reserved[0]`. + gl_create_surface_calls: Vec<(i32, u32, Vec)>, } impl MockHostIO { @@ -18328,6 +18445,10 @@ mod tests { kms_set_fb_calls: Vec::new(), gl_bind_foreign_texture_calls: Vec::new(), gl_bind_foreign_texture_rc: 7, + gbm_gpu_bo_create_calls: Vec::new(), + gbm_gpu_bo_create_rc: 0, + gbm_bo_destroy_calls: Vec::new(), + gl_create_surface_calls: Vec::new(), } } @@ -19058,7 +19179,22 @@ mod tests { ) -> i32 { 0 } - fn gbm_bo_destroy(&mut self, _pid: i32, _bo_id: u32) {} + fn gbm_gpu_bo_create( + &mut self, + pid: i32, + bo_id: u32, + width: u32, + height: u32, + format: u32, + usage: u32, + ) -> i32 { + self.gbm_gpu_bo_create_calls + .push((pid, bo_id, width, height, format, usage)); + self.gbm_gpu_bo_create_rc + } + fn gbm_bo_destroy(&mut self, pid: i32, bo_id: u32) { + self.gbm_bo_destroy_calls.push((pid, bo_id)); + } fn gbm_bo_bind(&mut self, pid: i32, bo_id: u32, addr: usize, len: usize) -> i32 { self.gbm_bo_bind_calls.push((pid, bo_id, addr, len)); self.gbm_bo_bind_rc @@ -19090,6 +19226,10 @@ mod tests { .push((pid, ctx_id, bo_id, gl_target)); self.gl_bind_foreign_texture_rc } + fn gl_create_surface(&mut self, pid: i32, surface_id: u32, attrs: &[u8]) { + self.gl_create_surface_calls + .push((pid, surface_id, attrs.to_vec())); + } } fn user_process(pid: u32) -> Process { @@ -42168,6 +42308,122 @@ mod tests { ); } + #[test] + fn dri_ioctl_create_gpu_bo_returns_handle_stride_and_calls_host() { + use wasm_posix_shared::dri::*; + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/dri/renderD128", O_RDWR, 0).unwrap(); + + // width/height in; format/usage passed through to the host. + let req = WpkDrmGpuBoCreate { + width: 64, + height: 32, + format: 0x3432_5258, // DRM_FORMAT_XRGB8888 + usage: 0x5, // GBM_BO_USE_SCANOUT|RENDERING + }; + let mut buf = [0u8; core::mem::size_of::()]; + unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmGpuBoCreate, req) }; + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_WPK_CREATE_GPU_BO, &mut buf).unwrap(); + + let out: WpkDrmGpuBoCreate = + unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmGpuBoCreate) }; + assert_eq!(out.width, 64, "width echoed unchanged"); + assert_eq!(out.height, 32, "height echoed unchanged"); + assert_eq!(out.format, 1, "format slot becomes the out handle"); + assert_eq!(out.usage, 64 * 4, "usage slot becomes the out stride (bytes)"); + + // The host received one gpu-bo-create with the original + // format/usage and the registry's bo id. + assert_eq!(host.gbm_gpu_bo_create_calls.len(), 1); + let (cpid, _bo, cw, ch, cfmt, cusage) = host.gbm_gpu_bo_create_calls[0]; + assert_eq!((cpid, cw, ch, cfmt, cusage), (1, 64, 32, 0x3432_5258, 0x5)); + } + + #[test] + fn dri_ioctl_create_gpu_bo_rejects_zero_dims() { + use wasm_posix_shared::dri::*; + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/dri/renderD128", O_RDWR, 0).unwrap(); + + for (w, h) in [(0u32, 32u32), (64, 0)] { + let req = WpkDrmGpuBoCreate { width: w, height: h, format: 0, usage: 0 }; + let mut buf = [0u8; core::mem::size_of::()]; + unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmGpuBoCreate, req) }; + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_WPK_CREATE_GPU_BO, &mut buf) + .unwrap_err(), + Errno::EINVAL + ); + } + assert!(host.gbm_gpu_bo_create_calls.is_empty(), "no host call on reject"); + } + + #[test] + fn dri_ioctl_create_gpu_bo_rolls_back_on_host_failure() { + use wasm_posix_shared::dri::*; + let _g = crate::dri::bo::TEST_REGISTRY_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::dri::bo::reset_registry(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.gbm_gpu_bo_create_rc = -(Errno::ENOMEM as i32); + let fd = sys_open(&mut proc, &mut host, b"/dev/dri/renderD128", O_RDWR, 0).unwrap(); + + let req = WpkDrmGpuBoCreate { width: 8, height: 8, format: 0, usage: 0 }; + let mut buf = [0u8; core::mem::size_of::()]; + unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmGpuBoCreate, req) }; + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_WPK_CREATE_GPU_BO, &mut buf).unwrap_err(), + Errno::ENOMEM + ); + // The registry allocation was rolled back — the bo id it would + // have used is now free, so the next alloc reclaims nothing stale + // (tombstone gap) but no live bo leaked. + assert!( + crate::dri::with_registry(|r| r.get(1).is_none()), + "failed gpu bo must be decref'd, not leaked" + ); + // No per-fd handle was installed either. + let ofd_idx = proc.fd_table.get(fd).unwrap().ofd_ref.0; + assert!(proc + .ofd_table + .get(ofd_idx) + .unwrap() + .dri() + .unwrap() + .handles + .is_empty()); + } + + #[test] + fn dri_ioctl_map_dumb_rejects_gpu_tier_bo() { + use wasm_posix_shared::dri::*; + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/dri/renderD128", O_RDWR, 0).unwrap(); + + // Create a GPU-tier bo, grab its handle. + let req = WpkDrmGpuBoCreate { width: 16, height: 16, format: 0, usage: 0 }; + let mut buf = [0u8; core::mem::size_of::()]; + unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmGpuBoCreate, req) }; + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_WPK_CREATE_GPU_BO, &mut buf).unwrap(); + let created: WpkDrmGpuBoCreate = + unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmGpuBoCreate) }; + let handle = created.format; // out handle lives in the format slot + + // MAP_DUMB on a GPU-tier bo is rejected — it has no CPU SAB. + let map = WpkDrmModeMapDumb { handle, pad: 0, offset: 0 }; + let mut mbuf = [0u8; core::mem::size_of::()]; + unsafe { core::ptr::write_unaligned(mbuf.as_mut_ptr() as *mut WpkDrmModeMapDumb, map) }; + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_MAP_DUMB, &mut mbuf).unwrap_err(), + Errno::EINVAL + ); + } + #[test] fn dri_ioctl_map_dumb_returns_bo_id_shifted_offset() { use wasm_posix_shared::dri::*; @@ -43412,6 +43668,45 @@ mod tests { assert!(proc.dri_bindings.is_empty()); } + #[test] + fn mmap_dri_rejects_gpu_tier_bo_offset() { + // A GPU-tier bo has no CPU-side SAB. MAP_DUMB already refuses to + // hand out an offset for it, but a caller could forge the encoded + // offset (bo_id << 12) directly — the mmap path must still reject + // it rather than bind a nonexistent SAB slice. + use wasm_posix_shared::dri::*; + use wasm_posix_shared::mmap::{MAP_SHARED, PROT_READ, PROT_WRITE}; + let _g = crate::dri::bo::TEST_REGISTRY_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::dri::bo::reset_registry(); + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/dri/renderD128", O_RDWR, 0).unwrap(); + + // First registry alloc → bo id 1; forge its encoded mmap offset. + let req = WpkDrmGpuBoCreate { width: 64, height: 64, format: 0, usage: 0 }; + let mut buf = [0u8; core::mem::size_of::()]; + unsafe { core::ptr::write_unaligned(buf.as_mut_ptr() as *mut WpkDrmGpuBoCreate, req) }; + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_WPK_CREATE_GPU_BO, &mut buf).unwrap(); + + let err = sys_mmap( + &mut proc, + &mut host, + 0, + 0x10000, + PROT_READ | PROT_WRITE, + MAP_SHARED, + fd, + (1u64 << 12) as i64, + ) + .unwrap_err(); + assert_eq!(err, Errno::EINVAL); + assert!(host.gbm_bo_bind_calls.is_empty()); + assert!(proc.dri_bindings.is_empty()); + } + #[test] fn mmap_dri_rolls_back_when_host_bind_fails() { use wasm_posix_shared::mmap::{MAP_SHARED, PROT_READ, PROT_WRITE}; @@ -43833,6 +44128,81 @@ mod tests { ); } + #[test] + fn glio_create_surface_translates_target_bo_handle() { + // GPU-tier producer targeting: GLIO_CREATE_SURFACE's reserved[0] + // carries the target bo HANDLE; the kernel must translate it to a + // global bo_id (which the host can resolve) before forwarding, and + // reject an unknown handle with ENOENT. + use wasm_posix_shared::dri::*; + use wasm_posix_shared::gl; + let _g = crate::dri::bo::TEST_REGISTRY_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::dri::bo::reset_registry(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // Two fds so this fd's local handle (1) differs from the resolved + // global bo id (2), proving the kernel translated rather than + // forwarded the raw handle. + let fd_a = sys_open(&mut proc, &mut host, b"/dev/dri/renderD128", O_RDWR, 0).unwrap(); + let fd = sys_open(&mut proc, &mut host, b"/dev/dri/renderD128", O_RDWR, 0).unwrap(); + + let dumb = WpkDrmModeCreateDumb { width: 64, height: 32, bpp: 32, ..Default::default() }; + let mut dbuf = [0u8; core::mem::size_of::()]; + unsafe { core::ptr::write_unaligned(dbuf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, dumb) }; + sys_ioctl(&mut proc, &mut host, fd_a, DRM_IOCTL_MODE_CREATE_DUMB, &mut dbuf).unwrap(); + unsafe { core::ptr::write_unaligned(dbuf.as_mut_ptr() as *mut WpkDrmModeCreateDumb, dumb) }; + sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_CREATE_DUMB, &mut dbuf).unwrap(); + let dumb_out: WpkDrmModeCreateDumb = + unsafe { core::ptr::read_unaligned(dbuf.as_ptr() as *const _) }; + assert_eq!(dumb_out.handle, 1); // this fd's local handle + + // Bring up the GL session + context on `fd`. + let mut ver_buf = [0u8; 4]; + ver_buf.copy_from_slice(&gl::OP_VERSION.to_le_bytes()); + sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_INIT, &mut ver_buf).unwrap(); + let cattrs = gl::GlContextAttrs { client_version: 3, reserved: [0; 3] }; + let mut abuf = [0u8; core::mem::size_of::()]; + unsafe { core::ptr::write_unaligned(abuf.as_mut_ptr() as *mut gl::GlContextAttrs, cattrs) }; + sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_CREATE_CONTEXT, &mut abuf).unwrap(); + + let sz = core::mem::size_of::(); + let mut sbuf = vec![0u8; sz]; + let mut nullbuf = [0u8; 0]; + let read_reserved0 = + |bytes: &[u8]| u32::from_le_bytes(bytes[16..20].try_into().unwrap()); + + // (1) No target (reserved[0]=0) → host sees 0 (ordinary surface). + let s0 = gl::GlSurfaceAttrs { + kind: gl::WPK_SURFACE_DEFAULT, width: 64, height: 32, config_id: 1, reserved: [0; 4], + }; + unsafe { core::ptr::write_unaligned(sbuf.as_mut_ptr() as *mut gl::GlSurfaceAttrs, s0) }; + sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_CREATE_SURFACE, &mut sbuf).unwrap(); + assert_eq!(read_reserved0(&host.gl_create_surface_calls[0].2), 0); + sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_DESTROY_SURFACE, &mut nullbuf).unwrap(); + + // (2) Target this fd's local handle 1 → host sees global bo_id 2. + let s1 = gl::GlSurfaceAttrs { + kind: gl::WPK_SURFACE_DEFAULT, width: 64, height: 32, config_id: 1, reserved: [1, 0, 0, 0], + }; + unsafe { core::ptr::write_unaligned(sbuf.as_mut_ptr() as *mut gl::GlSurfaceAttrs, s1) }; + sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_CREATE_SURFACE, &mut sbuf).unwrap(); + assert_eq!(read_reserved0(&host.gl_create_surface_calls[1].2), 2); + sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_DESTROY_SURFACE, &mut nullbuf).unwrap(); + + // (3) Unknown target handle → ENOENT, and the host is NOT called. + let s2 = gl::GlSurfaceAttrs { + kind: gl::WPK_SURFACE_DEFAULT, width: 64, height: 32, config_id: 1, reserved: [999, 0, 0, 0], + }; + unsafe { core::ptr::write_unaligned(sbuf.as_mut_ptr() as *mut gl::GlSurfaceAttrs, s2) }; + assert_eq!( + sys_ioctl(&mut proc, &mut host, fd, gl::GLIO_CREATE_SURFACE, &mut sbuf).unwrap_err(), + Errno::ENOENT, + ); + assert_eq!(host.gl_create_surface_calls.len(), 2); + } + #[test] fn glio_submit_rejects_out_of_range_range() { use wasm_posix_shared::gl; diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index ed345dddbe..13a1bc38e8 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -4763,8 +4763,10 @@ pub mod dri { /// (re)uploads the bo's current pixels into the texture from host-side /// storage, so callers refresh a texture by re-issuing the ioctl after /// the producer commits new content. The returned `gl_texture_id` is - /// stable across rebinds of the same bo. GPU-tier bos - /// (`WPK_CREATE_GPU_BO`) remain unimplemented. + /// stable across rebinds of the same bo. On a GPU-tier bo + /// (`WPK_CREATE_GPU_BO`) the bind is zero-copy: the pixels already live + /// as a `WebGLTexture` on the shared context, so it returns that + /// texture id directly with no upload. pub const DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE: u32 = 0xc010_64e1; /// GPU-bo allocator argument. 16 bytes on wasm32 (4 × u32). `format` and diff --git a/docs/architecture.md b/docs/architecture.md index 3c78057cdc..a5d64bca6b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2434,18 +2434,33 @@ throughput or performance claim. `crates/kernel/src/syscalls.rs` handles DRM_IOCTL_VERSION, MODE_GETRESOURCES, MODE_GETCONNECTOR, MODE_GETENCODER, MODE_GETCRTC, MODE_SETCRTC, MODE_GETPLANE_RESOURCES, MODE_ADDFB / RMFB / DIRTYFB, MODE_PAGE_FLIP, and the dumb-buffer creation+mmap path. Programs compiled against the upstream `libdrm` (vendored under `packages/registry/libdrm/`) link cleanly; SDL2's KMSDRM backend uses the same surface unmodified. `host_kms_mode_info` returns a mode flagged PREFERRED so KMSDRM's mode-selection loop picks it up. When the embedder has reported the display pane's device-pixel size (`setKmsDisplaySize`, fed by the Modeset pane's ResizeObserver), the mode follows the pane's aspect ratio at a fixed 1080 logical height — `round(1080 × aspect) × 1080`, width clamped to [1440, 3840] — so a mode-picking client (wlcompositor, SDL2 KMSDRM) fills the pane with no letterbox; without a reported size (Node hosts, headless) it stays the historical 1920×1080@60. The mode is sampled per GETCONNECTOR call but effectively fixed once a client boots; resizing the pane afterwards reintroduces letterboxing rather than switching modes. The connector-id parameter is plumbed through but v1 advertises a single connector. `libc/glue/libgbm_stub.c` implements a 2-BO scanout ring (lock_front_buffer / release_buffer / has_free_buffers / destroy) on top of the dumb-buffer surface so KMSDRM's swap chain has somewhere to hand off frames. The `libEGL.a` / `libGLESv2.a` stubs (in `libc/glue/`) route GLES commands through the `/dev/dri/renderD128` cmdbuf to the host's WebGL2 bridge. -`DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE` (a WPK extension, `'d'` nr `0xE1`) bridges the two tiers: it (re)uploads a CPU-tier bo's current pixels into a `WebGLTexture` in the calling fd's GL context — the host reads the bo's canonical SAB storage directly, so window-sized textures never squeeze through the 64 KB-capped cmdbuf TLV records. The bo handle and the GL session must live on the same fd; `libEGL` exposes the flow as `wpkEglImportDmabufHandle(prime_fd)` (PRIME import on the EGL fd) + `wpkEglBindBoTexture(handle, GL_TEXTURE_2D)` (idempotent per bo; re-call to refresh after the producer commits). Texture lifetime is tied to the bo: the last GEM_CLOSE deletes it. This is the first slice of the GPU tier (`WPK_CREATE_GPU_BO` — fully GPU-backed bos — remains unimplemented; see the wayland plan §7 F′). +`DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE` (a WPK extension, `'d'` nr `0xE1`) bridges the two tiers: it (re)uploads a CPU-tier bo's current pixels into a `WebGLTexture` in the calling fd's GL context — the host reads the bo's canonical SAB storage directly, so window-sized textures never squeeze through the 64 KB-capped cmdbuf TLV records. The bo handle and the GL session must live on the same fd; `libEGL` exposes the flow as `wpkEglImportDmabufHandle(prime_fd)` (PRIME import on the EGL fd) + `wpkEglBindBoTexture(handle, GL_TEXTURE_2D)` (idempotent per bo; re-call to refresh after the producer commits). Texture lifetime is tied to the bo: the last GEM_CLOSE deletes it. + +`DRM_IOCTL_WPK_CREATE_GPU_BO` (`'d'` nr `0xE0`) is the fully GPU-backed tier: the bo is a host `WebGLTexture` plus a color-attachment FBO — no SAB, unmappable on the CPU. WebGL textures are not shareable across contexts, so all GPU-tier bos and every GL client that touches them live on **one** shared multiplexer context: the DRM-master compositor's scanout WebGL2 context. A non-master GL client (which has no display canvas of its own) is routed onto that shared context at `host_gl_create_context`; the existing `GlMuxer` (keyed by the WebGL2 context) multiplexes the compositor and its clients by replaying each binding's shadow state on `switchTo`. A client renders into the bo by targeting its FBO: the bo handle reaches the next `eglCreateWindowSurface`'s `GLIO_CREATE_SURFACE` either explicitly (`libEGL`'s `wpkEglSetWindowSurfaceTarget(handle)`) or, for a libwayland-egl `wl_egl_window`, from the bo that window allocated; the kernel translates the per-fd handle to a global bo id and the host redirects the client's "bind default framebuffer 0" to the bo's FBO (sizing the viewport to the bo). Because a toolkit may create the window surface *before* the GL context, the redirect target is captured at surface creation and (re)applied at whichever of context-creation or surface-creation runs last, since it needs both `b.gl` and the resolved bo. `eglSwapBuffers` flushes the shared context as the buffer-ready fence before `wl_surface.commit`; command order through the one submit queue gives render-before-sample ordering for free (no explicit sync object in v1). `BIND_FOREIGN_TEXTURE` on a GPU-tier bo then degenerates to returning the texture id — true zero-copy, no upload. Allocation degrades to a CPU-tier dumb bo whenever no shared context exists — headless Node has no WebGL2, and in the browser the compositor must have created its context first — so `libgbm` only requests the GPU tier for render-only bos (RENDERING set, none of SCANOUT/CURSOR/WRITE/LINEAR) and falls back to CREATE_DUMB on any error. This producer path is browser-only. The GPU-tier bo's FBO is color-only; a depth attachment (required for arbitrary non-convex 3D) is tracked as follow-up (plan §7.1). `MODE_PAGE_FLIP` latches the new framebuffer as the host-side scanout immediately (`host_kms_set_fb`, same call `MODE_SETCRTC` makes) and queues a flip-complete event that `kernel_vblank` — driven by the host's 60 Hz vblank pump — retires into the fd's event ring at the next tick. Two consequences: libdrm's `drmModePageFlip → poll → drmHandleEvent` loop runs at refresh rate rather than ioctl rate, and any host-side consumer of the current scanout (the vblank pump's WebGL2 scanout presenter or the legacy 2D canvas blit used by CPU-rendered demos) always reads the most recently flipped buffer, never a double-buffered client's back buffer. The immediate latch is race-free because a well-behaved client fully paints a bo before flipping to it and only reuses the old bo after the flip-complete event. ## Wayland compositor (`wlcompositor`, `wlterm`) -On top of the DRM/KMS + evdev surfaces above sits a real Wayland stack that runs entirely in-kernel — no host-side Wayland. `programs/wlcompositor/` is a PID-2 server built against a wasm32 port of `libwayland-server`: it owns the `card0` scanout (via the same KMSDRM/`libgbm` path SDL2 uses), reads input from `/dev/input/event0` (keyboard) and `event1` (pointer) through a real `libinput` 1.25.0 port, and exports the core protocol plus `wl_shm`, `xdg_shell`, `wl_seat`, and `wl_output`. Clients connect over a Unix socket at `/tmp/wayland-0` (`/` is a read-only rootfs and `/var/run` is `EACCES` for non-root, so the well-known runtime dir is `/tmp`). Buffer sharing is zero-copy: clients allocate `wl_shm` pools backed by `gbm` dumb BOs and pass the prime-fd to the compositor via `SCM_RIGHTS`. Compositing is GPU-first: at boot the compositor probes the renderD128 GLES bridge (shader compile via sync queries — they fail cleanly on hosts without WebGL2) and, when available, imports each client bo as a texture (`wpkEglImportDmabufHandle` + `wpkEglBindBoTexture`, re-bound only for buffers dirtied by a commit) and renders wallpaper + z-ordered window quads + focus border in a single cmdbuf flush per frame; its GL context claims the CRTC canvas and the vblank pump's presenter stands down. Without GL (Node smokes, `WLC_NO_GPU=1`, or a runtime failure — which also terminates EGL so the pump presenter resumes), it falls back to importing with `gbm_bo_import` and CPU-blitting into the scanout buffer. Either way it keeps committing PAGE_FLIPs as the frame clock. Keymaps are compiled with a wasm32 `libxkbcommon` port and handed to clients as an mmap'd fd over `wl_keyboard.keymap`. +On top of the DRM/KMS + evdev surfaces above sits a real Wayland stack that runs entirely in-kernel — no host-side Wayland. `programs/wlcompositor/` is a PID-2 server built against a wasm32 port of `libwayland-server`: it owns the `card0` scanout (via the same KMSDRM/`libgbm` path SDL2 uses), reads input from `/dev/input/event0` (keyboard) and `event1` (pointer) through a real `libinput` 1.25.0 port, and exports the core protocol plus `wl_shm`, `xdg_shell`, `wl_seat`, `wl_output`, and `zwp_linux_dmabuf_v1`. Clients connect over a Unix socket at `/tmp/wayland-0` (`/` is a read-only rootfs and `/var/run` is `EACCES` for non-root, so the well-known runtime dir is `/tmp`). Buffer sharing is zero-copy: clients allocate `wl_shm` pools backed by `gbm` dumb BOs and pass the prime-fd to the compositor via `SCM_RIGHTS`. The same prime-fd can also arrive over `zwp_linux_dmabuf_v1` (advertised at version 3, `XRGB8888`/`ARGB8888` + `LINEAR` only, no feedback): `zwp_linux_buffer_params_v1.add`/`create[_immed]` wrap the plane fd in a synthetic single-ref `shm_pool` so the resulting `wl_buffer` reuses the exact `shm_buffer` import/composite/destroy path — for a GPU-tier bo the downstream `BIND_FOREIGN_TEXTURE` is zero-copy. Compositing is GPU-first: at boot the compositor probes the renderD128 GLES bridge (shader compile via sync queries — they fail cleanly on hosts without WebGL2) and, when available, imports each client bo as a texture (`wpkEglImportDmabufHandle` + `wpkEglBindBoTexture`, re-bound only for buffers dirtied by a commit) and renders wallpaper + z-ordered window quads + focus border in a single cmdbuf flush per frame; its GL context claims the CRTC canvas and the vblank pump's presenter stands down. Without GL (Node smokes, `WLC_NO_GPU=1`, or a runtime failure — which also terminates EGL so the pump presenter resumes), it falls back to importing with `gbm_bo_import` and CPU-blitting into the scanout buffer. Either way it keeps committing PAGE_FLIPs as the frame clock. Keymaps are compiled with a wasm32 `libxkbcommon` port and handed to clients as an mmap'd fd over `wl_keyboard.keymap`. Kandelo-authored clients build on `libkwl` (`examples/libs/libkwl/`), a small toolkit over `libwayland-client` that wraps registry bind, an `xdg` CSD toplevel, double-buffered `wl_shm` back buffers, xkb keysym/UTF-8 translation, and a `kwl_dispatch` event loop; it exposes `kwl_display_fd()` so an app can `poll` the Wayland connection alongside its own fds. Drawing goes through `libwpkdraw` (`examples/libs/wpkdraw/`), a CPU rasterizer (alpha-blended clear/pixel/rect + an `stb_truetype` font engine over a bundled Inconsolata) that renders into a caller-owned ARGB buffer. `programs/wlterm/` is the first real client: a terminal that `forkpty()`s a shell (`dash`), runs an in-tree VT100 core (`vt100.c`), and multiplexes the Wayland display fd and the PTY master fd in one `poll` loop — Wayland key events become PTY writes, PTY output feeds the VT100 grid and is rendered back into the libkwl window. Because `wlterm` forks, its wasm is mandatorily processed by `wasm-fork-instrument` (see the fork-instrumentation policy in `CLAUDE.md`). The stack is dual-host: the Node smoke gates (`host/test/{wpkdraw,libkwl,wlcompositor,wlterm,wldesktop,wldesktop-liveness}-smoke.test.ts`) drive compositor+client harnesses (two-process for the single-client gates, compositor + wlclock + wlpaint for the desktop gates), and the browser demo (`/?demo=wayland`, staged by `apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts` and gated by `apps/browser-demos/test/kandelo-wayland.spec.ts`) boots the compositor plus three clients — `wlclock` (animated clock), `wlpaint` (pointer painting), and `wlterm` — against `card0` mirrored to an `OffscreenCanvas`, with DOM keyboard input injected as evdev events and the pane's pointer bridge feeding `event1`. This is the same KMS-to-canvas + `BrowserInputSource` path the `modeset` and `sdl2` demos use. The pane opts into the vblank pump's WebGL2 scanout presenter (`mode: "webgl2-scanout"`: scanout-to-texture upload with shader-side XRGB→RGB swizzle, change-driven presents gated on the kernel commit count plus a ~15 Hz content-probe backstop, and GPU scaling at the pane's device-pixel resolution); in the browser the compositor's own GLES context then claims the canvas for GPU compositing (`markKmsCanvasGlOwned` — the presenter stands down, stats slot 7 flips to 3/`webgl2-gl`), while headless/Node runs stay on the CPU-composite + presenter pipeline. A legacy `mode: "2d"` putImageData blit remains available. See [browser-support.md](browser-support.md#wayland-desktop-demo). +### Tiling window manager (`WLC_LAYOUT`, workspaces, `kwlctl`, keybinds) + +The same `wlcompositor` binary is also a Hyprland-class tiling WM (PR14); the floating desktop above is simply its default layout, so `/?demo=wayland` is unchanged. `WLC_LAYOUT=dwindle` selects the tiler. + +- **Layout engine.** `compute_tiling(area, n)` is a pure function: it partitions the output among `n` windows by recursively splitting the remaining region along its longer side (Hyprland's dwindle default — near half to window *i*, remainder carried forward), insetting an outer gap from the screen edge and an inner gap between windows. `retile()` runs it over the mapped windows on the active workspace (in map order = z-order) and pushes each dictated size through the `xdg_toplevel.configure` path; `FLOATING` mode keeps the app_id placement rules and makes `retile()` a no-op. Because the tiler is pure, the Node gate predicts the exact partition and compares it against the emitted `TILE` markers. +- **Workspaces.** Nine 1-based workspaces on the single output. Each surface carries a workspace id (assigned at first map); `surface_visible()` (mapped AND on the active workspace) gates compositing, input hit-testing, and tiling. `switch_workspace()` restores focus to the target's top window (z-order doubles as per-workspace focus memory); `move_focus_to_workspace()` sends the focused window away and re-tiles the remainder. +- **`kwlctl` IPC.** A control + event socket at `/tmp/kwlctl-0` (the hyprctl analog), polled in the compositor's `wl_event_loop` alongside the wayland + libinput fds. Verbs: `clients` / `workspaces` / `activewindow` (JSON queries), `dispatch >`, and `--listen` (a newline-delimited `event>>data` stream in Hyprland's socket2 format — `workspace>>N`, `activewindow>>app_id`). `dispatch exec` uses the non-forking `posix_spawnp` (`SYS_SPAWN`) — a `fork()` from inside an event-loop callback would wedge the server — and accepted control fds are `CLOEXEC` so they don't leak into spawned children. The CLI client is `programs/wlcompositor/kwlctl.c`. +- **Keybinds.** A config-driven bind table parsed from `WLC_CONFIG` / `/etc/kandelo/wlcompositor.conf` (a hyprland.conf-shaped subset: `bind = MODS, KEY, DISPATCHER[, ARGS]`); absent config installs generic SUPER-based defaults, not demo-specific ones. Keys are intercepted in the compositor's keyboard path before the focused client: a bind matches on the pressed key's shift-independent base keysym plus an exact modifier mask. Modifiers: `SUPER` (Mod4), `SHIFT`, `CTRL` — the self-contained xkb keymap carries `Super_L`, both Shifts, and `Control_L`. `CTRL` exists because a browser reserves the Cmd/Win (`SUPER`) key, so the in-browser demo mirrors every `SUPER` bind onto `CTRL`. Dispatchers: `exec`, `workspace`, `movetoworkspace`, `killactive`, `cyclenext`/`cycleprev` (focus cycling without z-order reordering, so a tiled layout keeps its geometry). The `exec` dispatcher is how new panes are opened Hyprland-style — a per-app launch bind rather than a launcher UI: the `/?demo=hyprland` config binds `Return`→`wlterm`, `K`→`wlclock`, `P`→`wlpaint` (each on both `SUPER` and `CTRL`), and on the keypress the compositor runs `kwlctl_exec` → `posix_spawnp` of the `/usr/local/bin` binary, which connects as a new tiled client. Because bound combos are grabbed before the focused client, a `CTRL`-letter launch bind shadows the terminal's like-named control key in-browser; the clock is deliberately on `K` (not `C`) so `Ctrl+C` SIGINT still reaches `wlterm`. A real Hyprland session drives these on `SUPER` and avoids the clash entirely. `killactive` sends `xdg_toplevel.close` to the focused window; the client is responsible for tearing its surface down (the compositor retiles once the surface is destroyed). `wlterm` closes its window immediately and hangs its shell up with `SIGHUP` — closing the pty master alone does not wake a shell blocked in `read()`, so without the explicit hangup the reap (and the tile) would block forever. +- **Server-side decoration.** The compositor advertises `zxdg_decoration_manager_v1` and negotiates the mode by layout: `dwindle` → `SERVER_SIDE` (a tiled window has no titlebar), `floating` → `CLIENT_SIDE` (the client keeps its CSD titlebar). A libkwl client honors the negotiated mode (`decoration_configure`): under SSD it sets its titlebar height to 0 and treats all pointer events as content, so the tiled desktop looks like Hyprland. +- **Client-side resize.** The compositor composites each surface at its **native** buffer size (`blit_surface` does not scale to the tile), so tiling requires the *client* to resize into the size the compositor dictates. `retile()` sends `xdg_toplevel.configure(w,h)`; libkwl records it and, on the `xdg_surface.configure` ack barrier, rebuilds both `wl_shm` buffers at the new size and pushes a `KWL_RESIZE` event (new content w/h). Clients react: `wlclock` recomputes its dial geometry, `wlterm` reflows its VT100 grid (`vt100_resize` + `TIOCSWINSZ` + `SIGWINCH`), `wlpaint` reallocates its canvas (preserving the painting) so the toolbar + drawing area fill the whole tile rather than a fixed 640×420 corner. The initial `get_toplevel` `configure(0,0)` ("you decide") is ignored, so a floating client (`/?demo=wayland`) never resizes and is byte-identical to before. + +These are entirely in-kernel (client↔compositor over the wayland + `/tmp/kwlctl-0` sockets) — no host-runtime change — and gated by `host/test/wlcompositor-{tiling,resize,kwlctl,keybind,decoration}-smoke.test.ts`. The browser demo (`/?demo=hyprland`, staged by `live-setup.ts` with `WLC_LAYOUT=dwindle` + a staged `/etc/kandelo/wlcompositor.conf`, gated by `apps/browser-demos/test/kandelo-hyprland.spec.ts`) boots the same compositor plus a `wlclock` and two `wlterm` terminals, which tile into gapped borderless frames and resize into their tiles — the first end-to-end Hyprland-class desktop. `wlpaint` is also staged (not auto-spawned) so the `Ctrl+P` launch bind can summon it on demand. See [browser-support.md](browser-support.md#hyprland-tiling-demo). + ## Signal Subsystem Signals are delivered at syscall boundaries. When a process has a pending signal: diff --git a/docs/browser-support.md b/docs/browser-support.md index 7d9cc6cffe..6c910141da 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -324,6 +324,7 @@ Located in `apps/browser-demos/pages/`: | sdl2 | SDL2 GLSL playground | dinit | Live-coding shader editor on SDL2's KMSDRM backend: gap-buffer editor left, GLES2 fragment shader on `/dev/dri/card0` right, chip synth / sound shader through `/dev/dsp`. The binary comes from the `sdl2-demo` package and is baked into the image with its shader presets before boot. A `BrowserInputSource` feeds the keyboard and wheel into `/dev/input/event{0,1}`; the Modeset pane owns the pointer and injects framebuffer-absolute coordinates via `sendPointerAbs`. | | modeset | modeset.c | `kernel.boot` + spawn | Minimal KMS client: opens `/dev/dri/card0`, becomes DRM master, allocates dumb buffers, draws an animated gradient, and commits real `drmModePageFlip` ioctls. The Modeset pane bridges the CRTC to an OffscreenCanvas and shows a live PAGE_FLIP counter chip. | | wayland | wlcompositor + wlclock + wlpaint + wlterm | `kernel.boot` + spawn | Full Wayland desktop — see [Wayland desktop demo](#wayland-desktop-demo) below. | +| hyprland | wlcompositor (dwindle) + wlclock + 2× wlterm (+ wlpaint via keybind) | `kernel.boot` + spawn | Hyprland-class tiling desktop; `Ctrl+Return`/`Ctrl+K`/`Ctrl+P` open new terminal/clock/paint panes — see [Hyprland tiling demo](#hyprland-tiling-demo) below. | The "Boot pattern" column reflects how the demo enters the kernel: - **`kernel.boot`** — `kernelOwnedFs: true`, exec the language interpreter as the first user process. @@ -435,6 +436,59 @@ PAGE_FLIP counter, and flicker stability via canvas PNG-size distribution) and the node-side twins under `host/test/wl*-smoke.test.ts` (including `wldesktop-liveness-smoke.test.ts`). +### Hyprland tiling demo + +`/?demo=hyprland` boots the same `wlcompositor` binary as a Hyprland-class +tiling window manager (the floating `/?demo=wayland` desktop above is its +default layout). The staging block sets `WLC_LAYOUT=dwindle` in the +compositor's environment and stages a hyprland.conf-shaped +`/etc/kandelo/wlcompositor.conf` (read via `WLC_CONFIG`), then spawns three +real clients — one `wlclock` and two `wlterm` terminals: + +- **Dwindle tiling.** Every mapped window is retiled into gapped, borderless + frames by recursively splitting the remaining region along its longer side + (Hyprland's dwindle default), across nine 1-based workspaces. +- **Client-side resize (the crux).** The compositor composites each surface + at its native buffer size — it does not scale a window to its tile — so + tiling requires the *client* to resize. On each retile the compositor + sends `xdg_toplevel.configure(w,h)`; the libkwl clients rebuild their + `wl_shm` buffers to match and redraw (`wlclock` recomputes its dial, + `wlterm` reflows its VT100 grid via `TIOCSWINSZ` + `SIGWINCH`, `wlpaint` + reallocates its canvas so the toolbar + drawing area fill the whole tile + instead of a fixed 640×420 corner). Floating clients ignore the initial + `configure(0,0)`, so `/?demo=wayland` is unchanged. +- **Server-side decorations.** Under `dwindle` the compositor negotiates + `SERVER_SIDE` decorations, so tiled windows have no titlebar (a floating + layout keeps client-side CSD). +- **Keybinds.** `Return` launches a terminal, `W` kills the focused window, + and `1..9` switch workspaces — bound on both `SUPER` (real Hyprland) and + `CTRL` in the staged `wlcompositor.conf`. Use **`CTRL`** in the browser: + the OS/browser reserve `SUPER` (Cmd/Win) — `Cmd+W` closes the tab, + `Cmd+1..9` switch browser tabs — so those never reach the page, while + `Ctrl+…` does. The compositor also supports move-to-workspace, focus + cycling, and a `kwlctl` control socket (the `hyprctl` analog), which this + demo doesn't bind — see architecture.md. +- **New-pane launch keybinds.** Opening a new pane is done Hyprland-style — + each app has its own `exec` bind rather than a launcher/`rofi` UI: + `Return`→`wlterm`, `K`→`wlclock` (K as in clo**K** — see the caveat), + `P`→`wlpaint` (again on both `SUPER` and `CTRL`). Pressing the combo makes + the compositor `posix_spawnp` the binary from `/usr/local/bin`, and the new + client tiles into the layout. `wlpaint` is staged solely for this path — + unlike `/?demo=wayland` it is not auto-spawned into the initial layout, so + `Ctrl+P` is how you summon it. + **Caveat:** the compositor grabs a bound combo before the focused client, + so a `CTRL`+letter launch bind shadows the terminal's like-named control key. + The clock is bound to `K` (not `C`) precisely to leave `Ctrl+C` (SIGINT) to + the terminal; `Ctrl+W` (killactive) does still shadow `wlterm`'s werase. + That is the cost of using `CTRL` as the WM modifier in-browser; a real + Hyprland session on `SUPER` has no such clash. + +See +[architecture.md](architecture.md#tiling-window-manager-wlc_layout-workspaces-kwlctl-keybinds). +The tiling paths are gated node-side by +`host/test/wlcompositor-{tiling,resize,kwlctl,keybind,decoration}-smoke.test.ts` +and in the browser by `apps/browser-demos/test/kandelo-hyprland.spec.ts`. + Run the browser app: `cd apps/browser-demos && npm run dev`, then open `http://127.0.0.1:5401/`. diff --git a/docs/plans/2026-06-17-sdl2-glsl-playground-plan.md b/docs/plans/2026-06-17-sdl2-glsl-playground-plan.md index 289c977b7d..9501ce9d7c 100644 --- a/docs/plans/2026-06-17-sdl2-glsl-playground-plan.md +++ b/docs/plans/2026-06-17-sdl2-glsl-playground-plan.md @@ -213,7 +213,7 @@ All items implemented (uncommitted on `explore-dri-sdl2`): - ✅ Preset dropdown: Ctrl+L cycles the next preset for the active mode; Ctrl+Shift+L opens a modal chooser overlay (Up/Down + Enter + Esc). Lists `*.frag` under `/usr/share/shaders/{image,sound}` via `readdir`. (`main.c` preset browser.) - ✅ Boot splash over the render pane (fades out) + persistent "SDL2 GLSL Playground" title in the render-pane corner. (`main.c` render section.) -Headless gate `cd host && npx vitest run sdl2` stays 4/4; full vitest + cargo + ABI not yet re-run for this batch. +Headless gate `cd host && npx vitest run sdl2` stays 4/4. ### Phase 9 — Verification + docs (1 day) diff --git a/docs/plans/2026-07-08-dri-wayland-compositor-plan.md b/docs/plans/2026-07-08-dri-wayland-compositor-plan.md index d524264e17..ee7f0a6d44 100644 --- a/docs/plans/2026-07-08-dri-wayland-compositor-plan.md +++ b/docs/plans/2026-07-08-dri-wayland-compositor-plan.md @@ -446,9 +446,10 @@ libwayland is the first consumer to hit: ### Gaps discovered + fixed during the desktop demo hardening (post-PR7) Running the full three-client desktop (`/?demo=wayland`) as a real user — -typing, dragging windows, drag-painting, leaving it idle — surfaced five -more defects that no marker-based gate caught. All are fixed on this branch; -the first three live in shared kernel/host code, so both hosts get them. +typing, dragging windows, drag-painting, leaving it idle — surfaced the ten +findings below (most of them real defects) that no marker-based gate caught. +All are addressed on this branch; the first four live in shared kernel/host +code, so both hosts get them. 1. **Blocking-poll timeouts never expired** (`host/src/kernel-worker.ts`). The EAGAIN retry loop re-entered `handleBlockingRetry` / `handleSelect` / @@ -549,7 +550,8 @@ the first three live in shared kernel/host code, so both hosts get them. New permanent gates from this pass: `host/test/wldesktop-liveness-smoke.test.ts` (node: PAGE_FLIP commits keep advancing across drag-paint strokes) and -`kandelo-wayland.spec.ts` gates 1c (webgl2 renderer active), 3 (per-step +`kandelo-wayland.spec.ts` gates 1c (webgl2 renderer active), 1d (the +compositor composites on the GPU — WLC_RENDERER gpu), 3 (per-step corner stability during the window drag), 4 (drag-paint liveness), and 5 (flicker stability). diff --git a/docs/posix-status.md b/docs/posix-status.md index 98c660ce67..b0bd43f10d 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -266,7 +266,7 @@ shortcuts. | Interface | Status | Notes | |-----------|--------|-------| -| `/dev/dri/renderD128` | Partial | Render-node subset for `libdrm`, GBM, EGL, and GLES. GEM handles are fd-local. BO mmap offsets must come from `DRM_IOCTL_MODE_MAP_DUMB` on the same open file description. GLIO command buffers live in process memory and are unbound on `munmap`, `exec`, `exit`, and final fd close. The WPK extension `DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE` (re)uploads a CPU-tier bo's pixels as a `WebGLTexture` in the caller's GL context — the wlcompositor GPU-compositing path; `WPK_CREATE_GPU_BO` stays ENOSYS. | +| `/dev/dri/renderD128` | Partial | Render-node subset for `libdrm`, GBM, EGL, and GLES. GEM handles are fd-local. BO mmap offsets must come from `DRM_IOCTL_MODE_MAP_DUMB` on the same open file description. GLIO command buffers live in process memory and are unbound on `munmap`, `exec`, `exit`, and final fd close. The WPK extension `DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE` (re)uploads a CPU-tier bo's pixels as a `WebGLTexture` in the caller's GL context — the wlcompositor GPU-compositing path (on a GPU-tier bo it returns the texture id with no upload). `DRM_IOCTL_WPK_CREATE_GPU_BO` allocates a GPU-tier bo backed by a host `WebGLTexture`+FBO on the compositor's shared multiplexer context — unmappable on the CPU, rendered into by a routed client and sampled zero-copy; allocation degrades to a CPU-tier dumb bo when no shared GL context exists (headless Node, or before the compositor's context is up). `GLIO_CREATE_SURFACE`'s `reserved[0]` optionally names a target bo whose FBO the surface renders into (the client's default framebuffer is redirected there). | | `/dev/dri/card0` | Partial | Single virtual KMS device with one connector, encoder, and CRTC. Supports dumb buffers, `ADDFB2`/`RMFB`, DRM master, `SET_CRTC`, `PAGE_FLIP`, vblank event reads, and host-provided mode info for the attached KMS canvas. The preferred mode defaults to 1920×1080@60 and follows the embedder-reported display aspect (width `round(1080 × aspect)` clamped [1440, 3840] at 1080 tall) when the host has one. `PAGE_FLIP` latches the host scanout at ioctl time; flip-complete events retire at the host's 60 Hz vblank tick. `SCM_RIGHTS` can pass a PRIME fd's bo sidecar between processes. Multi-head, real display probing, and hardware acceleration are out of scope for v1. | | Sysroot graphics libraries | Partial | `scripts/build-musl.sh` builds `libdrm.a`, `libgbm.a`, `libEGL.a`, and `libGLESv2.a` into `sysroot/lib` with pkg-config files. Packages consume these via `wasm32posix-pkg-config`; the libraries are not standalone package outputs. | diff --git a/examples/libs/libkwl/include/kwl.h b/examples/libs/libkwl/include/kwl.h index 26e4e38a61..9be785b4a3 100644 --- a/examples/libs/libkwl/include/kwl.h +++ b/examples/libs/libkwl/include/kwl.h @@ -8,16 +8,24 @@ * commit, and pump input events. See * docs/plans/2026-07-09-dri-pr7-libkwl-wlterm-plan.md §4. * - * Scope: a single fixed-size toplevel per connection, software rendering - * into a wl_shm buffer the compositor imports via gbm, keyboard (keysym + - * UTF-8) and pointer (motion + button) input. No surface resize. + * Scope: a single toplevel per connection, software rendering into a wl_shm + * buffer the compositor imports via gbm, keyboard (keysym + UTF-8) and + * pointer (motion + button) input. * - * Decoration is client-side (CSD): libkwl draws a KWL_TITLEBAR_H-px - * titlebar (title text + close box) above the app's content. The app draws - * only the content area — kwl_window_surface() is w×h as requested — and - * receives pointer coordinates content-local. Pressing the close box emits - * KWL_CLOSE; dragging the titlebar hands the interaction to the compositor - * via xdg_toplevel.move (the window moves; the app sees nothing). + * Decoration follows the compositor's zxdg_decoration negotiation. When it + * grants CLIENT_SIDE (the floating desktop) libkwl draws its own + * KWL_TITLEBAR_H-px titlebar — title text + close box — above the content; + * pressing the close box emits KWL_CLOSE and dragging the titlebar hands the + * interaction to the compositor via xdg_toplevel.move. When it grants + * SERVER_SIDE (a tiling WM) libkwl drops the titlebar entirely: the whole + * surface is content and the compositor draws the border/focus ring. + * + * Resize: a tiling compositor dictates each window's geometry through the + * xdg configure path. libkwl reallocates its buffers to the new size and + * delivers a KWL_RESIZE event carrying the new content dimensions; the app + * re-lays-out and redraws. A floating compositor sends a 0×0 "you decide" + * configure, so a floating window keeps the size it asked for and never + * sees KWL_RESIZE. */ #ifndef KWL_H #define KWL_H @@ -40,6 +48,7 @@ enum kwl_event_type { KWL_POINTER_BUTTON, /* pointer button transition at (x, y) */ KWL_CLOSE, /* the toplevel was asked to close */ KWL_FRAME, /* a committed frame was presented */ + KWL_RESIZE, /* content size changed to (x, y) — realloc'd buffer */ }; /* Modifier bitmask for kwl_event.mods (effective state at the event). */ @@ -53,16 +62,17 @@ struct kwl_event { uint32_t mods; /* KWL_KEY/KWL_TEXT: KWL_MOD_* bitmask */ uint32_t button; /* KWL_POINTER_BUTTON: a linux BTN_* code */ uint32_t state; /* KWL_KEY/KWL_POINTER_BUTTON: 1 = down, 0 = up */ - int x, y; /* KWL_POINTER_*: surface-local pointer position */ + int x, y; /* KWL_POINTER_*: pointer pos; KWL_RESIZE: new w, h */ char utf8[8]; /* KWL_TEXT: NUL-terminated UTF-8 */ }; -/* Connect to the compositor (/tmp/wayland-0) and map a single CSD toplevel - * with a w×h CONTENT area (the surface is KWL_TITLEBAR_H taller). `title` - * is drawn in the titlebar and doubles as the xdg app_id, which the - * compositor's placement rules key on. Blocks until the initial xdg - * configure is acked and the wl_shm buffers are ready. Returns NULL on - * failure. */ +/* Connect to the compositor (/tmp/wayland-0) and map a toplevel with a w×h + * CONTENT area (the surface is taller by the titlebar the compositor grants, + * 0 under server-side decoration). `title` is drawn in the titlebar and + * doubles as the xdg app_id, which the compositor's placement rules key on. + * Blocks until the initial xdg configure is acked and the wl_shm buffers are + * ready. A tiling compositor may then resize the window (KWL_RESIZE). Returns + * NULL on failure. */ struct kwl_window *kwl_window_create(const char *title, int w, int h); void kwl_window_destroy(struct kwl_window *win); diff --git a/examples/libs/libkwl/src/kwl.c b/examples/libs/libkwl/src/kwl.c index 7ea3bf4bd6..41afd966ab 100644 --- a/examples/libs/libkwl/src/kwl.c +++ b/examples/libs/libkwl/src/kwl.c @@ -14,14 +14,17 @@ * - pointer enter/motion/button * events land in a fixed ring the app pops via kwl_dispatch(). * - * Client-side decoration (CSD): every window carries a KWL_TITLEBAR_H-px - * titlebar libkwl draws once into each buffer — title text plus a close - * box. The buffer the compositor sees is (w × h+titlebar); the app's - * kwl_window_surface() is a sub-view starting below the titlebar, and all - * pointer events the app receives are content-local. A press on the - * titlebar is not forwarded: it either emits KWL_CLOSE (on the close box) - * or asks the compositor to start an interactive move via - * xdg_toplevel.move — the standard Wayland CSD drag contract. */ + * Decoration is negotiated: tb_h is the titlebar height the compositor + * grants — KWL_TITLEBAR_H under client-side decoration, 0 under server-side + * (a tiling WM draws its own border). When present, libkwl draws the + * titlebar once into each buffer (title text plus a close box); the buffer + * the compositor sees is (w × h+tb_h), the app's kwl_window_surface() is a + * sub-view starting below it, and pointer events reach the app content-local. + * A press on the titlebar is not forwarded: it emits KWL_CLOSE (close box) or + * starts an interactive move via xdg_toplevel.move — the CSD drag contract. + * + * Resize: on a compositor-dictated configure, kwl_apply_resize() rebuilds + * both buffers at the new size and posts KWL_RESIZE for the app to re-lay-out. */ #include #include #include @@ -36,6 +39,7 @@ #include #include #include "xdg-shell-client-protocol.h" +#include "xdg-decoration-v1-client-protocol.h" #include #include @@ -75,10 +79,16 @@ struct kwl_window { struct xdg_toplevel *toplevel; struct wl_keyboard *keyboard; struct wl_pointer *pointer; + struct zxdg_decoration_manager_v1 *decor_mgr; + struct zxdg_toplevel_decoration_v1 *decor; + char *title; /* retained: redrawn into the titlebar on resize */ int w, h; /* app-visible CONTENT size */ - int total_h; /* h + KWL_TITLEBAR_H — the wl_surface size */ + int tb_h; /* titlebar height: 0 under server-side decoration */ + int total_h; /* h + tb_h — the wl_surface size */ int configured; + int mapped; /* first buffer committed; a later configure = resize */ + int pending_w, pending_h; /* last configure's surface size (0 = keep) */ /* gbm allocation for the shared wl_shm buffers. */ int render_fd; @@ -101,6 +111,9 @@ struct kwl_window { int evq_head, evq_tail, evq_count; }; +/* Reallocate the buffers to a new content size and post KWL_RESIZE. */ +static void kwl_apply_resize(struct kwl_window *w, int cw, int ch); + /* ---- event ring -------------------------------------------------------- */ static void kwl_push(struct kwl_window *w, const struct kwl_event *e) { @@ -134,6 +147,9 @@ static void registry_global(void *data, struct wl_registry *reg, uint32_t name, w->seat = wl_registry_bind(reg, name, &wl_seat_interface, 1); else if (strcmp(iface, "wl_output") == 0) w->output = wl_registry_bind(reg, name, &wl_output_interface, 2); + else if (strcmp(iface, "zxdg_decoration_manager_v1") == 0) + w->decor_mgr = wl_registry_bind( + reg, name, &zxdg_decoration_manager_v1_interface, 1); } static void registry_global_remove(void *data, struct wl_registry *r, uint32_t name) {} @@ -151,20 +167,50 @@ static const struct xdg_wm_base_listener wm_base_listener = { .ping = wm_base_ping, }; +/* The compositor grants a decoration mode: SERVER_SIDE means it draws the + * border itself, so we drop our CSD titlebar (tb_h 0); CLIENT_SIDE keeps it. + * Delivered once, in the create roundtrip before the buffers are sized. */ +static void decoration_configure(void *data, + struct zxdg_toplevel_decoration_v1 *d, + uint32_t mode) { + struct kwl_window *w = data; + w->tb_h = mode == ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE + ? 0 + : KWL_TITLEBAR_H; +} +static const struct zxdg_toplevel_decoration_v1_listener decoration_listener = { + .configure = decoration_configure, +}; + +/* xdg_surface.configure is the commit barrier for a batch of toplevel state. + * The pending toplevel size (if the compositor dictated one) takes effect + * here: once mapped, a size that differs from the current surface is a + * tiling resize. */ static void xdg_surface_configure(void *data, struct xdg_surface *xs, uint32_t serial) { struct kwl_window *w = data; xdg_surface_ack_configure(xs, serial); w->configured = 1; + if (w->mapped && w->pending_w > 0 && w->pending_h > 0) { + int cw = w->pending_w, ch = w->pending_h - w->tb_h; + if (ch > 0 && (cw != w->w || ch != w->h)) + kwl_apply_resize(w, cw, ch); + } + w->pending_w = w->pending_h = 0; } static const struct xdg_surface_listener xdg_surface_listener = { .configure = xdg_surface_configure, }; -/* v1 ignores the compositor's suggested size — the window keeps the size - * the app requested (surfaces are fixed-size in v1). */ +/* Record the compositor's suggested surface size; 0×0 ("you decide", the + * floating case) leaves the window at the size the app asked for. The resize + * itself is deferred to the xdg_surface.configure barrier above. */ static void toplevel_configure(void *data, struct xdg_toplevel *t, int32_t w, - int32_t h, struct wl_array *states) {} + int32_t h, struct wl_array *states) { + struct kwl_window *win = data; + win->pending_w = w; + win->pending_h = h; +} static void toplevel_close(void *data, struct xdg_toplevel *t) { struct kwl_window *win = data; struct kwl_event e = { .type = KWL_CLOSE }; @@ -284,10 +330,10 @@ static const struct wl_keyboard_listener keyboard_listener = { /* ---- pointer ----------------------------------------------------------- */ -/* The close box rect, in surface coordinates. */ +/* The close box rect, in surface coordinates (CSD only). */ static int in_close_box(struct kwl_window *w, int x, int y) { int bx = w->w - KWL_TB_CLOSE_MARGIN - KWL_TB_CLOSE_SZ; - int by = (KWL_TITLEBAR_H - KWL_TB_CLOSE_SZ) / 2; + int by = (w->tb_h - KWL_TB_CLOSE_SZ) / 2; return x >= bx && x < bx + KWL_TB_CLOSE_SZ && y >= by && y < by + KWL_TB_CLOSE_SZ; } @@ -305,11 +351,11 @@ static void ptr_motion(void *data, struct wl_pointer *p, uint32_t time, struct kwl_window *w = data; w->ptr_x = wl_fixed_to_int(x); w->ptr_y = wl_fixed_to_int(y); - if (w->ptr_y < KWL_TITLEBAR_H) return; /* decoration, not app content */ + if (w->ptr_y < w->tb_h) return; /* decoration, not app content */ struct kwl_event e = { .type = KWL_POINTER_MOTION, .x = w->ptr_x, - .y = w->ptr_y - KWL_TITLEBAR_H, + .y = w->ptr_y - w->tb_h, }; kwl_push(w, &e); } @@ -317,7 +363,7 @@ static void ptr_button(void *data, struct wl_pointer *p, uint32_t serial, uint32_t time, uint32_t button, uint32_t state) { struct kwl_window *w = data; if (state == WL_POINTER_BUTTON_STATE_PRESSED && - w->ptr_y < KWL_TITLEBAR_H) { + w->ptr_y < w->tb_h) { /* Titlebar interactions are the toolkit's, not the app's. */ if (in_close_box(w, w->ptr_x, w->ptr_y)) { struct kwl_event e = { .type = KWL_CLOSE }; @@ -339,7 +385,7 @@ static void ptr_button(void *data, struct wl_pointer *p, uint32_t serial, .button = button, .state = state, .x = w->ptr_x, - .y = w->ptr_y - KWL_TITLEBAR_H, + .y = w->ptr_y - w->tb_h, }; kwl_push(w, &e); } @@ -406,11 +452,22 @@ static int kwl_buffer_init(struct kwl_window *w, struct kwl_buffer *b) { return 0; } -/* The app's drawable: the buffer rows below the titlebar. */ +/* Release one buffer's bo + wl_buffer (the inverse of kwl_buffer_init). */ +static void kwl_buffer_fini(struct kwl_buffer *b) { + if (b->wl_buf) wl_buffer_destroy(b->wl_buf); + if (b->bo) { + if (b->map_data) gbm_bo_unmap(b->bo, b->map_data); + gbm_bo_destroy(b->bo); + } + memset(b, 0, sizeof(*b)); +} + +/* The app's drawable: the buffer rows below the titlebar (all rows under + * server-side decoration, where tb_h is 0). */ static struct wpk_surface content_view(struct kwl_window *w, struct kwl_buffer *b) { return wpk_surface_wrap( - b->pixels + (size_t)KWL_TITLEBAR_H * (b->stride / 4), w->w, w->h, + b->pixels + (size_t)w->tb_h * (b->stride / 4), w->w, w->h, b->stride); } @@ -450,7 +507,8 @@ struct kwl_window *kwl_window_create(const char *title, int w, int h) { if (!win) { errno = ENOMEM; return NULL; } win->w = w; win->h = h; - win->total_h = h + KWL_TITLEBAR_H; + win->tb_h = KWL_TITLEBAR_H; /* CSD default; SSD negotiation zeroes it */ + if (title) win->title = strdup(title); int fd = connect_socket(); if (fd < 0) goto fail; @@ -486,12 +544,26 @@ struct kwl_window *kwl_window_create(const char *title, int w, int h) { /* The compositor's placement rules key on app_id. */ xdg_toplevel_set_app_id(win->toplevel, title); } + + /* Ask for a decoration so the compositor tells us whether it draws the + * border (SSD → no titlebar) or we do (CSD). It answers with a configure + * that decoration_configure() folds into tb_h before we size buffers. */ + if (win->decor_mgr) { + win->decor = zxdg_decoration_manager_v1_get_toplevel_decoration( + win->decor_mgr, win->toplevel); + if (win->decor) + zxdg_toplevel_decoration_v1_add_listener( + win->decor, &decoration_listener, win); + } wl_surface_commit(win->surface); - /* Wait for the initial configure before attaching a buffer. */ + /* Wait for the initial configure (+ the decoration mode) before sizing + * and attaching a buffer. */ while (!win->configured) if (wl_display_dispatch(win->display) < 0) goto fail; + win->total_h = h + win->tb_h; + /* gbm-backed double buffer. */ win->render_fd = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); if (win->render_fd < 0) goto fail; @@ -500,11 +572,14 @@ struct kwl_window *kwl_window_create(const char *title, int w, int h) { for (int i = 0; i < KWL_NUM_BUFFERS; i++) if (kwl_buffer_init(win, &win->bufs[i]) != 0) goto fail; - /* Decorate both buffers once; the app only ever draws the content. */ - struct wpk_font *tb_font = wpk_font_load_default(KWL_TB_FONT_PX); - for (int i = 0; i < KWL_NUM_BUFFERS; i++) - draw_titlebar(win, &win->bufs[i], title, tb_font); - if (tb_font) wpk_font_destroy(tb_font); + /* Decorate both buffers once (skipped under SSD, tb_h 0); the app only + * ever draws the content. */ + if (win->tb_h > 0) { + struct wpk_font *tb_font = wpk_font_load_default(KWL_TB_FONT_PX); + for (int i = 0; i < KWL_NUM_BUFFERS; i++) + draw_titlebar(win, &win->bufs[i], title, tb_font); + if (tb_font) wpk_font_destroy(tb_font); + } win->back_index = 0; win->back = content_view(win, &win->bufs[0]); @@ -517,19 +592,16 @@ struct kwl_window *kwl_window_create(const char *title, int w, int h) { void kwl_window_destroy(struct kwl_window *win) { if (!win) return; - for (int i = 0; i < KWL_NUM_BUFFERS; i++) { - struct kwl_buffer *b = &win->bufs[i]; - if (b->wl_buf) wl_buffer_destroy(b->wl_buf); - if (b->bo) { - if (b->map_data) gbm_bo_unmap(b->bo, b->map_data); - gbm_bo_destroy(b->bo); - } - } + for (int i = 0; i < KWL_NUM_BUFFERS; i++) + kwl_buffer_fini(&win->bufs[i]); if (win->gbm) gbm_device_destroy(win->gbm); if (win->render_fd > 0) close(win->render_fd); if (win->xkb_state) xkb_state_unref(win->xkb_state); if (win->xkb_keymap) xkb_keymap_unref(win->xkb_keymap); if (win->xkb_ctx) xkb_context_unref(win->xkb_ctx); + if (win->decor) zxdg_toplevel_decoration_v1_destroy(win->decor); + if (win->decor_mgr) zxdg_decoration_manager_v1_destroy(win->decor_mgr); + free(win->title); if (win->toplevel) xdg_toplevel_destroy(win->toplevel); if (win->xdg_surface) xdg_surface_destroy(win->xdg_surface); if (win->surface) wl_surface_destroy(win->surface); @@ -541,7 +613,34 @@ struct wpk_surface *kwl_window_surface(struct kwl_window *win) { return &win->back; } +/* A tiling compositor dictated a new size: rebuild both buffers, redraw the + * titlebar (CSD only), reset the back buffer, and hand the app a KWL_RESIZE + * so it re-lays-out its content. The app's next kwl_window_commit presents + * the new geometry. */ +static void kwl_apply_resize(struct kwl_window *w, int cw, int ch) { + for (int i = 0; i < KWL_NUM_BUFFERS; i++) + kwl_buffer_fini(&w->bufs[i]); + w->w = cw; + w->h = ch; + w->total_h = ch + w->tb_h; + for (int i = 0; i < KWL_NUM_BUFFERS; i++) + if (kwl_buffer_init(w, &w->bufs[i]) != 0) return; /* OOM: leave broken */ + + if (w->tb_h > 0) { + struct wpk_font *tb_font = wpk_font_load_default(KWL_TB_FONT_PX); + for (int i = 0; i < KWL_NUM_BUFFERS; i++) + draw_titlebar(w, &w->bufs[i], w->title, tb_font); + if (tb_font) wpk_font_destroy(tb_font); + } + + w->back_index = 0; + w->back = content_view(w, &w->bufs[0]); + struct kwl_event e = { .type = KWL_RESIZE, .x = cw, .y = ch }; + kwl_push(w, &e); +} + void kwl_window_commit(struct kwl_window *win) { + win->mapped = 1; /* first commit maps; later configures are resizes */ struct kwl_buffer *b = &win->bufs[win->back_index]; wl_surface_attach(win->surface, b->wl_buf, 0, 0); wl_surface_damage(win->surface, 0, 0, win->w, win->total_h); diff --git a/host/src/dri/kms-registry.ts b/host/src/dri/kms-registry.ts index fb76f4e3c1..7875fb02b0 100644 --- a/host/src/dri/kms-registry.ts +++ b/host/src/dri/kms-registry.ts @@ -87,6 +87,10 @@ export class KmsRegistry { setMasterPid(pid: number): void { this.masterPid = pid; } dropMaster(): void { this.masterPid = null; } isMasterPid(pid: number): boolean { return this.masterPid === pid; } + /** The pid currently holding DRM master, or null. The compositor's + * scanout context is the shared GPU-bo multiplexer context, so the + * host resolves it via this pid. */ + getMasterPid(): number | null { return this.masterPid; } /** First CRTC with an FB bound for which `pid` holds DRM master. * Null if `pid` is not master or no CRTC has an FB yet. The kernel diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 40b395bef0..3b4118b46d 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -1192,6 +1192,19 @@ export class WasmPosixKernel { this.callbacks.markKmsCanvasGlReleased?.(crtc); } + /** The shared multiplexer context that backs GPU-tier bos: the + * DRM-master compositor's scanout WebGL2 context. WebGL textures are + * not shareable across contexts, so every GPU-bo texture (and the FBO + * its producer renders into) must live here for the compositor to + * sample it zero-copy. Null when no pid holds master, or the master + * has no live context yet (headless Node, or before the compositor's + * `eglCreateContext`) — the GPU-bo path then degrades to CPU tier. */ + #sharedGlContext(): WebGL2RenderingContext | null { + const masterPid = this.kms.getMasterPid(); + if (masterPid == null) return null; + return this.gl.get(masterPid)?.gl ?? null; + } + #createKernelMemory(pointerWidth: 4 | 8): WebAssembly.Memory { if (pointerWidth === 8) { return new IntrinsicWasmMemory({ @@ -1995,11 +2008,40 @@ export class WasmPosixKernel { return -12; // ENOMEM } }, + // WPK_CREATE_GPU_BO: allocate a GPU-tier bo backed by a + // WebGLTexture (+FBO) on the shared multiplexer context — no SAB, + // unmappable, sampled zero-copy by the compositor and rendered + // into by its producer (PR10). + // + // The shared context is the DRM-master compositor's scanout + // context (`sharedGlContext`). When it is absent — headless Node + // (no WebGL at all), or the browser before the compositor's + // `eglCreateContext` — we return -ENOSYS so the kernel rolls back + // its registry allocation and libgbm falls back to a CPU-tier + // dumb bo. That fallback is the correct behavior, not a defect. + // A createGpuBo failure (context couldn't allocate the objects) + // returns -ENOMEM, likewise rolled back by the kernel. + host_gbm_gpu_bo_create: ( + _pid: number, + bo_id: number, + width: number, + height: number, + _format: number, + _usage: number, + ): number => { + const ctx = this.#sharedGlContext(); + if (!ctx) return -38; // -ENOSYS → guest falls back to CPU tier + const texId = this.gl.createGpuBo(bo_id, ctx, width, height); + if (texId == null) return -12; // -ENOMEM + return 0; + }, host_gbm_bo_destroy: (pid: number, bo_id: number): void => { // The bo owns any foreign textures bound from it (see shared's // BIND_FOREIGN_TEXTURE doc) — drop them across all GL bindings - // before the pixel SAB goes away. + // before the pixel SAB goes away. A GPU-tier bo instead owns a + // texture+FBO on the shared context; release that too. this.gl.dropForeignTexturesForBo(bo_id); + this.gl.destroyGpuBo(bo_id); this.bos.destroy(pid, bo_id); }, host_gbm_bo_bind: ( @@ -2071,6 +2113,32 @@ export class WasmPosixKernel { b.forward.onCreateContext(); return; } + // GPU-tier producer routing (PR10 §7.1): a non-master GL client + // has no display canvas to build a context on. Route it onto + // the shared multiplexer context — the DRM-master compositor's + // scanout context — so its GPU-bo FBO renders live on the same + // context the compositor samples zero-copy. The existing + // `GlMuxer` (keyed by the WebGL2 context) multiplexes the two + // sessions by replaying each binding's shadow on `switchTo`. + // + // Ordering: the compositor (DRM master) must have created its + // context first, or `sharedGlContext()` is null and we fall + // through — leaving `b.gl = null`, i.e. inert (submit/query + // no-op), exactly as before. That also keeps this path inert on + // headless Node (no WebGL at all). The client's render target + // is redirected to its bo's FBO at `gl_create_surface` time; the + // viewport is seeded from the bo dims there. + if (!b.canvas && !this.kms.isMasterPid(pid)) { + const shared = this.#sharedGlContext(); + if (shared) { + b.gl = shared; + // The window surface may have been created before this + // context (SDL2 order); apply any pending GPU-bo render + // target now that `b.gl` exists. + this.gl.applyRenderTarget(b); + return; + } + } let claimedCrtc: number | null = null; if (!b.canvas) { // Auto-attach the KMS scanout canvas if this pid holds DRM @@ -2145,7 +2213,8 @@ export class WasmPosixKernel { const b = this.gl.get(pid); if (!b) return; b.surfaceId = surfaceId; - // GlSurfaceAttrs: u32 kind, width, height, config_id, … + // GlSurfaceAttrs: u32 kind, width, height, config_id, + // reserved[0]=target bo_id, reserved[1..4]. // Non-zero width/height is an explicit drawing-buffer size // request (libEGL forwards EGL_WIDTH/EGL_HEIGHT window-surface // attribs). A KMS compositor creates its surface before its @@ -2156,20 +2225,49 @@ export class WasmPosixKernel { attrsLen, "host_gl_create_surface attrs length", ); - if (b.canvas && attrsBytes >= 12) { - const attrs = this.#readKernelBytes(attrsPtr, 12); - const dv = new DataView(attrs.buffer, attrs.byteOffset, 12); + if (attrsBytes >= 20) { + const attrs = this.#readKernelBytes(attrsPtr, 20); + const dv = new DataView(attrs.buffer, attrs.byteOffset, 20); const w = dv.getUint32(4, true); const h = dv.getUint32(8, true); - if (w > 0 && h > 0 && (b.canvas.width !== w || b.canvas.height !== h)) { + if (b.canvas && w > 0 && h > 0 && (b.canvas.width !== w || b.canvas.height !== h)) { b.canvas.width = w; b.canvas.height = h; } + // GPU-tier producer target: reserved[0] is the global bo_id + // (the kernel already translated the per-fd handle) whose FBO + // this window surface renders into. When it names a GPU bo on + // this session's shared context, redirect the client's default + // framebuffer to that FBO and size its viewport to the bo. A 0 + // id (the common canvas/scanout case) leaves the target unset. + // Capture the target and apply the redirect. SDL2's + // Wayland+GLES backend creates the window surface (here, + // during SDL_CreateWindow) BEFORE the GL context (during + // SDL_GL_CreateContext), so `b.gl` may still be null at this + // point — `applyRenderTarget` is a no-op then and the pending + // id is re-checked when the context is created. + const targetBoId = dv.getUint32(16, true); + if (targetBoId !== 0) { + b.pendingRenderTargetBoId = targetBoId; + this.gl.applyRenderTarget(b); + } } }, host_gl_destroy_surface: (pid: number, _surfaceId: number): void => { const b = this.gl.get(pid); - if (b) b.surfaceId = null; + if (!b) return; + b.surfaceId = null; + // Drop the producer render-target reference — the FBO itself is + // owned by the GPU bo (freed via destroyGpuBo on bo destroy), + // so never delete it here. Reset shadow.fbo so a subsequent + // surface on this binding starts at the default framebuffer. + // Also clear the pending target so a re-created context does not + // re-apply a stale redirect. + b.pendingRenderTargetBoId = 0; + if (b.renderTargetFbo) { + b.renderTargetFbo = null; + b.shadow.fbo = null; + } }, host_gl_make_current: ( _pid: number, _ctxId: number, _surfaceId: number, @@ -2251,9 +2349,16 @@ export class WasmPosixKernel { (bb, off, len) => decodeAndDispatch(bb, off, len), ); }, - host_gl_present: (_pid: number): void => { - // RAF-driven canvas presentation handles itself in v1. Hook - // is here for explicit-swap / pbuffer paths in v2. + host_gl_present: (pid: number): void => { + // A GPU-tier producer renders into an offscreen bo FBO on the + // shared context, so `eglSwapBuffers` must fence the queued GL + // work: `flush()` guarantees the producer's draws are submitted + // before the compositor samples the bo (their command order + // through the one shared context then gives render-before-sample + // for free — no explicit sync object in v1). Canvas-backed + // sessions present via RAF and need no fence here. + const b = this.gl.get(pid); + if (b?.renderTargetFbo) b.gl?.flush(); }, host_gl_query: ( pid: number, op: number, @@ -2319,6 +2424,16 @@ export class WasmPosixKernel { const b = this.gl.get(pid); if (!b || !b.gl || b.contextId !== ctxId) return -5; // EIO if (glTarget !== 0x0de1) return -22; // EINVAL: TEXTURE_2D only + // GPU-tier bo: the texture already lives on the shared context, + // so binding it degenerates to "return the texture id" — zero + // copies, no upload. The caller must be on that same context + // (WebGL textures aren't shareable); otherwise EIO. + const gpu = this.gl.gpuBo(boId); + if (gpu) { + if (b.gl !== gpu.gl) return -5; // EIO + b.textures.set(gpu.texId, gpu.tex); + return gpu.texId; + } const dims = this.bos.dims(boId); const bytes = this.bos.pixelView(boId); if (!dims || !bytes) return -2; // ENOENT diff --git a/host/src/webgl/bridge.ts b/host/src/webgl/bridge.ts index 9cb303ee9d..61bb23b430 100644 --- a/host/src/webgl/bridge.ts +++ b/host/src/webgl/bridge.ts @@ -642,7 +642,16 @@ function dispatch( } case O.OP_BIND_FRAMEBUFFER: { const target = v.getUint32(p, true); - const fbo = b.fbos.get(v.getUint32(p + 4, true)) ?? null; + const name = v.getUint32(p + 4, true); + let fbo = b.fbos.get(name) ?? null; + // GPU-tier producer redirect: "bind default framebuffer 0" (the + // client's window) becomes "render into the target bo's FBO" so a + // routed client's output lands in the bo the compositor samples. + // Only name 0 is remapped — a real FBO name the client generated + // (offscreen ping-pong, etc.) is honored as-is. + if (fbo === null && name === 0 && b.renderTargetFbo) { + fbo = b.renderTargetFbo; + } gl.bindFramebuffer(target, fbo); if (target !== GL_READ_FRAMEBUFFER) b.shadow.fbo = fbo; return; diff --git a/host/src/webgl/registry.ts b/host/src/webgl/registry.ts index f22ba9ab6a..74e8bf6d02 100644 --- a/host/src/webgl/registry.ts +++ b/host/src/webgl/registry.ts @@ -103,6 +103,25 @@ export type GlBinding = GlBindingInput & { * current program (e.g. uniform setters). */ currentProgram: WebGLProgram | null; + /** GPU-tier producer render target (PR10 §7.1): the FBO whose color + * attachment IS a GPU bo's texture. Set when the client's EGL window + * surface targets a GPU bo (`GLIO_CREATE_SURFACE` with a target bo). + * Non-null redirects the client's "bind default framebuffer 0" (its + * window) into the bo's FBO, so its GL output lands in the bo the + * compositor samples zero-copy. Owned by the bo, NOT this binding — + * never deleted on unbind (destroyed via `destroyGpuBo` on bo + * destroy). Null for canvas-backed (master) and CPU-tier sessions. */ + renderTargetFbo: WebGLFramebuffer | null; + + /** Target GPU bo_id captured at `GLIO_CREATE_SURFACE` but not yet + * applied, because the client created its window surface BEFORE its + * GL context (SDL2's Wayland+GLES backend creates the wl_egl_window + * surface during `SDL_CreateWindow`, then the context during + * `SDL_GL_CreateContext`). The redirect needs both `b.gl` (set at + * context creation) and the resolved bo, so whichever of the two + * runs last applies it. 0 = no pending target. */ + pendingRenderTargetBoId: number; + shadow: GlShadowState; forward: GlForwardChannel | null; @@ -111,9 +130,32 @@ export type GlBinding = GlBindingInput & { export type GlChangeEvent = "bind" | "unbind"; export type GlChangeListener = (pid: number, ev: GlChangeEvent) => void; +/** A GPU-tier bo (`DRM_IOCTL_WPK_CREATE_GPU_BO`): a `WebGLTexture` + + * color-attachment FBO living on the shared multiplexer context (the + * DRM-master compositor's scanout context). Unlike CPU-tier bos there + * is no SAB backing — the pixels only ever exist on the GPU. The + * producer renders into `fbo`; a consumer that `WPK_BIND_FOREIGN_TEXTURE`s + * it samples `tex` zero-copy, so it MUST be on the same `gl`. */ +export type GpuBo = { + gl: WebGL2RenderingContext; + tex: WebGLTexture; + fbo: WebGLFramebuffer; + texId: number; + w: number; + h: number; +}; + export class GlContextRegistry { private bindings = new Map(); private listeners = new Set(); + /** GPU-tier bos keyed by bo_id. Registry-scoped (NOT per-binding): + * the texture is owned by the bo and shared across every session that + * binds it, so it is freed only on bo destroy, never on `unbind()`. */ + private gpuBos = new Map(); + /** Id allocator for GPU-bo textures. Distinct band from per-binding + * foreign textures (`0x4000_0000`) so a stray id never resolves to + * the wrong table when debugging. */ + private nextGpuBoTexId = 0x6000_0000; /** Channels installed before `bind()` fires; drained when it does, so * the embedder can wire forwarding without racing `host_gl_bind`. */ private pendingForwards = new Map(); @@ -149,6 +191,8 @@ export class GlContextRegistry { nextForeignTexId: 0x4000_0000, claimedKmsCrtc: null, currentProgram: null, + renderTargetFbo: null, + pendingRenderTargetBoId: 0, shadow: defaultShadow(), forward, }); @@ -244,7 +288,11 @@ export class GlContextRegistry { /** Delete every binding's foreign texture for a destroyed bo (the bo * is the texture's canonical owner — see shared's * `DRM_IOCTL_WPK_BIND_FOREIGN_TEXTURE` doc). Called from the host's - * `gbm_bo_destroy` hook when the bo refcount hits zero. */ + * `gbm_bo_destroy` hook when the bo refcount hits zero. + * + * This DELIBERATELY leaves `gpuBos` untouched: a GPU-tier bo's texture + * is owned by the bo itself (this registry), not by any binding, and + * is released only via `destroyGpuBo` on bo destroy. */ dropForeignTexturesForBo(bo_id: number): void { for (const b of this.bindings.values()) { const entry = b.foreignTextures.get(bo_id); @@ -255,6 +303,97 @@ export class GlContextRegistry { } } + /** Allocate a GPU-tier bo (`DRM_IOCTL_WPK_CREATE_GPU_BO`): an empty + * `w×h` RGBA texture plus a color-attachment FBO on `gl` (the shared + * multiplexer context). Idempotent — a second call for a live `bo_id` + * returns the existing texId without reallocating. Returns the guest- + * visible texture id, or `null` if the context could not allocate the + * objects (the kernel then rolls back and the guest falls back to a + * CPU-tier dumb bo). + * + * Runs OUTSIDE the submit-drain/muxer path, so the prior + * TEXTURE_BINDING_2D and FRAMEBUFFER_BINDING are saved and restored — + * the shared context may be mid-frame for another session. */ + createGpuBo( + bo_id: number, + gl: WebGL2RenderingContext, + w: number, + h: number, + ): number | null { + const existing = this.gpuBos.get(bo_id); + if (existing) return existing.texId; + const tex = gl.createTexture(); + const fbo = gl.createFramebuffer(); + if (!tex || !fbo) { + if (tex) gl.deleteTexture(tex); + if (fbo) gl.deleteFramebuffer(fbo); + return null; + } + const prevTex = gl.getParameter(gl.TEXTURE_BINDING_2D) as WebGLTexture | null; + const prevFbo = gl.getParameter(gl.FRAMEBUFFER_BINDING) as WebGLFramebuffer | null; + gl.bindTexture(gl.TEXTURE_2D, tex); + // `null` data → allocate storage without an upload; the producer + // fills it by rendering into the FBO. + gl.texImage2D( + gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, + gl.RGBA, gl.UNSIGNED_BYTE, null, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0, + ); + gl.bindTexture(gl.TEXTURE_2D, prevTex); + gl.bindFramebuffer(gl.FRAMEBUFFER, prevFbo); + const texId = this.nextGpuBoTexId++; + this.gpuBos.set(bo_id, { gl, tex, fbo, texId, w, h }); + return texId; + } + + /** The GPU-tier bo for `bo_id`, or undefined if it is CPU-tier / + * unknown. Used by the foreign-texture bind path to short-circuit to a + * zero-copy texture-id return. */ + gpuBo(bo_id: number): GpuBo | undefined { + return this.gpuBos.get(bo_id); + } + + /** Apply the GPU-tier producer render-target redirect for `b` if both + * halves are now known: `b.gl` (set at context creation) and a pending + * target GPU bo (captured at surface creation). Redirects the client's + * default framebuffer (name 0) into the bo's FBO and seeds the viewport + * to the bo dims, so its GL output lands in the bo the compositor + * samples zero-copy. Idempotent; a no-op until both halves exist and + * the bo lives on the SAME context as `b.gl` (WebGL FBOs aren't + * shareable). Called from BOTH `gl_create_surface` and + * `gl_create_context` because SDL2's Wayland+GLES backend creates the + * window surface (during `SDL_CreateWindow`) BEFORE the context (during + * `SDL_GL_CreateContext`) — whichever runs last wins. Returns true when + * the redirect was applied. */ + applyRenderTarget(b: GlBinding): boolean { + if (b.renderTargetFbo || !b.gl || b.pendingRenderTargetBoId === 0) { + return false; + } + const gpu = this.gpuBos.get(b.pendingRenderTargetBoId); + if (!gpu || b.gl !== gpu.gl) return false; + b.renderTargetFbo = gpu.fbo; + b.shadow.fbo = gpu.fbo; + b.shadow.viewport = [0, 0, gpu.w, gpu.h]; + return true; + } + + /** Release a GPU-tier bo's FBO + texture from its shared context. + * Called from `gbm_bo_destroy` alongside `dropForeignTexturesForBo`. */ + destroyGpuBo(bo_id: number): void { + const entry = this.gpuBos.get(bo_id); + if (!entry) return; + this.gpuBos.delete(bo_id); + entry.gl.deleteFramebuffer(entry.fbo); + entry.gl.deleteTexture(entry.tex); + } + onChange(fn: GlChangeListener): () => void { this.listeners.add(fn); return () => { diff --git a/host/test/webgl-gpu-bo.test.ts b/host/test/webgl-gpu-bo.test.ts new file mode 100644 index 0000000000..b27c04a1db --- /dev/null +++ b/host/test/webgl-gpu-bo.test.ts @@ -0,0 +1,162 @@ +/** + * Unit tests for the GPU-tier bo bookkeeping in `GlContextRegistry` + * (`host/src/webgl/registry.ts`), the host half of + * `DRM_IOCTL_WPK_CREATE_GPU_BO` (PR10). The kernel-side ioctl dispatch + + * rollback is covered in Rust; these pin the TS half: + * + * - `createGpuBo` allocates ONE texture + FBO, ids from 0x60000000, + * saves/restores the shared context's prior TEXTURE_BINDING_2D and + * FRAMEBUFFER_BINDING (it runs outside the muxer, mid-frame for + * another session), + * - a second create for a live bo is idempotent (no realloc, no extra + * texImage2D upload) — the zero-copy bind degenerates to the same id, + * - `destroyGpuBo` frees the FBO + texture, + * - `dropForeignTexturesForBo` leaves GPU bos alone (owned by the bo, + * not by any binding). + */ +import { describe, expect, it } from "vitest"; +import { GlContextRegistry } from "../src/webgl/registry.js"; + +const GL = { + TEXTURE_2D: 0x0de1, + RGBA: 0x1908, + UNSIGNED_BYTE: 0x1401, + LINEAR: 0x2601, + CLAMP_TO_EDGE: 0x812f, + TEXTURE_MIN_FILTER: 0x2801, + TEXTURE_MAG_FILTER: 0x2800, + TEXTURE_WRAP_S: 0x2802, + TEXTURE_WRAP_T: 0x2803, + FRAMEBUFFER: 0x8d40, + COLOR_ATTACHMENT0: 0x8ce0, + TEXTURE_BINDING_2D: 0x8069, + FRAMEBUFFER_BINDING: 0x8ca6, +} as const; + +function makeFakeGl() { + // Sentinels the alloc path must save and restore around itself. + const state = { + binding2d: { name: "prev-tex" } as unknown, + fboBinding: { name: "prev-fbo" } as unknown, + }; + const calls = { + texImage2D: [] as unknown[][], + framebufferTexture2D: 0, + bindTexture: [] as unknown[], + bindFramebuffer: [] as unknown[], + deletedTextures: [] as unknown[], + deletedFramebuffers: [] as unknown[], + }; + let texN = 0; + let fboN = 0; + const gl = { + ...GL, + createTexture: () => ({ kind: "tex", id: ++texN }), + createFramebuffer: () => ({ kind: "fbo", id: ++fboN }), + deleteTexture: (t: unknown) => calls.deletedTextures.push(t), + deleteFramebuffer: (f: unknown) => calls.deletedFramebuffers.push(f), + getParameter: (p: number) => + p === GL.TEXTURE_BINDING_2D + ? state.binding2d + : p === GL.FRAMEBUFFER_BINDING + ? state.fboBinding + : null, + bindTexture: (_target: number, tex: unknown) => calls.bindTexture.push(tex), + bindFramebuffer: (_target: number, fbo: unknown) => calls.bindFramebuffer.push(fbo), + texImage2D: (...args: unknown[]) => calls.texImage2D.push(args), + texParameteri: () => {}, + framebufferTexture2D: () => { calls.framebufferTexture2D++; }, + }; + return { gl: gl as unknown as WebGL2RenderingContext, calls, state }; +} + +describe("GlContextRegistry — GPU-tier bo (WPK_CREATE_GPU_BO)", () => { + it("createGpuBo allocates one texture+FBO, ids from 0x60000000, restores prior bindings", () => { + const { gl, calls, state } = makeFakeGl(); + const reg = new GlContextRegistry(); + + const id = reg.createGpuBo(42, gl, 320, 240); + expect(id).toBe(0x6000_0000); + + const entry = reg.gpuBo(42)!; + expect(entry.texId).toBe(id); + expect(entry.w).toBe(320); + expect(entry.h).toBe(240); + + // Exactly one storage allocation with null data (no upload). + expect(calls.texImage2D.length).toBe(1); + expect(calls.texImage2D[0][0]).toBe(GL.TEXTURE_2D); + expect(calls.texImage2D[0].at(-1)).toBeNull(); + expect(calls.framebufferTexture2D).toBe(1); + + // The prior TEXTURE_BINDING_2D and FRAMEBUFFER_BINDING are restored + // last — the shared context may be mid-frame for another session. + expect(calls.bindTexture.at(-1)).toBe(state.binding2d); + expect(calls.bindFramebuffer.at(-1)).toBe(state.fboBinding); + }); + + it("distinct bos get distinct ids from the GPU band", () => { + const { gl } = makeFakeGl(); + const reg = new GlContextRegistry(); + expect(reg.createGpuBo(1, gl, 8, 8)).toBe(0x6000_0000); + expect(reg.createGpuBo(2, gl, 8, 8)).toBe(0x6000_0001); + }); + + it("re-create for a live bo is idempotent — same id, no realloc, no extra upload", () => { + const { gl, calls } = makeFakeGl(); + const reg = new GlContextRegistry(); + const id = reg.createGpuBo(7, gl, 64, 64); + expect(reg.createGpuBo(7, gl, 64, 64)).toBe(id); + // The zero-copy bind reuses the same texture: still one allocation. + expect(calls.texImage2D.length).toBe(1); + }); + + it("bind semantics: gpuBo returns the same tex with no upload (zero-copy)", () => { + const { gl, calls } = makeFakeGl(); + const reg = new GlContextRegistry(); + const id = reg.createGpuBo(9, gl, 16, 16); + const uploadsAfterCreate = calls.texImage2D.length; + + // What host_gl_bind_foreign_texture does on the GPU path: look the bo + // up and return its stable texId — no texImage2D, no copy. + const gpu = reg.gpuBo(9)!; + expect(gpu.texId).toBe(id); + expect(calls.texImage2D.length).toBe(uploadsAfterCreate); + }); + + it("destroyGpuBo frees the FBO and texture", () => { + const { gl, calls } = makeFakeGl(); + const reg = new GlContextRegistry(); + reg.createGpuBo(5, gl, 32, 32); + const entry = reg.gpuBo(5)!; + + reg.destroyGpuBo(5); + expect(reg.gpuBo(5)).toBeUndefined(); + expect(calls.deletedTextures).toEqual([entry.tex]); + expect(calls.deletedFramebuffers).toEqual([entry.fbo]); + // Idempotent: destroying again is a no-op. + reg.destroyGpuBo(5); + expect(calls.deletedTextures.length).toBe(1); + }); + + it("dropForeignTexturesForBo leaves GPU bos untouched (bo-owned, not binding-owned)", () => { + const { gl, calls } = makeFakeGl(); + const reg = new GlContextRegistry(); + reg.createGpuBo(3, gl, 8, 8); + + reg.dropForeignTexturesForBo(3); + // The GPU-bo texture survives — it is freed only via destroyGpuBo. + expect(reg.gpuBo(3)).toBeDefined(); + expect(calls.deletedTextures).toEqual([]); + }); + + it("createGpuBo returns null when the context cannot allocate", () => { + const { gl } = makeFakeGl(); + // A context that fails to create a framebuffer must roll back the + // texture and report failure so the kernel falls back to CPU tier. + (gl as unknown as { createFramebuffer: () => null }).createFramebuffer = () => null; + const reg = new GlContextRegistry(); + expect(reg.createGpuBo(1, gl, 8, 8)).toBeNull(); + expect(reg.gpuBo(1)).toBeUndefined(); + }); +}); diff --git a/host/test/wlcompositor-decoration-smoke.test.ts b/host/test/wlcompositor-decoration-smoke.test.ts new file mode 100644 index 0000000000..a9629865cd --- /dev/null +++ b/host/test/wlcompositor-decoration-smoke.test.ts @@ -0,0 +1,116 @@ +/** + * PR14e gate: the wlcompositor's zxdg_decoration_manager_v1 support. + * + * The compositor advertises the decoration manager and negotiates the mode by + * layout: DWINDLE forces SERVER_SIDE (a tiled window has no titlebar, so + * clients drop their CSD), while FLOATING grants CLIENT_SIDE (a draggable + * titlebar stays). wlclient-test, run with WLC_DECOR=1, binds the manager, + * creates a toplevel decoration, requests a mode, and prints the negotiated + * mode from the configure event. Both layouts are asserted here. + * + * Decoration negotiation is a client<->compositor protocol entirely inside the + * kernel — no host/src change. Skips if the binaries aren't built. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); +const hasBinaries = !!compositorBin && !!clientBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string, + timeoutMs: number, + context: () => string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (ref.value.includes(needle)) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${JSON.stringify(needle)}.\n${context()}`); +} + +describe("wlcompositor — server-side decoration negotiation", () => { + it.skipIf(!hasBinaries)( + "a client requesting decorations is configured SERVER_SIDE", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + host.spawn(clientBytes, ["wlclient-test"], { env: ["WLC_DECOR=1"] }); + await waitFor(out, "DECOR_MODE server_side", 20_000, dump); + expect(out.value, `client-side decoration leaked.\n${dump()}`) + .not.toContain("DECOR_MODE client_side"); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries)( + "the floating desktop is configured CLIENT_SIDE (draggable titlebar)", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + // No WLC_LAYOUT → the default FLOATING desktop. + const compExit = host.spawn(compositorBytes, ["wlcompositor"], {}); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + host.spawn(clientBytes, ["wlclient-test"], { env: ["WLC_DECOR=1"] }); + await waitFor(out, "DECOR_MODE client_side", 20_000, dump); + expect(out.value, `server-side decoration leaked into floating.\n${dump()}`) + .not.toContain("DECOR_MODE server_side"); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/host/test/wlcompositor-dmabuf-smoke.test.ts b/host/test/wlcompositor-dmabuf-smoke.test.ts new file mode 100644 index 0000000000..c907623371 --- /dev/null +++ b/host/test/wlcompositor-dmabuf-smoke.test.ts @@ -0,0 +1,114 @@ +/** + * PR11 gate: the wlcompositor Wayland server accepts a client buffer via + * zwp_linux_dmabuf_v1 (instead of wl_shm) and composites it to card0. + * + * Spawns the compositor (programs/wlcompositor/wlcompositor.c) and a dmabuf + * client (programs/wlcompositor/wldmabuf-test.c) under one NodeKernelHost, + * talking over the real AF_UNIX socket at /tmp/wayland-0. The client: + * + * - binds zwp_linux_dmabuf_v1 and confirms it advertises XRGB8888 + LINEAR + * (the one format/modifier the GPU tier + gbm import path handle); + * - allocates a renderD128 dumb-bo, paints it red, and turns its prime-fd + * into a wl_buffer via zwp_linux_buffer_params_v1.create_immed; + * - attaches + commits, and its frame callback fires only after the + * compositor imported the dmabuf and flipped it. + * + * The compositor samples the composited pixel and we assert it is the + * client's red — proving the dmabuf buffer traversed the same import + + * composite path as wl_shm. Input routing is covered by the wl_shm gate + * (wlcompositor-smoke.test.ts); this one is purely the dmabuf buffer path. + * + * Both processes exit 0. Skips if the binaries aren't built (bare checkout). + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const clientBin = tryResolveBinary("programs/wldmabuf-test.wasm"); +const hasBinaries = !!compositorBin && !!clientBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string, + timeoutMs: number, + context: () => string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (ref.value.includes(needle)) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${JSON.stringify(needle)}.\n${context()}`); +} + +describe("wlcompositor — composites a zwp_linux_dmabuf_v1 client buffer", () => { + it.skipIf(!hasBinaries)( + "dmabuf-imported buffer lands on card0 red", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + + const dump = () => + `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], {}); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + const clientExit = host.spawn(clientBytes, ["wldmabuf-test"], {}); + + // The client only prints DMABUF_CLIENT_OK after the compositor + // imported its dmabuf and flipped it (frame callback fired). + await waitFor(out, "DMABUF_CLIENT_OK", 20_000, dump); + + // The compositor imported the dmabuf prime-fd and composited it — + // the sampled pixel is the client's red. + await waitFor(out, "COMPOSITE_SAMPLE", 5_000, dump); + const sample = out.value.match(/COMPOSITE_SAMPLE x=\d+ y=\d+ px=0x([0-9a-f]{8})/); + expect(sample, `no composite sample.\n${dump()}`).not.toBeNull(); + const px = parseInt(sample![1], 16); + expect(px & 0xffffff, `composited pixel not red (0x${px.toString(16)})\n${dump()}`) + .toBe(0xff0000); + expect(out.value).toMatch(/FLIP fb=\d+ first=1/); + + const clientCode = await Promise.race([ + clientExit, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`client timed out.\n${dump()}`)), 25_000)), + ]); + expect(clientCode, `client exit.\n${dump()}`).toBe(0); + + const compCode = await Promise.race([ + compExit, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`compositor timed out.\n${dump()}`)), 10_000)), + ]); + expect(compCode, `compositor exit.\n${dump()}`).toBe(0); + expect(out.value).toContain("COMPOSITOR_LAST_CLIENT_GONE"); + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/host/test/wlcompositor-keybind-smoke.test.ts b/host/test/wlcompositor-keybind-smoke.test.ts new file mode 100644 index 0000000000..4a74a93618 --- /dev/null +++ b/host/test/wlcompositor-keybind-smoke.test.ts @@ -0,0 +1,347 @@ +/** + * PR14d gate: the wlcompositor's config-file keybind engine. + * + * Two paths: + * 1. No config file -> generic default binds (BINDS_LOADED source=default). + * Injecting SUPER+3 / SUPER+1 via evdev drives the workspace dispatcher + * (observed on the kwlctl --listen stream), and SUPER+J cycles keyboard + * focus between windows (observed via kwlctl activewindow). + * 2. A hyprland.conf-shaped file pointed at by WLC_CONFIG is parsed + * (BINDS_LOADED source=); its custom `SUPER, 5, workspace, 7` + * binding fires workspace 7 — proving config -> behavior, not the default. + * + * The keybind engine intercepts bound combos in the compositor's keyboard path + * before the focused client, so this is entirely in-kernel — no host/src change. + * Skips if the binaries aren't built. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync, writeFileSync, mkdtempSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); +const kwlctlBin = tryResolveBinary("programs/kwlctl.wasm"); +const hasBinaries = !!compositorBin && !!clientBin && !!kwlctlBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +// evdev keycodes (linux/input-event-codes.h). +const EV_KEY = 0x01; +const EV_SYN = 0x00; +const SYN_REPORT = 0x00; +const KEY_1 = 2; +const KEY_3 = 4; +const KEY_5 = 6; +const KEY_W = 17; +const KEY_J = 36; +const KEY_K = 37; +const KEY_LEFTMETA = 125; // SUPER +const KEY_LEFTCTRL = 29; // CTRL + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string | RegExp, + timeoutMs: number, + context: () => string, +): Promise { + const deadline = Date.now() + timeoutMs; + const hit = () => + typeof needle === "string" ? ref.value.includes(needle) : needle.test(ref.value); + while (Date.now() < deadline) { + if (hit()) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${needle}.\n${context()}`); +} + +describe("wlcompositor — config-file keybind engine", () => { + it.skipIf(!hasBinaries)( + "default binds drive workspace + focus-cycle dispatchers via evdev", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + const activeAddress = async (): Promise => { + out.value = out.value.replace(/\{"address"[^\n]*/g, ""); // clear stale + await host.spawn(kwlctlBytes, ["kwlctl", "activewindow"], {}); + const m = out.value.match(/\{"address":"([^"]+)"/); + expect(m, `no activewindow.\n${dump()}`).not.toBeNull(); + return m![1]; + }; + + const tap = (code: number) => { + host.injectInputEvent(0, EV_KEY, KEY_LEFTMETA, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, KEY_LEFTMETA, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + }; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + // No WLC_CONFIG -> generic defaults. + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + expect(out.value, `not default binds.\n${dump()}`) + .toMatch(/BINDS_LOADED n=\d+ source=default/); + + host.spawn(clientBytes, ["wlclient-test"], {}); + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, /TILE n=2 i=1 /, 20_000, dump); + + host.spawn(kwlctlBytes, ["kwlctl", "--listen"], {}); + await waitFor(out, "listening", 10_000, dump); + + // SUPER+3 -> workspace 3, SUPER+1 -> workspace 1 (default binds). + tap(KEY_3); + await waitFor(out, "workspace>>3", 10_000, dump); + tap(KEY_1); + await waitFor(out, "workspace>>1", 10_000, dump); + + // SUPER+J (cyclenext) / SUPER+K (cycleprev) move focus and back. + const before = await activeAddress(); + tap(KEY_J); + await waitFor(out, "activewindow>>", 10_000, dump); + const after = await activeAddress(); + expect(after, `cyclenext did not move focus.\n${dump()}`).not.toBe(before); + tap(KEY_K); + const restored = await activeAddress(); + expect(restored, `cycleprev did not restore focus.\n${dump()}`).toBe(before); + + // SUPER+W (killactive) closes the focused window; it exits and ws 1 + // re-tiles down to the single remaining window. + tap(KEY_W); + await waitFor(out, "CLIENT_CLOSED", 10_000, dump); + await waitFor(out, /TILE n=1 /, 10_000, dump); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries)( + "a WLC_CONFIG file is parsed and its custom bind overrides the default", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + + const dir = mkdtempSync(join(tmpdir(), "wlc-conf-")); + const confPath = join(dir, "wlcompositor.conf"); + // SUPER+5 -> workspace 7 (the default would be workspace 5). + writeFileSync(confPath, + "# kandelo test config\nbind = SUPER, 5, workspace, 7\n"); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + const tap = (code: number) => { + host.injectInputEvent(0, EV_KEY, KEY_LEFTMETA, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, KEY_LEFTMETA, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + }; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle", `WLC_CONFIG=${confPath}`], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + expect(out.value, `config not parsed.\n${dump()}`) + .toContain(`BINDS_LOADED n=1 source=${confPath}`); + + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, "CLIENT_CONNECTED count=1", 20_000, dump); + + host.spawn(kwlctlBytes, ["kwlctl", "--listen"], {}); + await waitFor(out, "listening", 10_000, dump); + + // The parsed bind sends SUPER+5 to workspace 7, not the default 5. + tap(KEY_5); + await waitFor(out, "workspace>>7", 10_000, dump); + expect(out.value).not.toContain("workspace>>5"); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + // A browser reserves SUPER (Cmd/Win), so the demo also binds CTRL; this + // gates that the bind engine matches the CTRL modifier. The key is a LETTER + // on purpose: xkb_keysym_from_name("W") resolves to the uppercase keysym, + // but binds are matched against the lowercase base-level keysym, so without + // the parse-time fold `bind = CTRL, W` silently never fires (a digit bind + // has no case and wouldn't catch the regression). + it.skipIf(!hasBinaries)( + "a CTRL + letter bind fires (the browser-usable modifier, case-folded)", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + + const dir = mkdtempSync(join(tmpdir(), "wlc-conf-")); + const confPath = join(dir, "wlcompositor.conf"); + writeFileSync(confPath, "bind = CTRL, W, workspace, 4\n"); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + const tapCtrl = (code: number) => { + host.injectInputEvent(0, EV_KEY, KEY_LEFTCTRL, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, KEY_LEFTCTRL, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + }; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle", `WLC_CONFIG=${confPath}`], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + expect(out.value, `config not parsed.\n${dump()}`) + .toContain(`BINDS_LOADED n=1 source=${confPath}`); + + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, "CLIENT_CONNECTED count=1", 20_000, dump); + + host.spawn(kwlctlBytes, ["kwlctl", "--listen"], {}); + await waitFor(out, "listening", 10_000, dump); + + tapCtrl(KEY_W); + await waitFor(out, "workspace>>4", 10_000, dump); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + // The `/?demo=hyprland` "new pane" flow: an `exec` bind launches an app when + // its key is pressed (CTRL+K=clock, CTRL+P=paint, CTRL+Return=terminal). This + // gates that a CTRL exec bind actually spawns a NEW client — the keybind + // engine dispatches ACT_EXEC → posix_spawnp, and the spawned client connects + // (count bumps). Under raw NodePlatformIO a spawn needs both legs: + // execProgramBytes feeds the preflight its program bytes, and the kernel's + // authoritative target stat reaches the same real host path. The bind's + // exec command lives in the compositor's 64-byte `param` buffer, so the + // target is a short /tmp symlink to the resolver-cached client wasm. + it.skipIf(!hasBinaries)( + "a CTRL exec bind launches a new client (the demo's new-pane keybind)", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + + const dir = mkdtempSync(join(tmpdir(), "wlc-conf-")); + const confPath = join(dir, "wlcompositor.conf"); + const execTarget = join(mkdtempSync("/tmp/wlc-bind-"), "client.wasm"); + symlinkSync(clientBin!, execTarget); + // Mirror HYPRLAND_WLCOMPOSITOR_CONF's launch bind shape (exec an + // absolute path); point CTRL+K (the clock bind) at wlclient-test. + writeFileSync(confPath, + `bind = CTRL, K, exec, ${execTarget}\n`); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + execProgramBytes: { [execTarget]: clientBytes }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + const tapCtrl = (code: number) => { + host.injectInputEvent(0, EV_KEY, KEY_LEFTCTRL, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 1); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, code, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(0, EV_KEY, KEY_LEFTCTRL, 0); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + }; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle", `WLC_CONFIG=${confPath}`], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + expect(out.value, `config not parsed.\n${dump()}`) + .toContain(`BINDS_LOADED n=1 source=${confPath}`); + + // One client already mapped so the compositor is live (count=1). + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, "CLIENT_CONNECTED count=1", 20_000, dump); + + // CTRL+K fires the exec bind; the compositor spawns the app and the new + // client connects (count=2), proving the launch keybind path. + tapCtrl(KEY_K); + await waitFor(out, `KWLCTL_EXEC "${execTarget}"`, 10_000, dump); + await waitFor(out, "CLIENT_CONNECTED count=2", 20_000, dump); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/host/test/wlcompositor-kwlctl-smoke.test.ts b/host/test/wlcompositor-kwlctl-smoke.test.ts new file mode 100644 index 0000000000..762bbbdf48 --- /dev/null +++ b/host/test/wlcompositor-kwlctl-smoke.test.ts @@ -0,0 +1,178 @@ +/** + * PR14c gate: the wlcompositor's kwlctl control + event socket (/tmp/kwlctl-0), + * the hyprctl analog. + * + * Spawns the compositor in dwindle mode plus three clients, then drives the + * kwlctl CLI (programs/wlcompositor/kwlctl.c) over the control socket: + * + * - `kwlctl clients` returns JSON for the three windows whose geometry equals + * the dwindle partition (proves the query surface reflects the layout); + * - `kwlctl --listen` streams the `event>>data` line emitted when + * `kwlctl dispatch workspace 2` switches workspace (proves the event bus); + * - `kwlctl dispatch exec wlclient-test` forks+execs a fourth client through + * the compositor (proves dispatch exec; the exec resolves via onResolveExec). + * + * kwlctl talks to the compositor entirely inside the kernel (a client<->server + * UNIX socket), so there is no host/src change — the dual-host parity rule is + * not triggered. Skips if the binaries aren't built. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); +const kwlctlBin = tryResolveBinary("programs/kwlctl.wasm"); +const hasBinaries = !!compositorBin && !!clientBin && !!kwlctlBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; +const GAP_OUTER = 12; +const GAP_INNER = 8; + +interface Rect { x: number; y: number; w: number; h: number } + +// Mirror of the C compute_tiling() (see wlcompositor-tiling-smoke.test.ts). +function computeTiling(area: Rect, n: number): Rect[] { + const out: Rect[] = []; + if (n <= 0) return out; + let region: Rect = { + x: area.x + GAP_OUTER, + y: area.y + GAP_OUTER, + w: Math.max(1, area.w - 2 * GAP_OUTER), + h: Math.max(1, area.h - 2 * GAP_OUTER), + }; + for (let i = 0; i < n; i++) { + if (i === n - 1) { out.push(region); break; } + const near: Rect = { ...region }; + const rest: Rect = { ...region }; + if (region.w >= region.h) { + const half = Math.max(1, Math.floor((region.w - GAP_INNER) / 2)); + near.w = half; + rest.x = region.x + half + GAP_INNER; + rest.w = region.w - half - GAP_INNER; + } else { + const half = Math.max(1, Math.floor((region.h - GAP_INNER) / 2)); + near.h = half; + rest.y = region.y + half + GAP_INNER; + rest.h = region.h - half - GAP_INNER; + } + out.push(near); + region = rest; + } + return out; +} + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string | RegExp, + timeoutMs: number, + context: () => string, +): Promise { + const deadline = Date.now() + timeoutMs; + const hit = () => + typeof needle === "string" ? ref.value.includes(needle) : needle.test(ref.value); + while (Date.now() < deadline) { + if (hit()) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${needle}.\n${context()}`); +} + +describe("wlcompositor — kwlctl control + event IPC", () => { + it.skipIf(!hasBinaries)( + "clients JSON matches dwindle; dispatch drives workspace + exec; --listen streams events", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + const kwlctlBytes = loadBytes(kwlctlBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + // `dispatch exec` runs posix_spawnp inside the compositor. Under raw + // NodePlatformIO a spawn needs both legs: this map feeds the + // side-effect-free preflight its program bytes, and the kernel's + // authoritative target stat reaches the same real host path. + execProgramBytes: { [clientBin!]: clientBytes }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + const runKwlctl = (args: string[]) => + host.spawn(kwlctlBytes, ["kwlctl", ...args], {}); + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + host.spawn(clientBytes, ["wlclient-test"], {}); + host.spawn(clientBytes, ["wlclient-test"], {}); + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, /TILE n=3 i=2 /, 20_000, dump); + + // --- kwlctl clients: JSON geometry equals the dwindle partition. --- + const clientsCode = await runKwlctl(["clients"]); + expect(clientsCode, `kwlctl clients exit.\n${dump()}`).toBe(0); + const arrMatch = out.value.match(/(\[\{"address"[^\n]*\])/); + expect(arrMatch, `no clients JSON.\n${dump()}`).not.toBeNull(); + const windows = JSON.parse(arrMatch![1]) as Array<{ + workspace: { id: number }; + at: [number, number]; + size: [number, number]; + focused: boolean; + }>; + expect(windows.length, `expected 3 windows.\n${dump()}`).toBe(3); + const expected = computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 3); + windows.forEach((w, i) => { + expect(w.workspace.id, `window ${i} workspace`).toBe(1); + expect({ x: w.at[0], y: w.at[1], w: w.size[0], h: w.size[1] }, + `window ${i} geometry.\n${dump()}`).toEqual(expected[i]); + }); + expect(windows.filter((w) => w.focused).length, + `exactly one focused.\n${dump()}`).toBe(1); + + // --- workspaces query: all three windows sit on the active ws 1. --- + const wsCode = await runKwlctl(["workspaces"]); + expect(wsCode, `kwlctl workspaces exit.\n${dump()}`).toBe(0); + const wsMatch = out.value.match(/(\[\{"id"[^\n]*\])/); + expect(wsMatch, `no workspaces JSON.\n${dump()}`).not.toBeNull(); + const workspaces = JSON.parse(wsMatch![1]) as Array<{ + id: number; windows: number; active: boolean; + }>; + expect(workspaces).toContainEqual({ id: 1, windows: 3, active: true }); + + // --- --listen streams the workspace event fired by a dispatch. --- + runKwlctl(["--listen"]); // background; drains until compositor exits + await waitFor(out, "listening", 10_000, dump); + + const dispCode = await runKwlctl(["dispatch", "workspace", "2"]); + expect(dispCode, `dispatch workspace exit.\n${dump()}`).toBe(0); + await waitFor(out, "workspace>>2", 10_000, dump); + + // --- dispatch exec spawns a fourth client through the compositor. + const execCode = await runKwlctl( + ["dispatch", "exec", clientBin!]); + expect(execCode, `dispatch exec exit.\n${dump()}`).toBe(0); + await waitFor(out, "CLIENT_CONNECTED count=4", 20_000, dump); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/host/test/wlcompositor-resize-smoke.test.ts b/host/test/wlcompositor-resize-smoke.test.ts new file mode 100644 index 0000000000..fea0b7949a --- /dev/null +++ b/host/test/wlcompositor-resize-smoke.test.ts @@ -0,0 +1,204 @@ +/** + * Client resize gate: a libkwl window honours the tiling compositor's dictated + * geometry. + * + * The tiling-smoke test proves the compositor computes the right partition and + * emits TILE markers, but its raw wlclient-test clients ignore the dictated + * size. This test closes that gap with real libkwl clients (wlclock): under + * WLC_LAYOUT=dwindle the compositor sends each window an xdg configure with its + * tile size; libkwl reallocates its buffers and posts KWL_RESIZE, and wlclock + * prints `WLCLOCK_RESIZE w=.. h=..`. We assert those dims equal the dwindle + * partition computed here (the same rule as tiling-smoke) — so the whole + * compositor→client resize path is verified end to end, not just the + * compositor's math. With one window it fills the whole gapped work area; a + * second window forces both down to the two-way split. + * + * wlclock (not wlterm) is used so the test needs no forkpty'd shell. Skips if + * the binaries aren't built. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const clockBin = tryResolveBinary("programs/wlclock.wasm"); +const paintBin = tryResolveBinary("programs/wlpaint.wasm"); +const hasBinaries = !!compositorBin && !!clockBin; +const hasPaint = !!compositorBin && !!paintBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +// Must match TILE_GAP_OUTER / TILE_GAP_INNER in wlcompositor.c. +const GAP_OUTER = 12; +const GAP_INNER = 8; + +interface Rect { x: number; y: number; w: number; h: number } + +// Mirror of the C compute_tiling() — see wlcompositor-tiling-smoke.test.ts. +function computeTiling(area: Rect, n: number): Rect[] { + const out: Rect[] = []; + if (n <= 0) return out; + let region: Rect = { + x: area.x + GAP_OUTER, + y: area.y + GAP_OUTER, + w: Math.max(1, area.w - 2 * GAP_OUTER), + h: Math.max(1, area.h - 2 * GAP_OUTER), + }; + for (let i = 0; i < n; i++) { + if (i === n - 1) { out.push(region); break; } + const near: Rect = { ...region }; + const rest: Rect = { ...region }; + if (region.w >= region.h) { + const half = Math.max(1, Math.floor((region.w - GAP_INNER) / 2)); + near.w = half; + rest.x = region.x + half + GAP_INNER; + rest.w = region.w - half - GAP_INNER; + } else { + const half = Math.max(1, Math.floor((region.h - GAP_INNER) / 2)); + near.h = half; + rest.y = region.y + half + GAP_INNER; + rest.h = region.h - half - GAP_INNER; + } + out.push(near); + region = rest; + } + return out; +} + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string | RegExp, + timeoutMs: number, + context: () => string, +): Promise { + const deadline = Date.now() + timeoutMs; + const hit = () => + typeof needle === "string" ? ref.value.includes(needle) : needle.test(ref.value); + while (Date.now() < deadline) { + if (hit()) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${needle}.\n${context()}`); +} + +describe("wlcompositor — libkwl clients resize to the dictated tile", () => { + it.skipIf(!hasBinaries)( + "one window fills the work area; a second splits both to the two-way partition", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clockBytes = loadBytes(clockBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + // One window: the sole tile is the whole gapped work area. Under + // server-side decoration (forced in dwindle) the content fills the + // full surface, so the resize dims equal the tile exactly. + host.spawn(clockBytes, ["wlclock"], {}); + const [solo] = computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 1); + await waitFor(out, `WLCLOCK_RESIZE w=${solo.w} h=${solo.h}`, 20_000, dump); + + // Second window: dwindle splits the work area along its longer (x) + // axis, so BOTH windows are reconfigured to the same half-width tile. + host.spawn(clockBytes, ["wlclock"], {}); + const two = computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 2); + expect(two[0].w, "expected an x-axis split").toBe(two[1].w); + await waitFor(out, `WLCLOCK_RESIZE w=${two[0].w} h=${two[0].h}`, 20_000, dump); + + // Both clients ended up at the two-way tile size — the first shrank + // from the solo tile and the second mapped straight into its half. + const resizes = [...out.value.matchAll(/WLCLOCK_RESIZE w=(\d+) h=(\d+)/g)] + .map((m) => `${m[1]}x${m[2]}`); + const halved = resizes.filter((r) => r === `${two[0].w}x${two[0].h}`); + expect(halved.length, + `expected both windows at the two-way tile.\n${dump()}`) + .toBeGreaterThanOrEqual(2); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + // wlpaint originally rendered a fixed 640×420 island regardless of its tile + // (it ignored KWL_RESIZE), so under dwindle it drew in the corner and left + // the rest of the tile as stale/blank buffer — reported as the paint window + // "not taking the whole width and height". It now honors KWL_RESIZE like + // wlclock/wlterm: this gates that WLPAINT_RESIZE reports the dwindle tile + // size, i.e. the toolbar+canvas fill the whole slot. + it.skipIf(!hasPaint)( + "wlpaint resizes to fill its dwindle tile (WLPAINT_RESIZE = the partition)", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const paintBytes = loadBytes(paintBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + // Sole window → the whole gapped work area. A fixed-size wlpaint would + // never emit WLPAINT_RESIZE at all (it would sit at 640×420), so this + // marker alone proves the client now honors the dictated tile size. + host.spawn(paintBytes, ["wlpaint"], {}); + await waitFor(out, "WLPAINT_READY", 20_000, dump); + const [solo] = computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 1); + await waitFor(out, `WLPAINT_RESIZE w=${solo.w} h=${solo.h}`, 20_000, dump); + + // A second window splits the work area; both wlpaint tiles reconfigure + // to the same half-width slot. + host.spawn(paintBytes, ["wlpaint"], {}); + const two = computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 2); + expect(two[0].w, "expected an x-axis split").toBe(two[1].w); + await waitFor(out, `WLPAINT_RESIZE w=${two[0].w} h=${two[0].h}`, 20_000, dump); + + const halved = [...out.value.matchAll(/WLPAINT_RESIZE w=(\d+) h=(\d+)/g)] + .map((m) => `${m[1]}x${m[2]}`) + .filter((r) => r === `${two[0].w}x${two[0].h}`); + expect(halved.length, + `expected both wlpaint windows at the two-way tile.\n${dump()}`) + .toBeGreaterThanOrEqual(2); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/host/test/wlcompositor-tiling-smoke.test.ts b/host/test/wlcompositor-tiling-smoke.test.ts new file mode 100644 index 0000000000..1372388516 --- /dev/null +++ b/host/test/wlcompositor-tiling-smoke.test.ts @@ -0,0 +1,245 @@ +/** + * PR14a gate: the wlcompositor's dwindle tiling engine partitions the output + * among mapped windows. + * + * Spawns the compositor with WLC_LAYOUT=dwindle plus three raw libwayland + * clients (programs/wlclient-test.c) over the real AF_UNIX socket. As each + * client maps, the compositor's pure compute_tiling() recomputes the layout + * and emits one `TILE n= i= x= y= w= h=` marker per window. This + * test parses the three-window retile and asserts the emitted geometry equals + * the dwindle partition — computed here from the same recursive rule — so the + * engine's decision is verified independently of whether the fixed-size test + * clients honour the dictated size. Also checks the tiles are in-bounds and + * pairwise non-overlapping. + * + * The floating desktop (/?demo=wayland) uses the default FLOATING mode and is + * covered by wlcompositor-smoke.test.ts; this test only exercises DWINDLE. + * Skips if the binaries aren't built (bare checkout). + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const compositorBin = tryResolveBinary("programs/wlcompositor.wasm"); +const clientBin = tryResolveBinary("programs/wlclient-test.wasm"); +const hasBinaries = !!compositorBin && !!clientBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +// Must match TILE_GAP_OUTER / TILE_GAP_INNER in wlcompositor.c. +const GAP_OUTER = 12; +const GAP_INNER = 8; + +// evdev codes (linux/input-event-codes.h) for the workspace keybind. +const EV_KEY = 0x01; +const EV_SYN = 0x00; +const SYN_REPORT = 0x00; +const KEY_2 = 3; +const KEY_LEFTSHIFT = 42; +const KEY_LEFTMETA = 125; // SUPER + +interface Rect { x: number; y: number; w: number; h: number } + +// Parse the `TILE n= i= ...` markers for a given window count into +// an index-ordered array. A single retile emits exactly `count` such lines. +function parseTiles(text: string, count: number): Rect[] { + const tiles: Rect[] = []; + const re = new RegExp( + `TILE n=${count} i=(\\d+) x=(-?\\d+) y=(-?\\d+) w=(\\d+) h=(\\d+)`, "g"); + for (let m = re.exec(text); m; m = re.exec(text)) { + tiles[Number(m[1])] = { + x: Number(m[2]), y: Number(m[3]), w: Number(m[4]), h: Number(m[5]), + }; + } + return tiles; +} + +// Mirror of the C compute_tiling(): recursively split the remaining region +// along its longer side, near-half to window i, remainder carried forward. +function computeTiling(area: Rect, n: number): Rect[] { + const out: Rect[] = []; + if (n <= 0) return out; + let region: Rect = { + x: area.x + GAP_OUTER, + y: area.y + GAP_OUTER, + w: Math.max(1, area.w - 2 * GAP_OUTER), + h: Math.max(1, area.h - 2 * GAP_OUTER), + }; + for (let i = 0; i < n; i++) { + if (i === n - 1) { out.push(region); break; } + const near: Rect = { ...region }; + const rest: Rect = { ...region }; + if (region.w >= region.h) { + const half = Math.max(1, Math.floor((region.w - GAP_INNER) / 2)); + near.w = half; + rest.x = region.x + half + GAP_INNER; + rest.w = region.w - half - GAP_INNER; + } else { + const half = Math.max(1, Math.floor((region.h - GAP_INNER) / 2)); + near.h = half; + rest.y = region.y + half + GAP_INNER; + rest.h = region.h - half - GAP_INNER; + } + out.push(near); + region = rest; + } + return out; +} + +function overlaps(a: Rect, b: Rect): boolean { + return a.x < b.x + b.w && b.x < a.x + a.w && + a.y < b.y + b.h && b.y < a.y + a.h; +} + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +async function waitFor( + ref: { value: string }, + needle: string | RegExp, + timeoutMs: number, + context: () => string, +): Promise { + const deadline = Date.now() + timeoutMs; + const hit = () => + typeof needle === "string" ? ref.value.includes(needle) : needle.test(ref.value); + while (Date.now() < deadline) { + if (hit()) return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${needle}.\n${context()}`); +} + +describe("wlcompositor — dwindle tiling partitions the output", () => { + it.skipIf(!hasBinaries)( + "three mapped clients tile into the exact non-overlapping dwindle partition", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + // Compositor in dwindle mode. + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + expect(out.value, `layout not dwindle.\n${dump()}`).toMatch(/WLC_LAYOUT dwindle/); + + // Three clients. Each maps a fixed-size window and then blocks waiting + // for input, so all three stay mapped simultaneously. We inject no + // input, so none exits before we read the layout. + host.spawn(clientBytes, ["wlclient-test"], {}); + host.spawn(clientBytes, ["wlclient-test"], {}); + host.spawn(clientBytes, ["wlclient-test"], {}); + + // The retile at the third map emits the full three-window partition. + await waitFor(out, /TILE n=3 i=2 /, 20_000, dump); + + const tiles = parseTiles(out.value, 3); + expect(tiles.filter(Boolean).length, `expected 3 tiles.\n${dump()}`).toBe(3); + + // 1) Emitted geometry equals the dwindle partition computed here. + const expected = computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 3); + expect(tiles, `tiling mismatch.\n${dump()}`).toEqual(expected); + + // 2) Every tile sits inside the gapped work area. + for (const t of tiles) { + expect(t.w).toBeGreaterThan(0); + expect(t.h).toBeGreaterThan(0); + expect(t.x).toBeGreaterThanOrEqual(GAP_OUTER); + expect(t.y).toBeGreaterThanOrEqual(GAP_OUTER); + expect(t.x + t.w).toBeLessThanOrEqual(CANVAS_W - GAP_OUTER); + expect(t.y + t.h).toBeLessThanOrEqual(CANVAS_H - GAP_OUTER); + } + + // 3) Pairwise non-overlapping — a genuine partition, not a stack. + for (let i = 0; i < tiles.length; i++) + for (let j = i + 1; j < tiles.length; j++) + expect(overlaps(tiles[i], tiles[j]), + `tiles ${i} and ${j} overlap.\n${dump()}`).toBe(false); + + void compExit; // cleaned up by host.destroy() in finally. + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); + + it.skipIf(!hasBinaries)( + "SUPER+SHIFT+2 moves the focused window to ws 2; ws 1 re-tiles the rest", + async () => { + const compositorBytes = loadBytes(compositorBin!); + const clientBytes = loadBytes(clientBin!); + + const out = { value: "" }; + const err = { value: "" }; + const host = new NodeKernelHost({ + onStdout: (_pid, data) => { out.value += new TextDecoder().decode(data); }, + onStderr: (_pid, data) => { err.value += new TextDecoder().decode(data); }, + }); + const dump = () => `--- stdout ---\n${out.value}\n--- stderr ---\n${err.value}`; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(compositorBytes, ["wlcompositor"], { + env: ["WLC_LAYOUT=dwindle"], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + host.spawn(clientBytes, ["wlclient-test"], {}); + host.spawn(clientBytes, ["wlclient-test"], {}); + host.spawn(clientBytes, ["wlclient-test"], {}); + await waitFor(out, /TILE n=3 i=2 /, 20_000, dump); + + // The last-mapped client holds keyboard focus. Send it to workspace 2 + // with SUPER+SHIFT+2 (press the modifiers, tap 2, release all). The + // compositor consumes the combo — the client never sees the keys. + const tap = (code: number, val: number) => { + host.injectInputEvent(0, EV_KEY, code, val); + host.injectInputEvent(0, EV_SYN, SYN_REPORT, 0); + }; + tap(KEY_LEFTMETA, 1); + tap(KEY_LEFTSHIFT, 1); + tap(KEY_2, 1); + tap(KEY_2, 0); + tap(KEY_LEFTSHIFT, 0); + tap(KEY_LEFTMETA, 0); + + await waitFor(out, /MOVE_TO_WS .* ws=2/, 10_000, dump); + await waitFor(out, /TILE n=2 i=1 /, 10_000, dump); + + // ws 1 now holds the two remaining windows in the exact 2-way dwindle + // partition — the moved window left and the rest re-tiled around it. + const tiles = parseTiles(out.value, 2); + expect(tiles.filter(Boolean).length, `expected 2 tiles.\n${dump()}`).toBe(2); + const expected = computeTiling({ x: 0, y: 0, w: CANVAS_W, h: CANVAS_H }, 2); + expect(tiles, `re-tile mismatch.\n${dump()}`).toEqual(expected); + expect(overlaps(tiles[0], tiles[1]), + `remaining tiles overlap.\n${dump()}`).toBe(false); + + void compExit; + } finally { + await host.destroy().catch(() => {}); + } + }, + 60_000, + ); +}); diff --git a/libc/glue/libegl_stub.c b/libc/glue/libegl_stub.c index 2e5dfb293c..cded9db4e1 100644 --- a/libc/glue/libegl_stub.c +++ b/libc/glue/libegl_stub.c @@ -27,6 +27,21 @@ static EGLint g_last_error = EGL_SUCCESS; static int g_initialized = 0; static int g_context_made = 0; static int g_surface_made = 0; +/* GPU-tier producer target: the bo handle (from PRIME_FD_TO_HANDLE / + * gbm_bo) whose FBO the NEXT eglCreateWindowSurface renders into. Set by + * wpkEglSetWindowSurfaceTarget, consumed once and cleared. 0 = an + * ordinary canvas/scanout window surface (the default). */ +static uint32_t g_pending_surface_target_bo = 0; +/* The native window (a struct wl_egl_window *) of the most recently created + * window surface, remembered so eglSwapBuffers can drive its wl_surface + * attach+commit. NULL for canvas/scanout surfaces (KMS compositor). */ +static void *g_current_egl_window = NULL; + +/* libwayland-egl hooks (libc/glue/libwayland-egl.c). Weak so a program that + * links libEGL WITHOUT libwayland-egl — a KMS/canvas GL client — still links; + * the symbols resolve to NULL and the wayland-egl path is simply skipped. */ +__attribute__((weak)) uint32_t _wpk_wlegl_bo_handle(void *egl_window); +__attribute__((weak)) void _wpk_wlegl_present(void *egl_window); #define EGL_DPY_HANDLE ((EGLDisplay)(uintptr_t)1) #define EGL_CONFIG_HANDLE ((EGLConfig) (uintptr_t)1) @@ -148,7 +163,7 @@ EGLContext eglCreateContext(EGLDisplay dpy, EGLConfig config, EGLSurface eglCreateWindowSurface(EGLDisplay dpy, EGLConfig config, EGLNativeWindowType win, const EGLint *attrib_list) { - (void)config; (void)win; + (void)config; if (dpy != EGL_DPY_HANDLE || g_fd < 0) { g_last_error = EGL_NOT_INITIALIZED; return EGL_NO_SURFACE; @@ -171,6 +186,22 @@ EGLSurface eglCreateWindowSurface(EGLDisplay dpy, EGLConfig config, if (a[0] == EGL_HEIGHT) surf.height = (uint32_t)a[1]; } } + /* GPU-tier producer targeting: reserved[0] carries the target bo + * handle. The kernel translates it to a global bo_id and the host + * redirects this surface's default-framebuffer renders into that + * bo's FBO (see GLIO_CREATE_SURFACE). Consumed once. 0 leaves the + * ordinary canvas/scanout behavior untouched. + * + * An explicit wpkEglSetWindowSurfaceTarget wins; otherwise, when the + * native window is a libwayland-egl wl_egl_window (SDL2's Wayland GL + * backend), take the bo it allocated. Remember that window so + * eglSwapBuffers can attach+commit it. */ + uint32_t target = g_pending_surface_target_bo; + g_pending_surface_target_bo = 0; + g_current_egl_window = (void *)win; + if (!target && win && _wpk_wlegl_bo_handle) + target = _wpk_wlegl_bo_handle((void *)win); + surf.reserved[0] = target; if (ioctl(g_fd, GLIO_CREATE_SURFACE, &surf) != 0) { g_last_error = EGL_BAD_ALLOC; return EGL_NO_SURFACE; @@ -211,6 +242,11 @@ EGLBoolean eglSwapBuffers(EGLDisplay dpy, EGLSurface surface) { g_last_error = EGL_BAD_SURFACE; return EGL_FALSE; } + /* For a libwayland-egl window the flush + GLIO_PRESENT above is the + * buffer-ready fence; now attach+commit the dmabuf buffer to the + * wl_surface. No-op (skipped) for canvas/scanout surfaces. */ + if (g_current_egl_window && _wpk_wlegl_present) + _wpk_wlegl_present(g_current_egl_window); return EGL_TRUE; } @@ -218,6 +254,7 @@ EGLBoolean eglDestroySurface(EGLDisplay dpy, EGLSurface surface) { if (dpy != EGL_DPY_HANDLE || surface != EGL_SURFACE_HANDLE) return EGL_FALSE; ioctl(g_fd, GLIO_DESTROY_SURFACE, NULL); g_surface_made = 0; + g_current_egl_window = NULL; return EGL_TRUE; } @@ -374,6 +411,18 @@ unsigned wpkEglBindBoTexture(EGLDisplay dpy, unsigned bo_handle, return req.gl_texture_id; } +/* Target the NEXT eglCreateWindowSurface at a GPU-tier bo's FBO: a + * producer renders its frame into `bo_handle` (allocated via + * gbm_bo_create with GPU usage, or imported via PRIME_FD_TO_HANDLE) + * instead of a display canvas, and the compositor samples it zero-copy + * with wpkEglBindBoTexture. Call immediately before eglCreateWindowSurface; + * the target is consumed once. Passing 0 (or not calling this) yields an + * ordinary window surface. No-op if the display isn't initialized. */ +void wpkEglSetWindowSurfaceTarget(EGLDisplay dpy, unsigned bo_handle) { + if (dpy != EGL_DPY_HANDLE) return; + g_pending_surface_target_bo = bo_handle; +} + /* Release a handle from wpkEglImportDmabufHandle. */ void wpkEglCloseBoHandle(EGLDisplay dpy, unsigned bo_handle) { if (dpy != EGL_DPY_HANDLE || g_fd < 0) return; diff --git a/libc/glue/libgbm_stub.c b/libc/glue/libgbm_stub.c index 1de2746cd4..b1df56c6f2 100644 --- a/libc/glue/libgbm_stub.c +++ b/libc/glue/libgbm_stub.c @@ -51,6 +51,20 @@ #include #include +/* WPK GPU-tier bo allocator (mirrors wasm_posix_shared::dri). A GPU-tier + * bo is backed by a host WebGLTexture (+FBO) on the compositor's shared + * context — unmappable on the CPU side, sampled/rendered zero-copy via + * the multiplexer. See DRM_IOCTL_WPK_CREATE_GPU_BO in shared. On return + * the `format` slot carries the per-fd handle and `usage` carries the + * stride (the 16-byte encoding is preserved). */ +#define WPK_DRM_IOCTL_CREATE_GPU_BO 0xc01064e0u /* _IOWR('d', 0xE0, 16) */ +struct wpk_drm_gpu_bo_create { + uint32_t width; + uint32_t height; + uint32_t format; /* in: fourcc. out: per-fd handle */ + uint32_t usage; /* in: GBM_BO_USE_*. out: stride (bytes) */ +}; + struct gbm_device { int fd; /* owned by caller; gbm_device_destroy does NOT close it */ }; @@ -64,6 +78,7 @@ struct gbm_bo { uint32_t bpp; /* bits per pixel */ uint64_t size; /* total bytes (pitch * height) */ uint64_t modifier; /* DRM_FORMAT_MOD_LINEAR for v1 */ + int is_gpu; /* 1 = GPU-tier (unmappable WebGLTexture bo) */ void *map_addr; /* lazy: set by gbm_bo_map */ size_t map_len; void *user_data; @@ -113,19 +128,60 @@ void gbm_device_destroy(struct gbm_device *gbm) { free(gbm); } +/* A bo purely used as a GL render target — RENDERING set, none of the + * CPU-facing usages (SCANOUT, CURSOR, WRITE, LINEAR) — can be a GPU-tier + * bo: unmappable, backed by a host WebGLTexture the compositor samples + * zero-copy. Anything the CPU must map (the scanout ring is SCANOUT| + * RENDERING; cursors are CURSOR|WRITE) stays a linear dumb bo. */ +static int usage_wants_gpu_tier(uint32_t flags) { + const uint32_t cpu_facing = + GBM_BO_USE_SCANOUT | GBM_BO_USE_CURSOR | + GBM_BO_USE_WRITE | GBM_BO_USE_LINEAR; + return (flags & GBM_BO_USE_RENDERING) && !(flags & cpu_facing); +} + struct gbm_bo *gbm_bo_create(struct gbm_device *gbm, uint32_t width, uint32_t height, uint32_t format, uint32_t flags) { - (void) flags; /* SCANOUT / CURSOR / RENDERING / LINEAR / etc. - * are advisory; v1 always allocates linear - * CPU-shared. The kernel rejects flags != 0 - * on the wire, so don't pass them through. */ uint32_t bpp = format_bpp(format); if (!gbm || !bpp || !width || !height) { errno = EINVAL; return NULL; } + struct gbm_bo *bo = (struct gbm_bo *) calloc(1, sizeof(*bo)); + if (!bo) { + errno = ENOMEM; + return NULL; + } + bo->dev = gbm; + bo->width = width; + bo->height = height; + bo->format = format; + bo->bpp = bpp; + bo->modifier = DRM_FORMAT_MOD_LINEAR; + + /* GPU tier first for render-only bos. On any error — notably ENOSYS + * when the host has no shared GL context yet (headless, or before the + * compositor's context exists) — fall through to a CPU-tier dumb bo, + * which BIND_FOREIGN_TEXTURE still samples via a host-side upload. */ + if (usage_wants_gpu_tier(flags)) { + struct wpk_drm_gpu_bo_create greq; + memset(&greq, 0, sizeof(greq)); + greq.width = width; + greq.height = height; + greq.format = format; + greq.usage = flags; + if (drmIoctl(gbm->fd, WPK_DRM_IOCTL_CREATE_GPU_BO, &greq) == 0) { + bo->handle = greq.format; /* out: per-fd handle */ + bo->stride = greq.usage; /* out: stride (bytes) */ + bo->size = (uint64_t) greq.usage * height; + bo->is_gpu = 1; + return bo; + } + /* else: degrade to CPU tier below. */ + } + struct drm_mode_create_dumb req; memset(&req, 0, sizeof(req)); req.height = height; @@ -133,26 +189,14 @@ struct gbm_bo *gbm_bo_create(struct gbm_device *gbm, req.bpp = bpp; req.flags = 0; if (drmIoctl(gbm->fd, DRM_IOCTL_MODE_CREATE_DUMB, &req) < 0) { - return NULL; - } - - struct gbm_bo *bo = (struct gbm_bo *) calloc(1, sizeof(*bo)); - if (!bo) { int save = errno; - /* Roll back kernel-side allocation on host-side OOM. */ - drmCloseBufferHandle(gbm->fd, req.handle); + free(bo); errno = save; return NULL; } - bo->dev = gbm; - bo->handle = req.handle; - bo->width = width; - bo->height = height; - bo->stride = req.pitch; - bo->size = req.size; - bo->format = format; - bo->bpp = bpp; - bo->modifier = DRM_FORMAT_MOD_LINEAR; + bo->handle = req.handle; + bo->stride = req.pitch; + bo->size = req.size; return bo; } @@ -232,6 +276,13 @@ void *gbm_bo_map(struct gbm_bo *bo, errno = EINVAL; return NULL; } + if (bo->is_gpu) { + /* GPU-tier bos live only as a host WebGLTexture — there is no CPU + * mapping. The kernel also rejects MAP_DUMB for them; fail here so + * a caller doesn't spin on a MAP_FAILED ioctl. */ + errno = EINVAL; + return NULL; + } if (bo->map_addr) { /* libgbm's contract permits repeated map calls; re-issue diff --git a/libc/glue/libglesv2_stub.c b/libc/glue/libglesv2_stub.c index 218cba042a..821865bc23 100644 --- a/libc/glue/libglesv2_stub.c +++ b/libc/glue/libglesv2_stub.c @@ -458,6 +458,28 @@ void glUniform4f(GLint location, GLfloat x, GLfloat y, GLfloat z, GLfloat w) { EMIT_END() } +/* Column-major 4x4 matrix uniforms — the MVP path any 3D client needs. + * WebGL2 rejects a + * transpose flag other than false, so the host forwards `transpose` + * verbatim to gl.uniformMatrix4fv; callers must pass GL_FALSE and supply + * column-major data. Payload: i32 loc, u32 count, u32 transposeBool, + * f32 mat[count*16]. */ +void glUniformMatrix4fv(GLint location, GLsizei count, GLboolean transpose, + const GLfloat *value) { + if (count < 0 || !value) return; + uint32_t floats = (uint32_t)count * 16u; + /* The TLV payload-length field is u16 — a single record holds at + * most (0xFFFF - 12) / 4 floats. One mat4 (16 floats) is far under + * that; guard anyway so an oversized array drops rather than truncates. */ + if (12u + floats * 4u > 0xFFFFu) return; + EMIT_BEGIN(OP_UNIFORM_MATRIX4FV, 12u + floats * 4u) + w_i32(&_c, location); + w_u32(&_c, (uint32_t)count); + w_u32(&_c, transpose ? 1u : 0u); + for (uint32_t i = 0; i < floats; i++) w_f32(&_c, value[i]); + EMIT_END() +} + /* ----- framebuffers ------------------------------------------------- */ void glGenFramebuffers(GLsizei n, GLuint *out) { diff --git a/libc/glue/libwayland-egl.c b/libc/glue/libwayland-egl.c new file mode 100644 index 0000000000..88c5b4f91d --- /dev/null +++ b/libc/glue/libwayland-egl.c @@ -0,0 +1,260 @@ +/* + * libwayland-egl shim for wasm-posix-kernel (step 12a). + * + * SDL2's upstream Wayland+OpenGLES backend (and any mesa-style client) drives + * a GL window through the standard libwayland-egl entry points: + * + * wl_egl_window_create(surface, w, h) -> struct wl_egl_window * + * eglCreateWindowSurface(dpy, cfg, (EGLNativeWindowType)egl_window) + * ... render GL ... + * eglSwapBuffers(dpy, egl_surface) -> present + * + * On a real system libwayland-egl is a dumb struct holder and mesa's egl_dri2 + * Wayland platform owns the buffer/present logic. We have no mesa, so this one + * translation unit merges both roles: it allocates the GPU-tier bo the window + * renders into, wraps it as a zwp_linux_dmabuf_v1 wl_buffer, and — driven by + * the two hooks libEGL calls (`_wpk_wlegl_bo_handle`, `_wpk_wlegl_present`) — + * targets that bo's FBO at surface creation and attach+commits it on swap. + * + * Design decisions (see docs/plans/2026-07-08-dri-wayland-compositor-plan.md + * §7.1 and the step-12 handoff): + * - The bo is created on the EGL session's OWN renderD128 fd (_wpk_gl_fd()) + * so its per-fd handle is the one eglCreateWindowSurface can target + * (GLIO_CREATE_SURFACE.reserved[0]). gbm_device_destroy does not close the + * fd, so tearing a window down never disturbs the live EGL session. + * - SINGLE reusable buffer (one bo / one wl_buffer, re-attached every frame). + * The GPU-tier bo is a persistent host WebGLTexture+FBO and the compositor + * re-binds the foreign texture per commit; the one shared GL submit queue + * orders render-before-sample, so no buffer pool / wl_buffer.release + * tracking is needed in v1. Live resize is therefore not supported — the + * window keeps its creation size (documented below). + * - The zwp_linux_dmabuf_v1 global is bound through a PRIVATE wl_event_queue + * so the roundtrip here never consumes events off the client's default + * queue (SDL dispatches that queue itself). + * + * The GPU path is browser-only (needs WebGL2). On a host without a shared GL + * context gbm degrades the bo to a CPU dumb bo; the shim still wires a valid + * dmabuf wl_buffer, but rendered content requires the GPU tier. + */ + +#include +#include +#include + +#include +#include +#include "linux-dmabuf-v1-client-protocol.h" + +#include +#include + +#include "wayland-egl-backend.h" +#include "gl_abi.h" + +/* Per-window backend state, hung off wl_egl_window.driver_private (the mesa + * contract). Both this file (producer) and libEGL (consumer, via the accessor + * hooks below) reach it only through the accessors, never by layout. */ +struct wpk_wlegl { + struct gbm_device *gbm; + struct gbm_bo *bo; + uint32_t bo_handle; /* per-fd handle on _wpk_gl_fd() */ + struct wl_event_queue *queue; /* private queue for the bind */ + struct zwp_linux_dmabuf_v1 *dmabuf; + struct wl_buffer *buffer; /* the single reusable buffer */ +}; + +/* ---- zwp_linux_dmabuf_v1 bind (private-queue registry roundtrip) --------- */ + +struct bind_state { struct zwp_linux_dmabuf_v1 *dmabuf; uint32_t version; }; + +static void dmabuf_format(void *d, struct zwp_linux_dmabuf_v1 *o, uint32_t f) { + (void)d; (void)o; (void)f; +} +static void dmabuf_modifier(void *d, struct zwp_linux_dmabuf_v1 *o, + uint32_t f, uint32_t hi, uint32_t lo) { + (void)d; (void)o; (void)f; (void)hi; (void)lo; +} +static const struct zwp_linux_dmabuf_v1_listener dmabuf_listener = { + .format = dmabuf_format, + .modifier = dmabuf_modifier, +}; + +static void reg_global(void *data, struct wl_registry *reg, uint32_t name, + const char *iface, uint32_t version) { + struct bind_state *b = data; + if (__builtin_strcmp(iface, "zwp_linux_dmabuf_v1") == 0) { + uint32_t v = version < 3 ? version : 3; + b->dmabuf = wl_registry_bind(reg, name, &zwp_linux_dmabuf_v1_interface, v); + b->version = v; + } +} +static void reg_global_remove(void *data, struct wl_registry *r, uint32_t n) { + (void)data; (void)r; (void)n; +} +static const struct wl_registry_listener reg_listener = { + .global = reg_global, + .global_remove = reg_global_remove, +}; + +/* Bind zwp_linux_dmabuf_v1 on `dpy` using a private queue so we don't steal + * events off the caller's default queue. Returns the bound proxy (already on + * `queue`) or NULL. */ +static struct zwp_linux_dmabuf_v1 *bind_dmabuf(struct wl_display *dpy, + struct wl_event_queue *queue) { + struct wl_registry *reg = wl_display_get_registry(dpy); + if (!reg) return NULL; + wl_proxy_set_queue((struct wl_proxy *)reg, queue); + struct bind_state b = { .dmabuf = NULL, .version = 0 }; + wl_registry_add_listener(reg, ®_listener, &b); + /* Two roundtrips: the first delivers the global, the second drains the + * format/modifier burst the compositor emits on bind (harmless to skip, + * but keeps the private queue tidy). */ + if (wl_display_roundtrip_queue(dpy, queue) < 0) { wl_registry_destroy(reg); return NULL; } + if (b.dmabuf) { + zwp_linux_dmabuf_v1_add_listener(b.dmabuf, &dmabuf_listener, &b); + wl_display_roundtrip_queue(dpy, queue); + } + wl_registry_destroy(reg); + return b.dmabuf; +} + +/* ---- buffer allocation -------------------------------------------------- */ + +/* Allocate the GPU-tier bo on the EGL fd and wrap it as a dmabuf wl_buffer. + * On success fills w->bo/gbm/bo_handle/buffer and returns 0. */ +static int alloc_buffer(struct wpk_wlegl *w, struct wl_egl_window *win, + int width, int height) { + int fd = _wpk_gl_fd(); + if (fd < 0) return -1; /* EGL not initialized yet — no session fd */ + + w->gbm = gbm_create_device(fd); + if (!w->gbm) return -1; + + /* GPU tier: RENDERING only (no CPU-facing usage) so gbm issues + * WPK_CREATE_GPU_BO — a host WebGLTexture+FBO we render into and sample + * zero-copy. Degrades to a CPU dumb bo on hosts without a shared GL ctx. */ + w->bo = gbm_bo_create(w->gbm, (uint32_t)width, (uint32_t)height, + GBM_FORMAT_XRGB8888, GBM_BO_USE_RENDERING); + if (!w->bo) return -1; + w->bo_handle = gbm_bo_get_handle(w->bo).u32; + + uint32_t stride = gbm_bo_get_stride(w->bo); + int prime = gbm_bo_get_fd(w->bo); + if (prime < 0) return -1; + + struct zwp_linux_buffer_params_v1 *params = + zwp_linux_dmabuf_v1_create_params(w->dmabuf); + zwp_linux_buffer_params_v1_add( + params, prime, 0, 0, stride, + (uint32_t)(DRM_FORMAT_MOD_LINEAR >> 32), + (uint32_t)(DRM_FORMAT_MOD_LINEAR & 0xffffffffu)); + w->buffer = zwp_linux_buffer_params_v1_create_immed( + params, width, height, DRM_FORMAT_XRGB8888, 0); + zwp_linux_buffer_params_v1_destroy(params); + close(prime); /* the compositor dup'd it into its own bo */ + + return w->buffer ? 0 : -1; +} + +static void free_buffer(struct wpk_wlegl *w) { + if (w->buffer) { wl_buffer_destroy(w->buffer); w->buffer = NULL; } + if (w->bo) { gbm_bo_destroy(w->bo); w->bo = NULL; } + if (w->gbm) { gbm_device_destroy(w->gbm); w->gbm = NULL; } /* no fd close */ + w->bo_handle = 0; +} + +/* ---- public libwayland-egl API ------------------------------------------ */ + +struct wl_egl_window * +wl_egl_window_create(struct wl_surface *surface, int width, int height) { + if (!surface || width <= 0 || height <= 0) return NULL; + + struct wl_egl_window *win = calloc(1, sizeof(*win)); + struct wpk_wlegl *w = calloc(1, sizeof(*w)); + if (!win || !w) { free(win); free(w); return NULL; } + + struct wl_display *dpy = wl_proxy_get_display((struct wl_proxy *)surface); + if (!dpy) { free(win); free(w); return NULL; } + w->queue = wl_display_create_queue(dpy); + if (!w->queue) { free(win); free(w); return NULL; } + + w->dmabuf = bind_dmabuf(dpy, w->queue); + if (!w->dmabuf || alloc_buffer(w, win, width, height) != 0) { + free_buffer(w); + if (w->dmabuf) zwp_linux_dmabuf_v1_destroy(w->dmabuf); + wl_event_queue_destroy(w->queue); + free(win); free(w); + return NULL; + } + + /* Fill the canonical backend struct. `version` is a const field, so cast + * through the address to initialise it once at construction. */ + *(intptr_t *)&win->version = WL_EGL_WINDOW_VERSION; + win->width = width; + win->height = height; + win->dx = win->dy = 0; + win->attached_width = width; + win->attached_height = height; + win->surface = surface; + win->driver_private = w; + win->resize_callback = NULL; + win->destroy_window_callback = NULL; + return win; +} + +void wl_egl_window_destroy(struct wl_egl_window *win) { + if (!win) return; + struct wpk_wlegl *w = win->driver_private; + if (w) { + free_buffer(w); + if (w->dmabuf) zwp_linux_dmabuf_v1_destroy(w->dmabuf); + if (w->queue) wl_event_queue_destroy(w->queue); + free(w); + } + free(win); +} + +/* v1 keeps the creation size: the GL surface's FBO target is bound to the + * bo at eglCreateWindowSurface time and there's no path to re-target a live + * EGL surface, so a genuine resize would desync render size from the buffer. + * We record the request (SDL reads it back) but do not reallocate. */ +void wl_egl_window_resize(struct wl_egl_window *win, int width, int height, + int dx, int dy) { + if (!win) return; + win->dx = dx; + win->dy = dy; + if (width > 0) win->width = width; + if (height > 0) win->height = height; +} + +void wl_egl_window_get_attached_size(struct wl_egl_window *win, + int *width, int *height) { + if (!win) { if (width) *width = 0; if (height) *height = 0; return; } + if (width) *width = win->attached_width; + if (height) *height = win->attached_height; +} + +/* ---- hooks called by libEGL (libegl_stub.c) ----------------------------- */ + +/* Return the GPU-tier bo handle an eglCreateWindowSurface should target for + * this native window, or 0 if it isn't one of ours. */ +uint32_t _wpk_wlegl_bo_handle(void *egl_window) { + if (!egl_window) return 0; + struct wl_egl_window *win = egl_window; + struct wpk_wlegl *w = win->driver_private; + return w ? w->bo_handle : 0; +} + +/* Present: attach the (already-rendered, already-flushed) buffer to the + * surface and commit. libEGL's eglSwapBuffers has issued the GL flush + + * GLIO_PRESENT fence before calling this, so the frame is complete on the + * shared context by the time the compositor samples it. */ +void _wpk_wlegl_present(void *egl_window) { + struct wl_egl_window *win = egl_window; + struct wpk_wlegl *w = win ? win->driver_private : NULL; + if (!egl_window) return; + if (!w || !w->buffer || !win->surface) return; + wl_surface_attach(win->surface, w->buffer, 0, 0); + wl_surface_damage(win->surface, 0, 0, win->width, win->height); + wl_surface_commit(win->surface); +} diff --git a/libc/glue/wayland-egl-include/wayland-egl-backend.h b/libc/glue/wayland-egl-include/wayland-egl-backend.h new file mode 100644 index 0000000000..e5287b766b --- /dev/null +++ b/libc/glue/wayland-egl-include/wayland-egl-backend.h @@ -0,0 +1,67 @@ +/* + * Copyright © 2011 Benjamin Franzke + * + * Permission is hereby granted, free of charge, to any person obtaining a + * copy of this software and associated documentation files (the "Software"), + * to deal in the Software without restriction, including without limitation + * the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice (including the next + * paragraph) shall be included in all copies or substantial portions of the + * Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * Authors: + * Benjamin Franzke + */ + +#ifndef _WAYLAND_EGL_PRIV_H +#define _WAYLAND_EGL_PRIV_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * NOTE: This version must be kept in sync with the version field in the + * wayland-egl-backend pkgconfig file generated in meson.build. + */ +#define WL_EGL_WINDOW_VERSION 3 + +struct wl_surface; + +struct wl_egl_window { + const intptr_t version; + + int width; + int height; + int dx; + int dy; + + int attached_width; + int attached_height; + + void *driver_private; + void (*resize_callback)(struct wl_egl_window *, void *); + void (*destroy_window_callback)(void *); + + struct wl_surface *surface; +}; + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/libc/glue/wayland-egl-include/wayland-egl-core.h b/libc/glue/wayland-egl-include/wayland-egl-core.h new file mode 100644 index 0000000000..b3ab5124de --- /dev/null +++ b/libc/glue/wayland-egl-include/wayland-egl-core.h @@ -0,0 +1,59 @@ +/* + * Copyright © 2011 Kristian Høgsberg + * Copyright © 2011 Benjamin Franzke + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice (including the + * next paragraph) shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef WAYLAND_EGL_CORE_H +#define WAYLAND_EGL_CORE_H + +#ifdef __cplusplus +extern "C" { +#endif + +#define WL_EGL_PLATFORM 1 + +struct wl_egl_window; +struct wl_surface; + +struct wl_egl_window * +wl_egl_window_create(struct wl_surface *surface, + int width, int height); + +void +wl_egl_window_destroy(struct wl_egl_window *egl_window); + +void +wl_egl_window_resize(struct wl_egl_window *egl_window, + int width, int height, + int dx, int dy); + +void +wl_egl_window_get_attached_size(struct wl_egl_window *egl_window, + int *width, int *height); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/libc/glue/wayland-egl-include/wayland-egl.h b/libc/glue/wayland-egl-include/wayland-egl.h new file mode 100644 index 0000000000..279dcb8bb8 --- /dev/null +++ b/libc/glue/wayland-egl-include/wayland-egl.h @@ -0,0 +1,33 @@ +/* + * Copyright © 2011 Kristian Høgsberg + * Copyright © 2011 Benjamin Franzke + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice (including the + * next paragraph) shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +#ifndef WAYLAND_EGL_H +#define WAYLAND_EGL_H + +#include +#include "wayland-egl-core.h" + +#endif diff --git a/packages/registry/libwayland/build-libwayland.sh b/packages/registry/libwayland/build-libwayland.sh index 464c948d53..84180b7202 100755 --- a/packages/registry/libwayland/build-libwayland.sh +++ b/packages/registry/libwayland/build-libwayland.sh @@ -154,6 +154,36 @@ wasm32posix-ar rcs "$INSTALL_DIR/lib/libwayland-client.a" \ wasm32posix-ar rcs "$INSTALL_DIR/lib/libwayland-server.a" \ "${SHARED_OBJS[@]}" "$SERVER_MAIN_OBJ" "$SHM_OBJ" "$LOOP_OBJ" +# --- libwayland-cursor.a (step 12b) ------------------------------------- +# SDL2's upstream Wayland backend (SDL_waylandmouse.c) links the five +# wl_cursor_* symbols from libwayland-cursor. The cursor lib is +# self-contained: wayland-cursor.c parses XCursor theme files from disk +# via xcursor.c, and falls back to the built-in cursor-data.h theme when +# no on-disk theme is found (so it works on wasm32 with no /usr/share/icons). +# os-compatibility.c provides os_create_anonymous_file (memfd/mkostemp, +# per config.h HAVE_MEMFD_CREATE) for the shm pool the cursor buffers live +# in. It references wl_shm_* from the client protocol, resolved when a +# client links libwayland-client alongside. +echo "==> Compiling + archiving libwayland-cursor.a..." +CURSOR_SRC="$SRC_DIR/cursor" +CURSOR_CFLAGS=( + -O2 -fPIC -fvisibility=hidden -std=gnu11 + -DHAVE_CONFIG_H + "-I$SRC_DIR" # config.h at source root + "-I$WLSRC" # wayland-client.h + generated wayland-client-protocol.h + "-I$CURSOR_SRC" # xcursor.h, os-compatibility.h, wayland-cursor.h, cursor-data.h + "-I$LIBFFI_PREFIX/include" + -Wno-unused-parameter -Wno-unused-function -Wno-unused-variable +) +CURSOR_OBJS=() +for tu in wayland-cursor.c os-compatibility.c xcursor.c; do + echo " cursor/$tu" >&2 + obj="$BUILD_DIR/cursor-$(basename "$tu" .c).o" + wasm32posix-cc -c "${CURSOR_CFLAGS[@]}" "$CURSOR_SRC/$tu" -o "$obj" + CURSOR_OBJS+=("$obj") +done +wasm32posix-ar rcs "$INSTALL_DIR/lib/libwayland-cursor.a" "${CURSOR_OBJS[@]}" + # --- Install public headers -------------------------------------------- echo "==> Installing headers..." for h in \ @@ -165,6 +195,80 @@ do cp "$WLSRC/$h" "$INSTALL_DIR/include/$h" done +# Client-side EGL + cursor headers (step 12b): SDL2's Wayland backend +# includes and "wayland-cursor.h". The wayland-egl headers +# are the platform-neutral EGLNativeWindowType contract (our shim +# libwayland-egl.a — built in scripts/build-programs.sh — implements the +# wl_egl_window_* symbols); wayland-cursor.h matches libwayland-cursor.a +# above. All vendored verbatim from this same wayland 1.24.0 source tree. +for h in wayland-egl.h wayland-egl-core.h wayland-egl-backend.h; do + cp "$SRC_DIR/egl/$h" "$INSTALL_DIR/include/$h" +done +cp "$CURSOR_SRC/wayland-cursor.h" "$INSTALL_DIR/include/wayland-cursor.h" + +# --- pkg-config .pc files (step 12b) ----------------------------------- +# SDL2's configure gates the Wayland backend on +# $PKG_CONFIG --exists 'wayland-client >= 1.18' wayland-scanner \ +# wayland-egl wayland-cursor egl 'xkbcommon >= 0.5.0' +# (configure.ac CheckWayland ~line 1742). We ship the wayland-* modules +# here; xkbcommon.pc comes from libxkbcommon and egl.pc from the sdl2 +# build (our libEGL stub). The wasm32posix-pkg-config wrapper reads these +# via PKG_CONFIG_PATH (kandelo cache paths are kept; host .pc filtered). +# wayland_scanner is a bare name so SDL's generated Makefile resolves it +# off PATH in the dev shell (matching the host wayland-scanner we used). +echo "==> Writing pkg-config .pc files..." +PC_DIR="$INSTALL_DIR/lib/pkgconfig" +mkdir -p "$PC_DIR" + +cat > "$PC_DIR/wayland-client.pc" < "$PC_DIR/wayland-egl.pc" < "$PC_DIR/wayland-cursor.pc" < "$PC_DIR/wayland-scanner.pc" < libwayland $WL_VERSION installed at $INSTALL_DIR" echo " lib/libwayland-client.a ($(wc -c < "$INSTALL_DIR/lib/libwayland-client.a") bytes)" echo " lib/libwayland-server.a ($(wc -c < "$INSTALL_DIR/lib/libwayland-server.a") bytes)" +echo " lib/libwayland-cursor.a ($(wc -c < "$INSTALL_DIR/lib/libwayland-cursor.a") bytes)" diff --git a/packages/registry/libwayland/build.toml b/packages/registry/libwayland/build.toml index 8de444f5c6..39c21efff5 100644 --- a/packages/registry/libwayland/build.toml +++ b/packages/registry/libwayland/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/libwayland/build-libwayland.sh" repo_url = "https://github.com/Automattic/kandelo.git" commit = "1dc10fcc1761475964c0108628a800c3c5e468e5" -revision = 1 +revision = 2 # The hand-curated config.h is part of the cache key: editing it (e.g. # flipping a HAVE_* to track a sysroot change) must invalidate cached diff --git a/packages/registry/libxkbcommon/build-libxkbcommon.sh b/packages/registry/libxkbcommon/build-libxkbcommon.sh index 0b446a5f97..a7f3fd379f 100755 --- a/packages/registry/libxkbcommon/build-libxkbcommon.sh +++ b/packages/registry/libxkbcommon/build-libxkbcommon.sh @@ -117,5 +117,24 @@ do cp "$SRC_DIR/include/xkbcommon/$h" "$INSTALL_DIR/include/xkbcommon/$h" done +# --- pkg-config .pc file (step 12b) ------------------------------------ +# SDL2's Wayland backend gate requires `xkbcommon >= 0.5.0` via pkg-config +# (configure.ac CheckWayland). The wasm32posix-pkg-config wrapper reads +# this through PKG_CONFIG_PATH (kandelo cache paths pass its filter). +echo "==> Writing xkbcommon.pc..." +PC_DIR="$INSTALL_DIR/lib/pkgconfig" +mkdir -p "$PC_DIR" +cat > "$PC_DIR/xkbcommon.pc" < libxkbcommon $XKB_VERSION installed at $INSTALL_DIR" echo " lib/libxkbcommon.a ($(wc -c < "$INSTALL_DIR/lib/libxkbcommon.a") bytes)" diff --git a/packages/registry/libxkbcommon/build.toml b/packages/registry/libxkbcommon/build.toml index a6ec6f9ca0..864ea6b840 100644 --- a/packages/registry/libxkbcommon/build.toml +++ b/packages/registry/libxkbcommon/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/libxkbcommon/build-libxkbcommon.sh" repo_url = "https://github.com/Automattic/kandelo.git" commit = "38670846e" -revision = 1 +revision = 2 # The hand-curated config.h is part of the cache key: editing it (e.g. # flipping a HAVE_* to track a sysroot change) must invalidate cached diff --git a/packages/registry/sdl2/build-sdl2.sh b/packages/registry/sdl2/build-sdl2.sh index c19b26ebfa..0dd5c24d16 100644 --- a/packages/registry/sdl2/build-sdl2.sh +++ b/packages/registry/sdl2/build-sdl2.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash # Build upstream SDL 2 for Kandelo with its unmodified OSS dsp audio -# backend, its KMSDRM video backend, and its direct evdev input path. +# backend, its KMSDRM and Wayland video backends, and its direct evdev +# input path. The Wayland backend (step 12) runs GL clients against +# wlcompositor through libwayland-client + the wl_egl_window shim +# (libwayland-egl.a) + libxkbcommon. set -euo pipefail @@ -24,6 +27,13 @@ if [ "$TARGET_ARCH" != "wasm32" ]; then fi export WASM_POSIX_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +# Wayland backend deps (step 12b): libwayland provides +# libwayland-{client,cursor}.a + wayland-egl/cursor headers + the +# wayland-{client,egl,cursor,scanner}.pc files; libxkbcommon provides +# libxkbcommon.a + xkbcommon.pc. Their pkgconfig dirs feed SDL2's +# configure gate (see the CheckWayland short-circuit below). +LIBWAYLAND_PREFIX="${WASM_POSIX_DEP_LIBWAYLAND_DIR:?WASM_POSIX_DEP_LIBWAYLAND_DIR not set (must be invoked via cargo xtask build-deps resolve sdl2)}" +LIBXKBCOMMON_PREFIX="${WASM_POSIX_DEP_LIBXKBCOMMON_DIR:?WASM_POSIX_DEP_LIBXKBCOMMON_DIR not set (must be invoked via cargo xtask build-deps resolve sdl2)}" # The KMSDRM backend links libdrm and libgbm. libdrm is a package the # resolver stages for us; libgbm is a sysroot library scripts/ @@ -59,7 +69,43 @@ tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 echo "==> Applying the Kandelo platform-classification patch..." patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/patches/0001-recognize-kandelo-as-unix.patch" -echo "==> Configuring SDL2 with the OSS, KMSDRM and evdev backends..." +# --- Wayland pkg-config wiring (step 12b) ------------------------------ +# SDL2's configure gates the Wayland backend on a hard pkg-config probe +# (configure.ac CheckWayland ~L1742): +# $PKG_CONFIG --exists 'wayland-client >= 1.18' wayland-scanner \ +# wayland-egl wayland-cursor egl 'xkbcommon >= 0.5.0' +# The wayland-* .pc files ship in libwayland's prefix, xkbcommon.pc in +# libxkbcommon's. egl.pc has no owning resolver package (our libEGL is the +# sysroot stub from scripts/build-gles-stubs.sh), so we synthesize a +# minimal one here purely to satisfy the --exists gate — SDL compiles +# against its own bundled khronos EGL headers (src/video/khronos), and +# the wayland backend links libEGL.a explicitly at client-link time +# (step 12c), so egl.pc's Libs/Cflags are never consumed. PKG_CONFIG +# points at the cross wrapper, which reads PKG_CONFIG_PATH (kandelo +# cache + this build dir pass its host-path filter). +export PKG_CONFIG=wasm32posix-pkg-config +PC_LOCAL="$BUILD_DIR/pkgconfig" +mkdir -p "$PC_LOCAL" +cat > "$PC_LOCAL/egl.pc" <= 1.18' wayland-scanner \ + wayland-egl wayland-cursor egl 'xkbcommon >= 0.5.0'; then + echo "ERROR: wayland pkg-config gate failed. PKG_CONFIG_PATH=$PKG_CONFIG_PATH" >&2 + "$PKG_CONFIG" --exists --print-errors 'wayland-client >= 1.18' \ + wayland-scanner wayland-egl wayland-cursor egl 'xkbcommon >= 0.5.0' >&2 || true + exit 1 +fi + +echo "==> Configuring SDL2 with the OSS, KMSDRM, Wayland and evdev backends..." # Kandelo exposes neither the non-POSIX sysctl header nor its matching API. # Pin the cross-compile probe so SDL uses its portable sysconf path. # Executable links intentionally permit unresolved host imports, so link-only @@ -106,7 +152,9 @@ echo "==> Configuring SDL2 with the OSS, KMSDRM and evdev backends..." --enable-video-kmsdrm \ --disable-kmsdrm-shared \ --disable-video-x11 \ - --disable-video-wayland \ + --enable-video-wayland \ + --disable-wayland-shared \ + --disable-libdecor \ --disable-video-vivante \ --disable-video-cocoa \ --disable-video-directfb \ @@ -171,11 +219,12 @@ test -f "$INSTALL_DIR/lib/pkgconfig/sdl2.pc" # Autoconf silently drops a backend whose probe fails, which would leave a # library that links but cannot open a window. Fail the build instead. -for feature in SDL_VIDEO_DRIVER_KMSDRM SDL_VIDEO_OPENGL_ES2 \ - SDL_VIDEO_OPENGL_EGL SDL_INPUT_LINUXEV SDL_AUDIO_DRIVER_OSS; do +for feature in SDL_VIDEO_DRIVER_KMSDRM SDL_VIDEO_DRIVER_WAYLAND \ + SDL_VIDEO_OPENGL_ES2 SDL_VIDEO_OPENGL_EGL SDL_INPUT_LINUXEV \ + SDL_AUDIO_DRIVER_OSS; do grep -q "^#define $feature 1" "$INSTALL_DIR/include/SDL2/SDL_config.h" || { echo "ERROR: configure did not enable $feature" >&2 exit 1 } done -echo "==> SDL2 static package complete (KMSDRM video, evdev input, OSS audio)" +echo "==> SDL2 static package complete (KMSDRM + Wayland video, evdev input, OSS audio)" diff --git a/packages/registry/sdl2/build.toml b/packages/registry/sdl2/build.toml index a10883cd56..b1e2f5f5eb 100644 --- a/packages/registry/sdl2/build.toml +++ b/packages/registry/sdl2/build.toml @@ -11,4 +11,4 @@ inputs = [ ] repo_url = "https://github.com/Automattic/kandelo.git" commit = "UNPUBLISHED" -revision = 2 +revision = 3 diff --git a/packages/registry/sdl2/package.toml b/packages/registry/sdl2/package.toml index 4958a5b598..464417ce1e 100644 --- a/packages/registry/sdl2/package.toml +++ b/packages/registry/sdl2/package.toml @@ -3,7 +3,7 @@ kind = "library" name = "sdl2" version = "2.32.10" kernel_abi = 43 -depends_on = ["libdrm@2.4.120"] +depends_on = ["libdrm@2.4.120", "libwayland@1.24.0", "libxkbcommon@1.7.0"] arches = ["wasm32"] [source] diff --git a/packages/registry/wayland-protocols/test/generate-and-verify.sh b/packages/registry/wayland-protocols/test/generate-and-verify.sh index ca68108d6f..fb80384484 100755 --- a/packages/registry/wayland-protocols/test/generate-and-verify.sh +++ b/packages/registry/wayland-protocols/test/generate-and-verify.sh @@ -40,8 +40,9 @@ gen() { wayland-scanner private-code "$xml" "$WORK/${base}-protocol.c" } -gen wayland "$XML_DIR/wayland.xml" -gen xdg-shell "$XML_DIR/xdg-shell.xml" +gen wayland "$XML_DIR/wayland.xml" +gen xdg-shell "$XML_DIR/xdg-shell.xml" +gen linux-dmabuf-v1 "$XML_DIR/linux-dmabuf-v1.xml" # --- completeness: every v1 interface must appear in the generated code --- failures=0 @@ -66,12 +67,17 @@ for i in xdg_wm_base xdg_surface xdg_toplevel; do check_iface xdg-shell-protocol.c "$i" done +echo "wayland-protocols: linux-dmabuf-v1 interfaces:" +for i in zwp_linux_dmabuf_v1 zwp_linux_buffer_params_v1; do + check_iface linux-dmabuf-v1-protocol.c "$i" +done + # --- optional wasm32 compile of the generated glue ------------------------ if command -v wasm32posix-cc >/dev/null 2>&1 && [ -n "${WAYLAND_UTIL_H:-}" ] \ && [ -f "${WAYLAND_UTIL_H:-/nonexistent}" ]; then echo "wayland-protocols: wasm32-compiling generated glue (wayland-util.h=$WAYLAND_UTIL_H)" inc="$WORK/inc"; mkdir -p "$inc"; cp "$WAYLAND_UTIL_H" "$inc/wayland-util.h" - for base in wayland xdg-shell; do + for base in wayland xdg-shell linux-dmabuf-v1; do if wasm32posix-cc -c -O2 -fPIC -I"$inc" \ "$WORK/${base}-protocol.c" -o "$WORK/${base}-protocol.o"; then echo " OK wasm32 ${base}-protocol.o ($(wc -c < "$WORK/${base}-protocol.o") bytes)" diff --git a/packages/registry/wayland-protocols/xml/linux-dmabuf-v1.xml b/packages/registry/wayland-protocols/xml/linux-dmabuf-v1.xml new file mode 100644 index 0000000000..12d09fb28f --- /dev/null +++ b/packages/registry/wayland-protocols/xml/linux-dmabuf-v1.xml @@ -0,0 +1,585 @@ + + + + + Copyright © 2014, 2015 Collabora, Ltd. + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + + This interface offers ways to create generic dmabuf-based wl_buffers. + + For more information about dmabuf, see: + https://www.kernel.org/doc/html/next/userspace-api/dma-buf-alloc-exchange.html + + Clients can use the get_surface_feedback request to get dmabuf feedback + for a particular surface. If the client wants to retrieve feedback not + tied to a surface, they can use the get_default_feedback request. + + The following are required from clients: + + - Clients must ensure that either all data in the dma-buf is + coherent for all subsequent read access or that coherency is + correctly handled by the underlying kernel-side dma-buf + implementation. + + - Don't make any more attachments after sending the buffer to the + compositor. Making more attachments later increases the risk of + the compositor not being able to use (re-import) an existing + dmabuf-based wl_buffer. + + The underlying graphics stack must ensure the following: + + - The dmabuf file descriptors relayed to the server will stay valid + for the whole lifetime of the wl_buffer. This means the server may + at any time use those fds to import the dmabuf into any kernel + sub-system that might accept it. + + However, when the underlying graphics stack fails to deliver the + promise, because of e.g. a device hot-unplug which raises internal + errors, after the wl_buffer has been successfully created the + compositor must not raise protocol errors to the client when dmabuf + import later fails. + + To create a wl_buffer from one or more dmabufs, a client creates a + zwp_linux_dmabuf_params_v1 object with a zwp_linux_dmabuf_v1.create_params + request. All planes required by the intended format are added with + the 'add' request. Finally, a 'create' or 'create_immed' request is + issued, which has the following outcome depending on the import success. + + The 'create' request, + - on success, triggers a 'created' event which provides the final + wl_buffer to the client. + - on failure, triggers a 'failed' event to convey that the server + cannot use the dmabufs received from the client. + + For the 'create_immed' request, + - on success, the server immediately imports the added dmabufs to + create a wl_buffer. No event is sent from the server in this case. + - on failure, the server can choose to either: + - terminate the client by raising a fatal error. + - mark the wl_buffer as failed, and send a 'failed' event to the + client. If the client uses a failed wl_buffer as an argument to any + request, the behaviour is compositor implementation-defined. + + For all DRM formats and unless specified in another protocol extension, + pre-multiplied alpha is used for pixel values. + + Unless specified otherwise in another protocol extension, implicit + synchronization is used. In other words, compositors and clients must + wait and signal fences implicitly passed via the DMA-BUF's reservation + mechanism. + + + + + Objects created through this interface, especially wl_buffers, will + remain valid. + + + + + + This temporary object is used to collect multiple dmabuf handles into + a single batch to create a wl_buffer. It can only be used once and + should be destroyed after a 'created' or 'failed' event has been + received. + + + + + + + This event advertises one buffer format that the server supports. + All the supported formats are advertised once when the client + binds to this interface. A roundtrip after binding guarantees + that the client has received all supported formats. + + For the definition of the format codes, see the + zwp_linux_buffer_params_v1::create request. + + Starting version 4, the format event is deprecated and must not be + sent by compositors. Instead, use get_default_feedback or + get_surface_feedback. + + + + + + + This event advertises the formats that the server supports, along with + the modifiers supported for each format. All the supported modifiers + for all the supported formats are advertised once when the client + binds to this interface. A roundtrip after binding guarantees that + the client has received all supported format-modifier pairs. + + For legacy support, DRM_FORMAT_MOD_INVALID (that is, modifier_hi == + 0x00ffffff and modifier_lo == 0xffffffff) is allowed in this event. + It indicates that the server can support the format with an implicit + modifier. When a plane has DRM_FORMAT_MOD_INVALID as its modifier, it + is as if no explicit modifier is specified. The effective modifier + will be derived from the dmabuf. + + A compositor that sends valid modifiers and DRM_FORMAT_MOD_INVALID for + a given format supports both explicit modifiers and implicit modifiers. + + For the definition of the format and modifier codes, see the + zwp_linux_buffer_params_v1::create and zwp_linux_buffer_params_v1::add + requests. + + Starting version 4, the modifier event is deprecated and must not be + sent by compositors. Instead, use get_default_feedback or + get_surface_feedback. + + + + + + + + + + + This request creates a new wp_linux_dmabuf_feedback object not bound + to a particular surface. This object will deliver feedback about dmabuf + parameters to use if the client doesn't support per-surface feedback + (see get_surface_feedback). + + + + + + + This request creates a new wp_linux_dmabuf_feedback object for the + specified wl_surface. This object will deliver feedback about dmabuf + parameters to use for buffers attached to this surface. + + If the surface is destroyed before the wp_linux_dmabuf_feedback object, + the feedback object becomes inert. + + + + + + + + + This temporary object is a collection of dmabufs and other + parameters that together form a single logical buffer. The temporary + object may eventually create one wl_buffer unless cancelled by + destroying it before requesting 'create'. + + Single-planar formats only require one dmabuf, however + multi-planar formats may require more than one dmabuf. For all + formats, an 'add' request must be called once per plane (even if the + underlying dmabuf fd is identical). + + You must use consecutive plane indices ('plane_idx' argument for 'add') + from zero to the number of planes used by the drm_fourcc format code. + All planes required by the format must be given exactly once, but can + be given in any order. Each plane index can only be set once; subsequent + calls with a plane index which has already been set will result in a + plane_set error being generated. + + + + + + + + + + + + + + + + Cleans up the temporary data sent to the server for dmabuf-based + wl_buffer creation. + + + + + + This request adds one dmabuf to the set in this + zwp_linux_buffer_params_v1. + + The 64-bit unsigned value combined from modifier_hi and modifier_lo + is the dmabuf layout modifier. DRM AddFB2 ioctl calls this the + fb modifier, which is defined in drm_mode.h of Linux UAPI. + This is an opaque token. Drivers use this token to express tiling, + compression, etc. driver-specific modifications to the base format + defined by the DRM fourcc code. + + Starting from version 4, the invalid_format protocol error is sent if + the format + modifier pair was not advertised as supported. + + Starting from version 5, the invalid_format protocol error is sent if + all planes don't use the same modifier. + + This request raises the PLANE_IDX error if plane_idx is too large. + The error PLANE_SET is raised if attempting to set a plane that + was already set. + + + + + + + + + + + + + + + + + + This asks for creation of a wl_buffer from the added dmabuf + buffers. The wl_buffer is not created immediately but returned via + the 'created' event if the dmabuf sharing succeeds. The sharing + may fail at runtime for reasons a client cannot predict, in + which case the 'failed' event is triggered. + + The 'format' argument is a DRM_FORMAT code, as defined by the + libdrm's drm_fourcc.h. The Linux kernel's DRM sub-system is the + authoritative source on how the format codes should work. + + The 'flags' is a bitfield of the flags defined in enum "flags". + 'y_invert' means the that the image needs to be y-flipped. + + Flag 'interlaced' means that the frame in the buffer is not + progressive as usual, but interlaced. An interlaced buffer as + supported here must always contain both top and bottom fields. + The top field always begins on the first pixel row. The temporal + ordering between the two fields is top field first, unless + 'bottom_first' is specified. It is undefined whether 'bottom_first' + is ignored if 'interlaced' is not set. + + This protocol does not convey any information about field rate, + duration, or timing, other than the relative ordering between the + two fields in one buffer. A compositor may have to estimate the + intended field rate from the incoming buffer rate. It is undefined + whether the time of receiving wl_surface.commit with a new buffer + attached, applying the wl_surface state, wl_surface.frame callback + trigger, presentation, or any other point in the compositor cycle + is used to measure the frame or field times. There is no support + for detecting missed or late frames/fields/buffers either, and + there is no support whatsoever for cooperating with interlaced + compositor output. + + The composited image quality resulting from the use of interlaced + buffers is explicitly undefined. A compositor may use elaborate + hardware features or software to deinterlace and create progressive + output frames from a sequence of interlaced input buffers, or it + may produce substandard image quality. However, compositors that + cannot guarantee reasonable image quality in all cases are recommended + to just reject all interlaced buffers. + + Any argument errors, including non-positive width or height, + mismatch between the number of planes and the format, bad + format, bad offset or stride, may be indicated by fatal protocol + errors: INCOMPLETE, INVALID_FORMAT, INVALID_DIMENSIONS, + OUT_OF_BOUNDS. + + Dmabuf import errors in the server that are not obvious client + bugs are returned via the 'failed' event as non-fatal. This + allows attempting dmabuf sharing and falling back in the client + if it fails. + + This request can be sent only once in the object's lifetime, after + which the only legal request is destroy. This object should be + destroyed after issuing a 'create' request. Attempting to use this + object after issuing 'create' raises ALREADY_USED protocol error. + + It is not mandatory to issue 'create'. If a client wants to + cancel the buffer creation, it can just destroy this object. + + + + + + + + + + This event indicates that the attempted buffer creation was + successful. It provides the new wl_buffer referencing the dmabuf(s). + + Upon receiving this event, the client should destroy the + zwp_linux_buffer_params_v1 object. + + + + + + + This event indicates that the attempted buffer creation has + failed. It usually means that one of the dmabuf constraints + has not been fulfilled. + + Upon receiving this event, the client should destroy the + zwp_linux_buffer_params_v1 object. + + + + + + This asks for immediate creation of a wl_buffer by importing the + added dmabufs. + + In case of import success, no event is sent from the server, and the + wl_buffer is ready to be used by the client. + + Upon import failure, either of the following may happen, as seen fit + by the implementation: + - the client is terminated with one of the following fatal protocol + errors: + - INCOMPLETE, INVALID_FORMAT, INVALID_DIMENSIONS, OUT_OF_BOUNDS, + in case of argument errors such as mismatch between the number + of planes and the format, bad format, non-positive width or + height, or bad offset or stride. + - INVALID_WL_BUFFER, in case the cause for failure is unknown or + platform specific. + - the server creates an invalid wl_buffer, marks it as failed and + sends a 'failed' event to the client. The result of using this + invalid wl_buffer as an argument in any request by the client is + defined by the compositor implementation. + + This takes the same arguments as a 'create' request, and obeys the + same restrictions. + + + + + + + + + + + + This object advertises dmabuf parameters feedback. This includes the + preferred devices and the supported formats/modifiers. + + The parameters are sent once when this object is created and whenever they + change. The done event is always sent once after all parameters have been + sent. When a single parameter changes, all parameters are re-sent by the + compositor. + + Compositors can re-send the parameters when the current client buffer + allocations are sub-optimal. Compositors should not re-send the + parameters if re-allocating the buffers would not result in a more optimal + configuration. In particular, compositors should avoid sending the exact + same parameters multiple times in a row. + + The tranche_target_device and tranche_formats events are grouped by + tranches of preference. For each tranche, a tranche_target_device, one + tranche_flags and one or more tranche_formats events are sent, followed + by a tranche_done event finishing the list. The tranches are sent in + descending order of preference. All formats and modifiers in the same + tranche have the same preference. + + To send parameters, the compositor sends one main_device event, tranches + (each consisting of one tranche_target_device event, one tranche_flags + event, tranche_formats events and then a tranche_done event), then one + done event. + + + + + Using this request a client can tell the server that it is not going to + use the wp_linux_dmabuf_feedback object anymore. + + + + + + This event is sent after all parameters of a wp_linux_dmabuf_feedback + object have been sent. + + This allows changes to the wp_linux_dmabuf_feedback parameters to be + seen as atomic, even if they happen via multiple events. + + + + + + This event provides a file descriptor which can be memory-mapped to + access the format and modifier table. + + The table contains a tightly packed array of consecutive format + + modifier pairs. Each pair is 16 bytes wide. It contains a format as a + 32-bit unsigned integer, followed by 4 bytes of unused padding, and a + modifier as a 64-bit unsigned integer. The native endianness is used. + + The client must map the file descriptor in read-only private mode. + + Compositors are not allowed to mutate the table file contents once this + event has been sent. Instead, compositors must create a new, separate + table file and re-send feedback parameters. Compositors are allowed to + store duplicate format + modifier pairs in the table. + + + + + + + + This event advertises the main device that the server prefers to use + when direct scan-out to the target device isn't possible. The + advertised main device may be different for each + wp_linux_dmabuf_feedback object, and may change over time. + + There is exactly one main device. The compositor must send at least + one preference tranche with tranche_target_device equal to main_device. + + Clients need to create buffers that the main device can import and + read from, otherwise creating the dmabuf wl_buffer will fail (see the + wp_linux_buffer_params.create and create_immed requests for details). + The main device will also likely be kept active by the compositor, + so clients can use it instead of waking up another device for power + savings. + + In general the device is a DRM node. The DRM node type (primary vs. + render) is unspecified. Clients must not rely on the compositor sending + a particular node type. Clients cannot check two devices for equality + by comparing the dev_t value. + + If explicit modifiers are not supported and the client performs buffer + allocations on a different device than the main device, then the client + must force the buffer to have a linear layout. + + + + + + + This event splits tranche_target_device and tranche_formats events in + preference tranches. It is sent after a set of tranche_target_device + and tranche_formats events; it represents the end of a tranche. The + next tranche will have a lower preference. + + + + + + This event advertises the target device that the server prefers to use + for a buffer created given this tranche. The advertised target device + may be different for each preference tranche, and may change over time. + + There is exactly one target device per tranche. + + The target device may be a scan-out device, for example if the + compositor prefers to directly scan-out a buffer created given this + tranche. The target device may be a rendering device, for example if + the compositor prefers to texture from said buffer. + + The client can use this hint to allocate the buffer in a way that makes + it accessible from the target device, ideally directly. The buffer must + still be accessible from the main device, either through direct import + or through a potentially more expensive fallback path. If the buffer + can't be directly imported from the main device then clients must be + prepared for the compositor changing the tranche priority or making + wl_buffer creation fail (see the wp_linux_buffer_params.create and + create_immed requests for details). + + If the device is a DRM node, the DRM node type (primary vs. render) is + unspecified. Clients must not rely on the compositor sending a + particular node type. Clients cannot check two devices for equality by + comparing the dev_t value. + + This event is tied to a preference tranche, see the tranche_done event. + + + + + + + This event advertises the format + modifier combinations that the + compositor supports. + + It carries an array of indices, each referring to a format + modifier + pair in the last received format table (see the format_table event). + Each index is a 16-bit unsigned integer in native endianness. + + For legacy support, DRM_FORMAT_MOD_INVALID is an allowed modifier. + It indicates that the server can support the format with an implicit + modifier. When a buffer has DRM_FORMAT_MOD_INVALID as its modifier, it + is as if no explicit modifier is specified. The effective modifier + will be derived from the dmabuf. + + A compositor that sends valid modifiers and DRM_FORMAT_MOD_INVALID for + a given format supports both explicit modifiers and implicit modifiers. + + Compositors must not send duplicate format + modifier pairs within the + same tranche or across two different tranches with the same target + device and flags. + + This event is tied to a preference tranche, see the tranche_done event. + + For the definition of the format and modifier codes, see the + wp_linux_buffer_params.create request. + + + + + + + + + + + This event sets tranche-specific flags. + + The scanout flag is a hint that direct scan-out may be attempted by the + compositor on the target device if the client appropriately allocates a + buffer. How to allocate a buffer that can be scanned out on the target + device is implementation-defined. + + This event is tied to a preference tranche, see the tranche_done event. + + + + + + diff --git a/packages/registry/wayland-protocols/xml/xdg-decoration-unstable-v1.xml b/packages/registry/wayland-protocols/xml/xdg-decoration-unstable-v1.xml new file mode 100644 index 0000000000..66c581d4bd --- /dev/null +++ b/packages/registry/wayland-protocols/xml/xdg-decoration-unstable-v1.xml @@ -0,0 +1,123 @@ + + + + Copyright © 2018 Simon Ser + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + This interface allows a compositor to announce support for server-side + decorations. + + + + + This interface allows a compositor to announce support for server-side + decorations. + + A window decoration is a set of window controls as deemed appropriate by + the party managing them, such as user interface components used to move, + resize and change a window's state. + + + + + Destroy the decoration manager. This doesn't destroy objects created + with the manager. + + + + + + Create a new decoration object associated with the given toplevel. + + Creating an xdg_toplevel_decoration from an xdg_toplevel which has a + buffer attached or committed is a client error, and any attempts by a + client to attach or manipulate a buffer prior to the first + xdg_toplevel_decoration.configure event must also be treated as + errors. + + + + + + + + + The decoration object allows the compositor to toggle server-side window + decorations for a toplevel surface. The client can request to switch to + another mode. + + The xdg_toplevel_decoration object must be destroyed before its + xdg_toplevel. + + + + + + + + + + + Switch back to a mode without any server-side decorations at the next + commit. + + + + + + These values describe the window decoration modes. + + + + + + + + Set the toplevel surface decoration mode. This informs the compositor + that the client prefers the provided decoration mode. + + + + + + + Unset the toplevel surface decoration mode. This informs the compositor + that the client doesn't prefer a particular decoration mode. + + + + + + The configure event configures the effective decoration mode. The + configured state should not be applied immediately. Clients must send + an ack_configure in response to this event. + + + + + diff --git a/programs/wlclock.c b/programs/wlclock.c index 0d1796f602..ae4427cf3d 100644 --- a/programs/wlclock.c +++ b/programs/wlclock.c @@ -9,9 +9,13 @@ * pacing from a second concurrent client while other windows get input — * i.e. the compositor really multiplexes clients. * + * Under a tiling compositor the window is resized to fill its slot; the face + * geometry is derived from the live surface size, so the clock scales. + * * Markers on stdout for the smoke gates: - * WLCLOCK_READY — window mapped + first frame committed - * WLCLOCK_EXIT — clean shutdown (close box) + * WLCLOCK_READY — window mapped + first frame committed + * WLCLOCK_RESIZE w=.. h=.. — the compositor dictated a new size + * WLCLOCK_EXIT — clean shutdown (close box) */ #include #include @@ -22,10 +26,7 @@ #define WIN_W 340 #define WIN_H 360 - -#define CX (WIN_W / 2) -#define CY 158 -#define RADIUS 130 +#define TIME_TEXT_H 28 /* space reserved below the face for the HH:MM:SS line */ /* sin/cos avoided on purpose: a 60-entry integer table (unit = 1/1000) * keeps the binary free of libm and is exact at the 60 positions a clock @@ -46,24 +47,32 @@ static int qcos(int pos60) { return sin_q[(((pos60 + 15) % 60) + 60) % 60]; } * the hand's direction. The sin table stays integer; only the final * endpoint is computed in float (no libm — wpk_line_aa uses the native * wasm f32.sqrt). */ -static void draw_hand(struct wpk_surface *s, int pos60, int len, int thick, - wpk_color color) { +static void draw_hand(struct wpk_surface *s, int cx, int cy, int pos60, int len, + int thick, wpk_color color) { float dx = qsin(pos60) / 1000.0f, dy = -qcos(pos60) / 1000.0f; - wpk_line_aa(s, CX, CY, CX + dx * len, CY + dy * len, thick, color); + wpk_line_aa(s, cx, cy, cx + dx * len, cy + dy * len, thick, color); } static void draw_clock(struct wpk_surface *s, struct wpk_font *font) { wpk_clear(s, WPK_RGB(0x20, 0x24, 0x30)); - /* Face: ring + hour/minute ticks, one AA segment per ray. */ + /* Face geometry scales with the surface: centred above the time line, + * radius bounded by the shorter half-axis. Hand/tick offsets stay + * proportional to the radius so the look survives any tile size. */ + int cx = s->w / 2; + int face_h = s->h - TIME_TEXT_H; + int cy = face_h / 2; + int radius = (cx < cy ? cx : cy) - 16; + if (radius < 10) radius = 10; + for (int i = 0; i < 60; i++) { float dx = qsin(i) / 1000.0f, dy = -qcos(i) / 1000.0f; - int inner = i % 5 == 0 ? RADIUS - 14 : RADIUS - 6; + int inner = i % 5 == 0 ? radius - radius / 10 : radius - radius / 22; float width = i % 5 == 0 ? 3.0f : 2.0f; wpk_color c = i % 5 == 0 ? WPK_RGB(0xc8, 0xce, 0xdc) : WPK_RGB(0x5a, 0x62, 0x78); - wpk_line_aa(s, CX + dx * inner, CY + dy * inner, - CX + dx * RADIUS, CY + dy * RADIUS, width, c); + wpk_line_aa(s, cx + dx * inner, cy + dy * inner, + cx + dx * radius, cy + dy * radius, width, c); } time_t now = time(NULL); @@ -71,17 +80,17 @@ static void draw_clock(struct wpk_surface *s, struct wpk_font *font) { localtime_r(&now, &tm); int hour_pos = (tm.tm_hour % 12) * 5 + tm.tm_min / 12; - draw_hand(s, hour_pos, RADIUS - 62, 6, WPK_RGB(0xe4, 0xe8, 0xf2)); - draw_hand(s, tm.tm_min, RADIUS - 34, 4, WPK_RGB(0xc0, 0xc8, 0xda)); - draw_hand(s, tm.tm_sec, RADIUS - 22, 2, WPK_RGB(0xe0, 0x6a, 0x5a)); - wpk_disc_aa(s, CX, CY, 4.5f, WPK_RGB(0xe4, 0xe8, 0xf2)); + draw_hand(s, cx, cy, hour_pos, radius * 52 / 100, 6, WPK_RGB(0xe4, 0xe8, 0xf2)); + draw_hand(s, cx, cy, tm.tm_min, radius * 74 / 100, 4, WPK_RGB(0xc0, 0xc8, 0xda)); + draw_hand(s, cx, cy, tm.tm_sec, radius * 83 / 100, 2, WPK_RGB(0xe0, 0x6a, 0x5a)); + wpk_disc_aa(s, cx, cy, 4.5f, WPK_RGB(0xe4, 0xe8, 0xf2)); if (font) { char buf[32]; snprintf(buf, sizeof(buf), "%02d:%02d:%02d", tm.tm_hour, tm.tm_min, tm.tm_sec); int tw = wpk_text_width(font, buf); - wpk_text(s, font, (WIN_W - tw) / 2, WIN_H - 22, buf, + wpk_text(s, font, (s->w - tw) / 2, s->h - 22, buf, WPK_RGB(0x9a, 0xa4, 0xbc)); } } @@ -105,6 +114,12 @@ int main(void) { struct kwl_event ev; while (kwl_dispatch(win, &ev, 40)) { if (ev.type == KWL_CLOSE) { running = 0; break; } + if (ev.type == KWL_RESIZE) { + printf("WLCLOCK_RESIZE w=%d h=%d\n", ev.x, ev.y); + fflush(stdout); + draw_clock(kwl_window_surface(win), font); + kwl_window_commit(win); + } } struct timespec now; clock_gettime(CLOCK_MONOTONIC, &now); diff --git a/programs/wlcompositor/kwlctl.c b/programs/wlcompositor/kwlctl.c new file mode 100644 index 0000000000..bab37f51d5 --- /dev/null +++ b/programs/wlcompositor/kwlctl.c @@ -0,0 +1,73 @@ +/* + * kwlctl — the hyprctl analog: a tiny CLI + event tail over the compositor's + * /tmp/kwlctl-0 control socket (PR14c). It speaks the newline-delimited line + * protocol wlcompositor's kwlctl IPC serves: + * + * kwlctl clients | workspaces | activewindow -> print the JSON reply + * kwlctl dispatch -> workspace N, movetoworkspace + * N, close, exec + * kwlctl --listen -> stream `event>>data` lines + * until the compositor exits + * + * The whole conversation is one command line written to the socket followed by + * the server's reply; for --listen the server holds the connection open and + * pushes events. This is the control surface Omarchy's scripts and the Tier-1 + * bar (PR15) consume. No fork here, so it is not fork-instrumented. + */ +#include +#include +#include +#include +#include +#include + +#define KWLCTL_SOCKET_PATH "/tmp/kwlctl-0" + +static int connect_kwlctl(void) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { perror("socket"); return -1; } + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, KWLCTL_SOCKET_PATH, sizeof(addr.sun_path) - 1); + /* The compositor may still be coming up; retry briefly like a wl client. */ + for (int i = 0; i < 200; i++) { + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) return fd; + usleep(10000); + } + perror("connect"); + close(fd); + return -1; +} + +int main(int argc, char **argv) { + if (argc < 2) { + fprintf(stderr, "usage: kwlctl \n"); + return 2; + } + + int fd = connect_kwlctl(); + if (fd < 0) return 1; + + /* Re-join argv[1..] into one space-separated command line. */ + char cmd[1024]; + int n = 0; + for (int i = 1; i < argc && n < (int)sizeof(cmd) - 2; i++) + n += snprintf(cmd + n, sizeof(cmd) - n, "%s%s", i > 1 ? " " : "", + argv[i]); + cmd[n++] = '\n'; + if (write(fd, cmd, (size_t)n) != n) { perror("write"); close(fd); return 1; } + + /* Print the reply. A request/reply command's socket is closed by the + * server after the reply (read hits EOF); --listen streams until the + * compositor exits. Either way, drain to EOF. */ + char buf[4096]; + ssize_t r; + while ((r = read(fd, buf, sizeof(buf))) > 0) { + fwrite(buf, 1, (size_t)r, stdout); + fflush(stdout); + } + close(fd); + return 0; +} diff --git a/programs/wlcompositor/wlclient-test.c b/programs/wlcompositor/wlclient-test.c index 35987e9020..ed8e127ab2 100644 --- a/programs/wlcompositor/wlclient-test.c +++ b/programs/wlcompositor/wlclient-test.c @@ -33,6 +33,7 @@ #include #include #include "xdg-shell-client-protocol.h" +#include "xdg-decoration-v1-client-protocol.h" #include @@ -47,6 +48,7 @@ struct client { struct xdg_wm_base *wm_base; struct wl_seat *seat; struct wl_output *output; + struct zxdg_decoration_manager_v1 *decor_mgr; struct wl_surface *surface; struct xdg_surface *xdg_surface; @@ -59,6 +61,7 @@ struct client { uint32_t key_code, key_state; int got_button; /* wl_pointer.button arrived */ uint32_t btn_code, btn_state; + int closed; /* compositor sent xdg_toplevel.close (killactive) */ }; /* ---- registry ---------------------------------------------------------- */ @@ -77,7 +80,23 @@ static void registry_global(void *data, struct wl_registry *reg, uint32_t name, c->seat = wl_registry_bind(reg, name, &wl_seat_interface, 1); else if (strcmp(iface, "wl_output") == 0) c->output = wl_registry_bind(reg, name, &wl_output_interface, 2); + else if (strcmp(iface, "zxdg_decoration_manager_v1") == 0) + c->decor_mgr = wl_registry_bind( + reg, name, &zxdg_decoration_manager_v1_interface, 1); } + +/* ---- xdg-decoration ---------------------------------------------------- */ + +static void decor_configure(void *data, struct zxdg_toplevel_decoration_v1 *d, + uint32_t mode) { + printf("DECOR_MODE %s\n", + mode == ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE ? "server_side" + : "client_side"); + fflush(stdout); +} +static const struct zxdg_toplevel_decoration_v1_listener decor_listener = { + .configure = decor_configure, +}; static void registry_global_remove(void *data, struct wl_registry *r, uint32_t name) {} static const struct wl_registry_listener registry_listener = { @@ -106,7 +125,9 @@ static const struct xdg_surface_listener xdg_surface_listener = { static void toplevel_configure(void *data, struct xdg_toplevel *t, int32_t w, int32_t h, struct wl_array *states) {} -static void toplevel_close(void *data, struct xdg_toplevel *t) {} +static void toplevel_close(void *data, struct xdg_toplevel *t) { + ((struct client *)data)->closed = 1; +} static const struct xdg_toplevel_listener toplevel_listener = { .configure = toplevel_configure, .close = toplevel_close, @@ -294,6 +315,18 @@ int main(void) { c.toplevel = xdg_surface_get_toplevel(c.xdg_surface); xdg_toplevel_add_listener(c.toplevel, &toplevel_listener, &c); xdg_toplevel_set_title(c.toplevel, "wlclient-test"); + + /* Optional: request server-side decorations (PR14e). The compositor forces + * SERVER_SIDE for tiling, which the client honors by drawing no titlebar. */ + if (getenv("WLC_DECOR") && c.decor_mgr) { + struct zxdg_toplevel_decoration_v1 *deco = + zxdg_decoration_manager_v1_get_toplevel_decoration(c.decor_mgr, + c.toplevel); + zxdg_toplevel_decoration_v1_add_listener(deco, &decor_listener, &c); + zxdg_toplevel_decoration_v1_set_mode( + deco, ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE); + } + wl_surface_commit(c.surface); /* Wait for the initial configure before attaching a buffer. */ @@ -319,10 +352,18 @@ int main(void) { fflush(stdout); /* Receive one host-injected key and one pointer button, forwarded by - * the compositor from libinput. */ - while (!(c.got_key && c.got_button)) + * the compositor from libinput — or exit if the compositor closes us + * (SUPER+W killactive). */ + while (!(c.got_key && c.got_button) && !c.closed) if (wl_display_dispatch(display) < 0) { fprintf(stderr, "dispatch\n"); return 1; } + if (c.closed) { + printf("CLIENT_CLOSED\n"); + fflush(stdout); + wl_display_disconnect(display); + return 0; + } + if (!c.got_keymap) { fprintf(stderr, "never received a valid xkb keymap\n"); return 1; diff --git a/programs/wlcompositor/wlcompositor.c b/programs/wlcompositor/wlcompositor.c index 7af35b9abe..69350813b4 100644 --- a/programs/wlcompositor/wlcompositor.c +++ b/programs/wlcompositor/wlcompositor.c @@ -69,10 +69,14 @@ */ #include #include +#include +#include +#include #include #include #include #include +#include #include #include #include @@ -82,8 +86,12 @@ #include #include #include "xdg-shell-server-protocol.h" +#include "linux-dmabuf-v1-server-protocol.h" +#include "xdg-decoration-v1-server-protocol.h" #include +#include +#include #include #include @@ -110,11 +118,42 @@ extern void wpkEglCloseBoHandle(EGLDisplay dpy, unsigned bo_handle); * root-owned 0755 scratch mount, so under this kernel the only dir writable * by any uid is /tmp (mode 1777) — it plays the XDG_RUNTIME_DIR role here. */ #define WL_SOCKET_PATH "/tmp/wayland-0" +/* The hyprctl analog: a control + event socket alongside the wayland one. + * kwlctl (programs/wlcompositor/kwlctl.c) and the Tier-1 bar speak to it. */ +#define KWLCTL_SOCKET_PATH "/tmp/kwlctl-0" +#define MAX_KWLCTL_CONNS 16 #define WL_KEYMAP_PATH "/tmp/wlcompositor-keymap.xkb" #define MAX_INPUT_RES 16 /* keyboard/pointer resources we track */ #define MAX_FRAME_CB 32 /* pending frame callbacks per surface */ #define MAX_SURFACES 16 /* mapped toplevels in the z-order list */ #define FOCUS_COLOR 0xff4f8fdfu /* accent ring, GPU and CPU paths */ +#define N_WORKSPACES 9 /* SUPER+1..9, Hyprland's 1-based workspaces */ + +/* The config path, hyprland.conf-shaped subset. Absent = generic defaults + * (install_default_binds); WLC_CONFIG overrides for tests. */ +#define WLC_CONFIG_PATH "/etc/kandelo/wlcompositor.conf" +#define MAX_BINDS 64 + +/* Modifier bits used by the keybind engine (mapped from xkb mod state). */ +#define MOD_SUPER 1 +#define MOD_SHIFT 2 +#define MOD_CTRL 4 /* the browser reserves SUPER (Cmd/Win), so CTRL is the + usable modifier for the in-browser demo */ + +enum bind_action { + ACT_EXEC, ACT_WORKSPACE, ACT_MOVE_TO_WS, ACT_KILL, + ACT_CYCLE_NEXT, ACT_CYCLE_PREV, +}; + +/* One `bind = MODS, KEY, DISPATCHER, ARGS` rule. sym is the BASE-level keysym + * (shift-independent) so `SUPER SHIFT, 1` matches the same key as `SUPER, 1`. */ +struct keybind { + uint32_t mods; /* MOD_* bitmask; matched exactly */ + xkb_keysym_t sym; + int action; + int arg; /* workspace number for workspace/movetoworkspace */ + char param[64]; /* command line for exec */ +}; /* ---- surface state ----------------------------------------------------- */ @@ -132,6 +171,7 @@ struct surface { char app_id[32]; int32_t x, y; /* top-left on the output */ int32_t w, h; /* committed buffer dims */ + int workspace; /* 1..N_WORKSPACES; 0 until first map */ int mapped; /* has a committed buffer been shown */ int placed; /* position assigned at first map */ struct wl_resource *frame_cbs[MAX_FRAME_CB]; @@ -170,6 +210,8 @@ struct shm_buffer { /* ---- compositor singleton ---------------------------------------------- */ +struct kwlctl_conn; /* one control-socket connection (defined with the IPC) */ + struct compositor { struct wl_display *display; struct wl_event_loop *loop; @@ -214,6 +256,21 @@ struct compositor { struct surface *grab; double grab_dx, grab_dy; + /* Layout policy (enum layout_mode); FLOATING unless WLC_LAYOUT overrides. */ + int layout; + + /* The visible workspace (1..N_WORKSPACES). Surfaces on other workspaces + * stay mapped but are excluded from compositing, input, and tiling. */ + int active_ws; + + /* kwlctl control clients that issued --listen; they receive the + * `event>>data` stream (Hyprland socket2 format). NULL = free slot. */ + struct kwlctl_conn *listeners[MAX_KWLCTL_CONNS]; + + /* Config-driven keybinds (install_default_binds or WLC_CONFIG_PATH). */ + struct keybind binds[MAX_BINDS]; + int n_binds; + /* Bound seat resources (across all clients; routed per-client). */ struct wl_resource *keyboards[MAX_INPUT_RES]; struct wl_resource *pointers[MAX_INPUT_RES]; @@ -261,6 +318,23 @@ static void slot_remove(struct wl_resource **slots, struct wl_resource *r) { static void schedule_repaint(void); static void kbd_set_focus(struct surface *s); static void ptr_refresh_focus(void); +static void kwlctl_emit(const char *fmt, ...); +static void kwlctl_exec(char *args); + +/* A surface participates in compositing, input, and tiling only when it is + * mapped AND on the active workspace. */ +static int surface_visible(const struct surface *s) { + return s->mapped && s->workspace == g.active_ws; +} + +/* Topmost mapped surface on workspace `ws` (its remembered focus, since + * focusing raises), or NULL. */ +static struct surface *topmost_on_ws(int ws) { + for (int i = g.n_surfaces - 1; i >= 0; i--) + if (g.zorder[i]->mapped && g.zorder[i]->workspace == ws) + return g.zorder[i]; + return NULL; +} /* ---- z-order helpers ---------------------------------------------------- */ @@ -286,7 +360,7 @@ static void zorder_raise(struct surface *s) { static struct surface *surface_at(double x, double y) { for (int i = g.n_surfaces - 1; i >= 0; i--) { struct surface *s = g.zorder[i]; - if (!s->mapped) continue; + if (!surface_visible(s)) continue; if (x >= s->x && x < s->x + s->w && y >= s->y && y < s->y + s->h) return s; } @@ -339,6 +413,98 @@ static void place_surface(struct surface *s) { s->placed = 1; } +/* ---- tiling layout ------------------------------------------------------ */ + +/* FLOATING (default, zero-initialised) keeps the app_id placement rules that + * /?demo=wayland depends on. DWINDLE dictates geometry to clients: the desktop + * becomes the tiling mode of the same compositor. Selected by WLC_LAYOUT. */ +enum layout_mode { LAYOUT_FLOATING = 0, LAYOUT_DWINDLE }; + +struct geom { int x, y, w, h; }; + +/* Gaps in output pixels. OUTER insets the whole tiling area from the screen + * edge; INNER separates adjacent windows. Hardcoded for v1 — PR17 makes them + * theme-driven. */ +#define TILE_GAP_OUTER 12 +#define TILE_GAP_INNER 8 + +/* Pure dwindle tiler: at each step split the remaining region along its LONGER + * side (Hyprland's default). Pure — reads only its arguments — so the smoke + * gate predicts the exact partition and checks it against the emitted geometry. */ +static void compute_tiling(struct geom area, int n, struct geom *out) { + if (n <= 0) return; + area.x += TILE_GAP_OUTER; + area.y += TILE_GAP_OUTER; + area.w -= 2 * TILE_GAP_OUTER; + area.h -= 2 * TILE_GAP_OUTER; + if (area.w < 1) area.w = 1; + if (area.h < 1) area.h = 1; + + struct geom region = area; + for (int i = 0; i < n; i++) { + if (i == n - 1) { out[i] = region; break; } + struct geom near = region, rest = region; + if (region.w >= region.h) { /* wider than tall: split L|R */ + int half = (region.w - TILE_GAP_INNER) / 2; + if (half < 1) half = 1; + near.w = half; + rest.x = region.x + half + TILE_GAP_INNER; + rest.w = region.w - half - TILE_GAP_INNER; + } else { /* taller than wide: split T/B */ + int half = (region.h - TILE_GAP_INNER) / 2; + if (half < 1) half = 1; + near.h = half; + rest.y = region.y + half + TILE_GAP_INNER; + rest.h = region.h - half - TILE_GAP_INNER; + } + out[i] = near; + region = rest; + } +} + +/* Recompute geometry for every mapped toplevel (in map order = z-order) and + * push it to each client through the xdg configure path. A no-op in FLOATING + * mode, so the app_id placement path is untouched. Emits one TILE marker per + * window for the smoke gate to verify the partition. */ +static void retile(void) { + if (g.layout == LAYOUT_FLOATING) return; + + struct surface *tiled[MAX_SURFACES]; + int n = 0; + for (int i = 0; i < g.n_surfaces; i++) + if (surface_visible(g.zorder[i])) tiled[n++] = g.zorder[i]; + if (n == 0) return; + + struct geom geoms[MAX_SURFACES]; + struct geom area = { 0, 0, (int)g.width, (int)g.height }; + compute_tiling(area, n, geoms); + + for (int i = 0; i < n; i++) { + struct surface *s = tiled[i]; + s->x = geoms[i].x; + s->y = geoms[i].y; + s->w = geoms[i].w; + s->h = geoms[i].h; + s->placed = 1; + /* The states array carries only ACTIVATED for now; TILED_* awaits an + * xdg-shell v2 bump. */ + if (s->xdg_toplevel && s->xdg_surface) { + struct wl_array states; + wl_array_init(&states); + uint32_t *st = wl_array_add(&states, sizeof(uint32_t)); + if (st) *st = XDG_TOPLEVEL_STATE_ACTIVATED; + xdg_toplevel_send_configure(s->xdg_toplevel, s->w, s->h, &states); + wl_array_release(&states); + xdg_surface_send_configure(s->xdg_surface, + wl_display_next_serial(g.display)); + } + printf("TILE n=%d i=%d x=%d y=%d w=%d h=%d\n", + n, i, s->x, s->y, s->w, s->h); + } + fflush(stdout); + schedule_repaint(); +} + /* ====================================================================== */ /* wl_surface */ /* ====================================================================== */ @@ -392,12 +558,16 @@ static void surface_commit(struct wl_client *c, struct wl_resource *r) { if (!s->mapped) { s->mapped = 1; - if (!s->placed) place_surface(s); + if (!s->workspace) s->workspace = g.active_ws; /* opens on the visible ws */ + /* Tiling dictates geometry for every window in retile(); only the + * floating desktop places individually by app_id. */ + if (g.layout == LAYOUT_FLOATING && !s->placed) place_surface(s); zorder_raise(s); /* A newly mapped window takes keyboard focus (and pointer focus if * the cursor happens to be over it). */ kbd_set_focus(s); ptr_refresh_focus(); + retile(); /* no-op when floating */ } schedule_repaint(); } @@ -434,9 +604,8 @@ static void surface_resource_destroy(struct wl_resource *r) { zorder_remove(s); if (g.kbd_focus == s) { g.kbd_focus = NULL; - /* Hand focus to the new top window, if any. */ - if (g.n_surfaces) - kbd_set_focus(g.zorder[g.n_surfaces - 1]); + /* Hand focus to the new top window on the visible workspace, if any. */ + kbd_set_focus(topmost_on_ws(g.active_ws)); } if (g.ptr_focus == s) g.ptr_focus = NULL; if (g.grab == s) g.grab = NULL; @@ -444,6 +613,8 @@ static void surface_resource_destroy(struct wl_resource *r) { * callbacks themselves are owned by the client and freed with it. */ s->n_frame_cbs = 0; free(s); + /* A closed window frees its slice back to the remaining tiles. */ + retile(); /* no-op when floating */ schedule_repaint(); } @@ -590,6 +761,254 @@ static void shm_bind(struct wl_client *client, void *data, uint32_t version, wl_shm_send_format(r, WL_SHM_FORMAT_ARGB8888); } +/* ====================================================================== */ +/* zwp_linux_dmabuf_v1 (PR11) */ +/* ====================================================================== */ + +/* A client that renders with GL hands us its frame as a dmabuf (a prime-fd + * on a renderD128 bo) instead of a wl_shm pool. Sampling is identical to + * the shm path — both wrap a prime-fd + dims — so a dmabuf wl_buffer reuses + * struct shm_buffer, backed by a single-plane pool over the dmabuf fd. For + * a GPU-tier bo the downstream BIND_FOREIGN_TEXTURE is zero-copy (PR10). + * + * We advertise version 3 (format + modifier events), LINEAR only — the one + * layout the GPU tier and our gbm_bo_import path handle. Feedback (v4+) is + * intentionally not offered. */ + +struct dmabuf_params { + int fd; /* plane-0 fd, dup'd from the client; -1 until add */ + int32_t offset, stride; + int has_plane; /* add() recorded plane 0 */ + int used; /* create/create_immed consumes the params once */ +}; + +/* Turn finished params into a wl_buffer-backing shm_buffer, transferring + * ownership of the plane fd to a fresh single-ref pool. On success *err=0; + * on a params error returns NULL with *err set to the code to report; on OOM + * returns NULL with *err=0 after posting no_memory. */ +static struct shm_buffer *dmabuf_make_buffer(struct wl_client *c, + struct dmabuf_params *p, + int32_t width, int32_t height, + uint32_t format, uint32_t *err) { + *err = 0; + if (!p->has_plane) { + *err = ZWP_LINUX_BUFFER_PARAMS_V1_ERROR_INCOMPLETE; + return NULL; + } + if (width <= 0 || height <= 0) { + *err = ZWP_LINUX_BUFFER_PARAMS_V1_ERROR_INVALID_DIMENSIONS; + return NULL; + } + if (format != DRM_FORMAT_XRGB8888 && format != DRM_FORMAT_ARGB8888) { + *err = ZWP_LINUX_BUFFER_PARAMS_V1_ERROR_INVALID_FORMAT; + return NULL; + } + struct shm_pool *pool = calloc(1, sizeof(*pool)); + struct shm_buffer *b = calloc(1, sizeof(*b)); + if (!pool || !b) { + free(pool); + free(b); + wl_client_post_no_memory(c); + return NULL; + } + pool->fd = p->fd; + pool->size = p->stride * height; + pool->refcount = 1; + b->pool = pool; + b->offset = p->offset; + b->width = width; + b->height = height; + b->stride = p->stride; + b->format = format; + p->fd = -1; /* the pool owns the fd now */ + return b; +} + +/* Free a built-but-unpublished buffer (wl_resource_create failed after the + * fd was already transferred into the pool). */ +static void dmabuf_discard_buffer(struct shm_buffer *b) { + if (--b->pool->refcount == 0) shm_pool_free(b->pool); + free(b); +} + +static void dmabuf_params_add(struct wl_client *c, struct wl_resource *r, + int32_t fd, uint32_t plane_idx, uint32_t offset, + uint32_t stride, uint32_t modifier_hi, + uint32_t modifier_lo) { + struct dmabuf_params *p = wl_resource_get_user_data(r); + if (p->used) { + wl_resource_post_error(r, ZWP_LINUX_BUFFER_PARAMS_V1_ERROR_ALREADY_USED, + "params already used"); + close(fd); + return; + } + if (plane_idx != 0) { + wl_resource_post_error(r, ZWP_LINUX_BUFFER_PARAMS_V1_ERROR_PLANE_IDX, + "only plane 0 is supported"); + close(fd); + return; + } + if (p->has_plane) { + wl_resource_post_error(r, ZWP_LINUX_BUFFER_PARAMS_V1_ERROR_PLANE_SET, + "plane 0 already set"); + close(fd); + return; + } + /* LINEAR only: the GPU tier keeps a LINEAR-equivalent layout and the CPU + * fallback maps the fd as linear bytes. */ + if ((((uint64_t)modifier_hi << 32) | modifier_lo) != DRM_FORMAT_MOD_LINEAR) { + wl_resource_post_error(r, ZWP_LINUX_BUFFER_PARAMS_V1_ERROR_INVALID_FORMAT, + "only DRM_FORMAT_MOD_LINEAR is supported"); + close(fd); + return; + } + p->fd = fd; + p->offset = (int32_t)offset; + p->stride = (int32_t)stride; + p->has_plane = 1; +} + +static void dmabuf_params_create(struct wl_client *c, struct wl_resource *r, + int32_t width, int32_t height, uint32_t format, + uint32_t flags) { + /* flags (y_invert/interlaced/bottom_first) don't apply: our producers + * render top-left-origin into a progressive bo. */ + struct dmabuf_params *p = wl_resource_get_user_data(r); + if (p->used) { + wl_resource_post_error(r, ZWP_LINUX_BUFFER_PARAMS_V1_ERROR_ALREADY_USED, + "params already used"); + return; + } + p->used = 1; + uint32_t err = 0; + struct shm_buffer *b = dmabuf_make_buffer(c, p, width, height, format, &err); + if (!b) { + if (err) zwp_linux_buffer_params_v1_send_failed(r); + return; + } + struct wl_resource *br = wl_resource_create(c, &wl_buffer_interface, 1, 0); + if (!br) { + dmabuf_discard_buffer(b); + wl_client_post_no_memory(c); + return; + } + wl_resource_set_implementation(br, &buffer_impl, b, buffer_resource_destroy); + zwp_linux_buffer_params_v1_send_created(r, br); +} + +static void dmabuf_params_create_immed(struct wl_client *c, + struct wl_resource *r, uint32_t buffer_id, + int32_t width, int32_t height, + uint32_t format, uint32_t flags) { + struct dmabuf_params *p = wl_resource_get_user_data(r); + if (p->used) { + wl_resource_post_error(r, ZWP_LINUX_BUFFER_PARAMS_V1_ERROR_ALREADY_USED, + "params already used"); + return; + } + p->used = 1; + uint32_t err = 0; + struct shm_buffer *b = dmabuf_make_buffer(c, p, width, height, format, &err); + if (!b) { + /* create_immed reports failure as a fatal protocol error (it has no + * 'failed' event — the client committed to the new_id). */ + if (err) wl_resource_post_error(r, err, "invalid dmabuf params"); + return; + } + struct wl_resource *br = + wl_resource_create(c, &wl_buffer_interface, 1, buffer_id); + if (!br) { + dmabuf_discard_buffer(b); + wl_client_post_no_memory(c); + return; + } + wl_resource_set_implementation(br, &buffer_impl, b, buffer_resource_destroy); +} + +static void dmabuf_params_destroy_req(struct wl_client *c, + struct wl_resource *r) { + wl_resource_destroy(r); +} +static const struct zwp_linux_buffer_params_v1_interface dmabuf_params_impl = { + .destroy = dmabuf_params_destroy_req, + .add = dmabuf_params_add, + .create = dmabuf_params_create, + .create_immed = dmabuf_params_create_immed, +}; +static void dmabuf_params_resource_destroy(struct wl_resource *r) { + struct dmabuf_params *p = wl_resource_get_user_data(r); + if (!p) return; + if (p->fd >= 0) close(p->fd); /* a plane added but never consumed */ + free(p); +} + +static void dmabuf_create_params(struct wl_client *c, struct wl_resource *r, + uint32_t params_id) { + struct dmabuf_params *p = calloc(1, sizeof(*p)); + if (!p) { wl_client_post_no_memory(c); return; } + p->fd = -1; + struct wl_resource *pr = wl_resource_create( + c, &zwp_linux_buffer_params_v1_interface, wl_resource_get_version(r), + params_id); + if (!pr) { free(p); wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(pr, &dmabuf_params_impl, p, + dmabuf_params_resource_destroy); +} +static void dmabuf_destroy_req(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} + +/* Feedback (v4+) is not advertised, so a conforming client never reaches + * these. Hand back an inert resource rather than leaving a NULL dispatch + * slot a malformed client could crash the compositor through. */ +static void dmabuf_feedback_destroy_req(struct wl_client *c, + struct wl_resource *r) { + wl_resource_destroy(r); +} +static const struct zwp_linux_dmabuf_feedback_v1_interface dmabuf_feedback_impl = { + .destroy = dmabuf_feedback_destroy_req, +}; +static void dmabuf_get_feedback(struct wl_client *c, struct wl_resource *r, + uint32_t id) { + struct wl_resource *fb = wl_resource_create( + c, &zwp_linux_dmabuf_feedback_v1_interface, wl_resource_get_version(r), + id); + if (!fb) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(fb, &dmabuf_feedback_impl, NULL, NULL); +} +static void dmabuf_get_default_feedback(struct wl_client *c, + struct wl_resource *r, uint32_t id) { + dmabuf_get_feedback(c, r, id); +} +static void dmabuf_get_surface_feedback(struct wl_client *c, + struct wl_resource *r, uint32_t id, + struct wl_resource *surface) { + dmabuf_get_feedback(c, r, id); +} +static const struct zwp_linux_dmabuf_v1_interface dmabuf_impl = { + .destroy = dmabuf_destroy_req, + .create_params = dmabuf_create_params, + .get_default_feedback = dmabuf_get_default_feedback, + .get_surface_feedback = dmabuf_get_surface_feedback, +}; +static void dmabuf_bind(struct wl_client *c, void *data, uint32_t version, + uint32_t id) { + struct wl_resource *r = + wl_resource_create(c, &zwp_linux_dmabuf_v1_interface, version, id); + if (!r) { wl_client_post_no_memory(c); return; } + wl_resource_set_implementation(r, &dmabuf_impl, NULL, NULL); + /* Advertise the formats the GPU tier + gbm import path handle, LINEAR + * only. The modifier event exists since interface version 3. */ + static const uint32_t fmts[] = { DRM_FORMAT_XRGB8888, DRM_FORMAT_ARGB8888 }; + for (unsigned i = 0; i < sizeof(fmts) / sizeof(fmts[0]); i++) { + zwp_linux_dmabuf_v1_send_format(r, fmts[i]); + if (version >= ZWP_LINUX_DMABUF_V1_MODIFIER_SINCE_VERSION) + zwp_linux_dmabuf_v1_send_modifier( + r, fmts[i], (uint32_t)(DRM_FORMAT_MOD_LINEAR >> 32), + (uint32_t)(DRM_FORMAT_MOD_LINEAR & 0xffffffffu)); + } +} + /* ====================================================================== */ /* wl_compositor */ /* ====================================================================== */ @@ -807,6 +1226,61 @@ static void wm_base_bind(struct wl_client *client, void *data, uint32_t version, wl_resource_set_implementation(r, &wm_base_impl, NULL, NULL); } +/* ====================================================================== */ +/* zxdg_decoration_manager_v1 — force server-side decorations (PR14e) */ +/* ====================================================================== */ + +/* Negotiate the decoration mode by layout: a tiled window has no titlebar, so + * DWINDLE forces SERVER_SIDE (the compositor draws the border/focus ring and + * the client drops its CSD); FLOATING grants CLIENT_SIDE so a draggable + * titlebar stays. The client's preferred mode is acknowledged but ignored. */ +static void decoration_send_mode(struct wl_resource *r) { + uint32_t mode = g.layout == LAYOUT_DWINDLE + ? ZXDG_TOPLEVEL_DECORATION_V1_MODE_SERVER_SIDE + : ZXDG_TOPLEVEL_DECORATION_V1_MODE_CLIENT_SIDE; + zxdg_toplevel_decoration_v1_send_configure(r, mode); +} +static void decoration_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static void decoration_set_mode(struct wl_client *c, struct wl_resource *r, + uint32_t mode) { + decoration_send_mode(r); +} +static void decoration_unset_mode(struct wl_client *c, struct wl_resource *r) { + decoration_send_mode(r); +} +static const struct zxdg_toplevel_decoration_v1_interface decoration_impl = { + .destroy = decoration_destroy, + .set_mode = decoration_set_mode, + .unset_mode = decoration_unset_mode, +}; + +static void decoration_mgr_destroy(struct wl_client *c, struct wl_resource *r) { + wl_resource_destroy(r); +} +static void decoration_mgr_get_toplevel_decoration( + struct wl_client *client, struct wl_resource *resource, uint32_t id, + struct wl_resource *toplevel) { + struct wl_resource *d = wl_resource_create( + client, &zxdg_toplevel_decoration_v1_interface, + wl_resource_get_version(resource), id); + if (!d) { wl_client_post_no_memory(client); return; } + wl_resource_set_implementation(d, &decoration_impl, NULL, NULL); + decoration_send_mode(d); /* initial configure */ +} +static const struct zxdg_decoration_manager_v1_interface decoration_mgr_impl = { + .destroy = decoration_mgr_destroy, + .get_toplevel_decoration = decoration_mgr_get_toplevel_decoration, +}; +static void decoration_mgr_bind(struct wl_client *client, void *data, + uint32_t version, uint32_t id) { + struct wl_resource *r = wl_resource_create( + client, &zxdg_decoration_manager_v1_interface, version, id); + if (!r) { wl_client_post_no_memory(client); return; } + wl_resource_set_implementation(r, &decoration_mgr_impl, NULL, NULL); +} + /* ====================================================================== */ /* wl_seat / wl_keyboard / wl_pointer */ /* ====================================================================== */ @@ -909,6 +1383,14 @@ static void kbd_set_focus(struct surface *s) { } wl_array_release(&keys); schedule_repaint(); /* focus border moved */ + kwlctl_emit("activewindow>>%s", s->app_id); + /* Observable focus marker: keyboard focus only moves to a window once its + * first commit maps it (surface_commit), so this is the authoritative + * "the window is now closeable by killactive" signal — distinct from a + * client's own READY print, which fires when it *queues* its first commit, + * before the compositor has processed the map and moved focus here. */ + printf("KBD_FOCUS app_id=%s\n", s->app_id); + fflush(stdout); } /* Pointer focus follows the surface under the cursor. */ @@ -937,6 +1419,39 @@ static void ptr_refresh_focus(void) { ptr_set_focus(surface_at(g.cursor_x, g.cursor_y)); } +/* ---- workspaces --------------------------------------------------------- */ + +/* Show workspace `ws`: its surfaces become visible + tiled, the rest hide. + * Focus restores to that workspace's topmost window (empty → no focus). */ +static void switch_workspace(int ws) { + if (ws < 1 || ws > N_WORKSPACES || ws == g.active_ws) return; + g.active_ws = ws; + kbd_set_focus(topmost_on_ws(ws)); + ptr_refresh_focus(); + retile(); + schedule_repaint(); + printf("WORKSPACE active=%d\n", ws); + fflush(stdout); + kwlctl_emit("workspace>>%d", ws); +} + +/* Send the focused window to workspace `ws`; it vanishes from the current + * view, which re-tiles around its absence, and focus falls to the next + * window here. */ +static void move_focus_to_workspace(int ws) { + if (ws < 1 || ws > N_WORKSPACES || !g.kbd_focus) return; + struct surface *s = g.kbd_focus; + if (s->workspace == ws) return; + s->workspace = ws; + g.kbd_focus = NULL; + kbd_set_focus(topmost_on_ws(g.active_ws)); + ptr_refresh_focus(); + retile(); + schedule_repaint(); + printf("MOVE_TO_WS \"%s\" ws=%d\n", s->app_id, ws); + fflush(stdout); +} + static void seat_get_pointer(struct wl_client *client, struct wl_resource *resource, uint32_t id) { struct wl_resource *p = wl_resource_create( @@ -1313,7 +1828,7 @@ static int repaint_gl(void) { struct surface *top = NULL; for (int i = 0; i < g.n_surfaces; i++) { struct surface *s = g.zorder[i]; - if (!s->mapped || !s->buffer) continue; + if (!surface_visible(s) || !s->buffer) continue; struct shm_buffer *b = wl_resource_get_user_data(s->buffer); if (!b) continue; texs[i] = shm_buffer_gl_texture(b); @@ -1325,7 +1840,7 @@ static int repaint_gl(void) { glc_draw_tex(glc.wallpaper_tex, 0, 0, (int32_t)g.width, (int32_t)g.height); for (int i = 0; i < g.n_surfaces; i++) { struct surface *s = g.zorder[i]; - if (!s->mapped || !s->buffer || !texs[i]) continue; + if (!surface_visible(s) || !s->buffer || !texs[i]) continue; struct shm_buffer *b = wl_resource_get_user_data(s->buffer); if (!b) continue; if (g.kbd_focus == s) /* 2px accent ring behind the window */ @@ -1412,7 +1927,7 @@ static void repaint(void) { struct surface *top = NULL; for (int i = 0; i < g.n_surfaces; i++) { struct surface *s = g.zorder[i]; - if (!s->mapped) continue; + if (!surface_visible(s)) continue; if (g.kbd_focus == s) draw_focus_border(s, dst, stride_px); blit_surface(s, dst, stride_px); top = s; @@ -1506,6 +2021,185 @@ static int card_readable(int fd, uint32_t mask, void *data) { /* Input: libinput → wl_keyboard / wl_pointer */ /* ====================================================================== */ +/* ---- keybind engine (config-driven) ------------------------------------ */ + +/* The base-level (shift-independent) keysym for an evdev keycode, so a bind + * written as `1` matches whether or not Shift is held. */ +static xkb_keysym_t base_keysym(uint32_t key) { + struct xkb_keymap *km = xkb_state_get_keymap(g.xkb_state); + xkb_layout_index_t layout = xkb_state_key_get_layout(g.xkb_state, key + 8); + const xkb_keysym_t *syms; + int n = xkb_keymap_key_get_syms_by_level(km, key + 8, layout, 0, &syms); + return n > 0 ? syms[0] : XKB_KEY_NoSymbol; +} + +/* The MOD_* bits currently active (only the mods our keymap defines). */ +static uint32_t active_mod_mask(void) { + uint32_t m = 0; + if (xkb_state_mod_name_is_active(g.xkb_state, XKB_MOD_NAME_LOGO, + XKB_STATE_MODS_EFFECTIVE) > 0) m |= MOD_SUPER; + if (xkb_state_mod_name_is_active(g.xkb_state, XKB_MOD_NAME_SHIFT, + XKB_STATE_MODS_EFFECTIVE) > 0) m |= MOD_SHIFT; + if (xkb_state_mod_name_is_active(g.xkb_state, XKB_MOD_NAME_CTRL, + XKB_STATE_MODS_EFFECTIVE) > 0) m |= MOD_CTRL; + return m; +} + +/* Move keyboard focus to the next/prev visible window in z-order WITHOUT + * reordering (so a tiled layout keeps its geometry as focus cycles). */ +static void focus_cycle(int dir) { + struct surface *vis[MAX_SURFACES]; + int n = 0, cur = -1; + for (int i = 0; i < g.n_surfaces; i++) + if (surface_visible(g.zorder[i])) { + if (g.zorder[i] == g.kbd_focus) cur = n; + vis[n++] = g.zorder[i]; + } + if (n == 0) return; + int next = cur < 0 ? 0 : (cur + dir + n) % n; + kbd_set_focus(vis[next]); + ptr_refresh_focus(); +} + +static void run_dispatch(const struct keybind *b) { + switch (b->action) { + case ACT_EXEC: { + char tmp[64]; + snprintf(tmp, sizeof(tmp), "%s", b->param); /* kwlctl_exec strtoks */ + kwlctl_exec(tmp); + break; + } + case ACT_WORKSPACE: switch_workspace(b->arg); break; + case ACT_MOVE_TO_WS: move_focus_to_workspace(b->arg); break; + case ACT_KILL: + if (g.kbd_focus && g.kbd_focus->xdg_toplevel) + xdg_toplevel_send_close(g.kbd_focus->xdg_toplevel); + break; + case ACT_CYCLE_NEXT: focus_cycle(+1); break; + case ACT_CYCLE_PREV: focus_cycle(-1); break; + } +} + +/* Config-driven keybind interception. Returns 1 when the pressed combo matches + * a bind and must NOT reach the focused client; the release of a matched combo + * is swallowed too. xkb_state already reflects this key. */ +static int try_keybind(uint32_t key, uint32_t state) { + uint32_t mods = active_mod_mask(); + if (!mods) return 0; /* fast path: unmodified keys go to the client */ + xkb_keysym_t sym = base_keysym(key); + for (int i = 0; i < g.n_binds; i++) { + if (g.binds[i].mods != mods || g.binds[i].sym != sym) continue; + if (state == WL_KEYBOARD_KEY_STATE_PRESSED) run_dispatch(&g.binds[i]); + return 1; + } + return 0; +} + +/* ---- config parsing ----------------------------------------------------- */ + +static void add_bind(uint32_t mods, xkb_keysym_t sym, int action, int arg, + const char *param) { + if (g.n_binds >= MAX_BINDS) return; + struct keybind *b = &g.binds[g.n_binds++]; + b->mods = mods; + b->sym = sym; + b->action = action; + b->arg = arg; + snprintf(b->param, sizeof(b->param), "%s", param ? param : ""); +} + +/* Generic defaults when no config file is present (NOT demo-specific): the + * standard SUPER-based tiling bindings. */ +static void install_default_binds(void) { + add_bind(MOD_SUPER, XKB_KEY_Return, ACT_EXEC, 0, "wlterm"); + add_bind(MOD_SUPER, XKB_KEY_w, ACT_KILL, 0, NULL); + add_bind(MOD_SUPER, XKB_KEY_j, ACT_CYCLE_NEXT, 0, NULL); + add_bind(MOD_SUPER, XKB_KEY_k, ACT_CYCLE_PREV, 0, NULL); + for (int i = 1; i <= N_WORKSPACES; i++) { + add_bind(MOD_SUPER, XKB_KEY_0 + i, ACT_WORKSPACE, i, NULL); + add_bind(MOD_SUPER | MOD_SHIFT, XKB_KEY_0 + i, ACT_MOVE_TO_WS, i, NULL); + } +} + +/* Trim leading/trailing ASCII whitespace in place, returning the start. */ +static char *trim(char *s) { + while (*s == ' ' || *s == '\t') s++; + char *end = s + strlen(s); + while (end > s && (end[-1] == ' ' || end[-1] == '\t' || end[-1] == '\r' || + end[-1] == '\n')) + *--end = '\0'; + return s; +} + +/* Parse a MODS token ("SUPER SHIFT" or "SUPER+SHIFT") into a MOD_* mask. + * Returns -1 on an unknown modifier name. */ +static int parse_mods(char *s, uint32_t *out) { + uint32_t m = 0; + for (char *tok = strtok(s, " +"); tok; tok = strtok(NULL, " +")) { + if (!strcasecmp(tok, "SUPER") || !strcasecmp(tok, "MOD4")) m |= MOD_SUPER; + else if (!strcasecmp(tok, "SHIFT")) m |= MOD_SHIFT; + else if (!strcasecmp(tok, "CTRL") || !strcasecmp(tok, "CONTROL")) + m |= MOD_CTRL; + else return -1; + } + *out = m; + return 0; +} + +/* Parse one `bind = MODS, KEY, DISPATCHER[, ARGS]` line into the table. */ +static void parse_bind_line(char *rhs) { + char *fields[4] = {0}; + int nf = 0; + for (char *tok = strtok(rhs, ","); tok && nf < 4; tok = strtok(NULL, ",")) + fields[nf++] = trim(tok); + if (nf < 3) return; + + uint32_t mods; + if (parse_mods(fields[0], &mods) < 0) return; + /* Match against the base-level keysym, which is lowercase for letters + * ("w", not "W"). xkb_keysym_from_name("W") resolves to the uppercase + * keysym, so fold to lower or a config `bind = CTRL, W` never fires. */ + xkb_keysym_t sym = xkb_keysym_to_lower( + xkb_keysym_from_name(fields[1], XKB_KEYSYM_CASE_INSENSITIVE)); + if (sym == XKB_KEY_NoSymbol) return; + + const char *disp = fields[2]; + const char *arg = nf > 3 ? fields[3] : ""; + if (!strcmp(disp, "exec")) add_bind(mods, sym, ACT_EXEC, 0, arg); + else if (!strcmp(disp, "workspace")) add_bind(mods, sym, ACT_WORKSPACE, atoi(arg), NULL); + else if (!strcmp(disp, "movetoworkspace")) add_bind(mods, sym, ACT_MOVE_TO_WS, atoi(arg), NULL); + else if (!strcmp(disp, "killactive")) add_bind(mods, sym, ACT_KILL, 0, NULL); + else if (!strcmp(disp, "cyclenext")) add_bind(mods, sym, ACT_CYCLE_NEXT, 0, NULL); + else if (!strcmp(disp, "cycleprev")) add_bind(mods, sym, ACT_CYCLE_PREV, 0, NULL); +} + +/* Load keybinds: parse WLC_CONFIG / WLC_CONFIG_PATH if present, else install + * generic defaults. */ +static void load_config(void) { + const char *env = getenv("WLC_CONFIG"); + const char *path = env ? env : WLC_CONFIG_PATH; + FILE *f = fopen(path, "r"); + const char *src; + if (!f) { + install_default_binds(); + src = "default"; + } else { + char line[256]; + while (fgets(line, sizeof(line), f)) { + char *s = trim(line); + if (*s == '\0' || *s == '#') continue; + if (strncmp(s, "bind", 4) == 0) { + char *eq = strchr(s, '='); + if (eq) parse_bind_line(trim(eq + 1)); + } + } + fclose(f); + src = path; + } + printf("BINDS_LOADED n=%d source=%s\n", g.n_binds, src); + fflush(stdout); +} + static void handle_keyboard(struct libinput_event_keyboard *k) { uint32_t key = libinput_event_keyboard_get_key(k); uint32_t state = libinput_event_keyboard_get_key_state(k) == @@ -1532,6 +2226,9 @@ static void handle_keyboard(struct libinput_event_keyboard *k) { g.sent_group = grp; } + /* Compositor keybinds intercept the key before the focused client. */ + if (try_keybind(key, state)) return; + if (!g.kbd_focus) return; for (int i = 0; i < MAX_INPUT_RES; i++) { if (!g.keyboards[i] || @@ -1754,6 +2451,7 @@ static int setup_keymap(void) { " = 52; = 53; = 54; = 55;\n" " = 56; = 57; = 58; = 59;\n" " = 60; = 61; = 62; = 65;\n" + " = 133;\n" /* evdev KEY_LEFTMETA (125) + 8: the SUPER key */ " };\n" " xkb_types \"kandelo\" {\n" " virtual_modifiers NumLock;\n" @@ -1778,6 +2476,9 @@ static int setup_keymap(void) { " interpret Control_L+AnyOfOrNone(all) {\n" " action = SetMods(modifiers=Control);\n" " };\n" + " interpret Super_L+AnyOfOrNone(all) {\n" + " action = SetMods(modifiers=Mod4);\n" + " };\n" " };\n" " xkb_symbols \"kandelo\" {\n" " key { [ Escape ] };\n" @@ -1788,6 +2489,7 @@ static int setup_keymap(void) { " key { [ Control_L ] };\n" " key { [ Shift_L ] };\n" " key { [ Shift_R ] };\n" + " key { [ Super_L ] };\n" " key { type=\"TWO_LEVEL\", [ 1, exclam ] };\n" " key { type=\"TWO_LEVEL\", [ 2, at ] };\n" " key { type=\"TWO_LEVEL\", [ 3, numbersign ] };\n" @@ -1837,6 +2539,7 @@ static int setup_keymap(void) { " key { type=\"TWO_LEVEL\", [ slash, question ] };\n" " modifier_map Shift { , };\n" " modifier_map Control { };\n" + " modifier_map Mod4 { };\n" " };\n" "};\n"; @@ -1983,6 +2686,215 @@ static int setup_input(void) { * libwayland. We manage the socket ourselves (rather than * wl_display_add_socket, which derives the path from XDG_RUNTIME_DIR) so * the path is deterministic for the client. */ +/* ====================================================================== */ +/* kwlctl control + event IPC (the hyprctl analog) */ +/* ====================================================================== */ + +/* One control-socket connection. A plain request/reply connection is closed + * after its reply; a --listen connection stays open and joins g.listeners to + * receive the event stream. */ +struct kwlctl_conn { + int fd; + struct wl_event_source *src; + int listening; +}; + +static void kwlctl_send(int fd, const char *buf, int len) { + for (int off = 0; off < len; ) { + ssize_t w = write(fd, buf + off, (size_t)(len - off)); + if (w <= 0) break; /* dead peer: reaped on its next readable/EOF */ + off += (int)w; + } +} + +/* Push one `event>>data` line (Hyprland socket2 format) to every listener. */ +static void kwlctl_emit(const char *fmt, ...) { + char buf[256]; + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(buf, sizeof(buf) - 1, fmt, ap); + va_end(ap); + if (n < 0) return; + if (n > (int)sizeof(buf) - 1) n = (int)sizeof(buf) - 1; + buf[n++] = '\n'; + for (int i = 0; i < MAX_KWLCTL_CONNS; i++) + if (g.listeners[i]) kwlctl_send(g.listeners[i]->fd, buf, n); +} + +/* JSON describing one surface, Hyprland `clients -j`-shaped subset. */ +static int kwlctl_window_json(char *buf, size_t cap, struct surface *s) { + return snprintf(buf, cap, + "{\"address\":\"%p\",\"class\":\"%s\",\"workspace\":{\"id\":%d}," + "\"at\":[%d,%d],\"size\":[%d,%d],\"focused\":%s}", + (void *)s, s->app_id, s->workspace, s->x, s->y, s->w, s->h, + g.kbd_focus == s ? "true" : "false"); +} + +static int kwlctl_clients_json(char *buf, size_t cap) { + int n = snprintf(buf, cap, "["); + int first = 1; + for (int i = 0; i < g.n_surfaces && n < (int)cap; i++) { + struct surface *s = g.zorder[i]; + if (!s->mapped) continue; + if (!first) n += snprintf(buf + n, cap - n, ","); + n += kwlctl_window_json(buf + n, cap - n, s); + first = 0; + } + n += snprintf(buf + n, cap - n, "]\n"); + return n; +} + +static int kwlctl_workspaces_json(char *buf, size_t cap) { + int counts[N_WORKSPACES + 1] = {0}; + for (int i = 0; i < g.n_surfaces; i++) + if (g.zorder[i]->mapped) counts[g.zorder[i]->workspace]++; + int n = snprintf(buf, cap, "["); + int first = 1; + for (int ws = 1; ws <= N_WORKSPACES && n < (int)cap; ws++) { + if (!counts[ws] && ws != g.active_ws) continue; + n += snprintf(buf + n, cap - n, + "%s{\"id\":%d,\"windows\":%d,\"active\":%s}", + first ? "" : ",", ws, counts[ws], + ws == g.active_ws ? "true" : "false"); + first = 0; + } + n += snprintf(buf + n, cap - n, "]\n"); + return n; +} + +static int kwlctl_activewindow_json(char *buf, size_t cap) { + if (!g.kbd_focus) return snprintf(buf, cap, "{}\n"); + int n = kwlctl_window_json(buf, cap, g.kbd_focus); + n += snprintf(buf + n, cap - n, "\n"); + return n; +} + +/* dispatch exec: launch a client with the NON-forking posix_spawnp + * (SYS_SPAWN, see docs/plans/2026-05-04-non-forking-posix-spawn-design.md). + * fork() from inside a wl_event_loop callback would wedge the server; the + * direct spawn syscall sidesteps it entirely and needs no fork instrumentation. + * posix_spawnp walks PATH in libc and passes the kernel one resolved path. */ +static void kwlctl_exec(char *args) { + char *argv[16]; + int argc = 0; + for (char *tok = strtok(args, " "); tok && argc < 15; + tok = strtok(NULL, " ")) + argv[argc++] = tok; + argv[argc] = NULL; + if (argc == 0) return; + extern char **environ; + pid_t pid = 0; + int rc = posix_spawnp(&pid, argv[0], NULL, NULL, argv, environ); + if (rc != 0) { + fprintf(stderr, "posix_spawnp %s: %s\n", argv[0], strerror(rc)); + return; + } + printf("KWLCTL_EXEC \"%s\" pid=%d\n", argv[0], (int)pid); + fflush(stdout); +} + +static void kwlctl_conn_close(struct kwlctl_conn *c) { + if (c->listening) + for (int i = 0; i < MAX_KWLCTL_CONNS; i++) + if (g.listeners[i] == c) { g.listeners[i] = NULL; break; } + if (c->src) wl_event_source_remove(c->src); + close(c->fd); + free(c); +} + +/* Execute one command line. Returns 1 to keep the connection open (--listen), + * 0 to close after the reply. */ +static int kwlctl_handle(struct kwlctl_conn *c, char *line) { + char buf[4096]; + if (strcmp(line, "clients") == 0) { + kwlctl_send(c->fd, buf, kwlctl_clients_json(buf, sizeof(buf))); + return 0; + } + if (strcmp(line, "workspaces") == 0) { + kwlctl_send(c->fd, buf, kwlctl_workspaces_json(buf, sizeof(buf))); + return 0; + } + if (strcmp(line, "activewindow") == 0) { + kwlctl_send(c->fd, buf, kwlctl_activewindow_json(buf, sizeof(buf))); + return 0; + } + if (strncmp(line, "dispatch ", 9) == 0) { + char *op = line + 9; + if (strncmp(op, "workspace ", 10) == 0) + switch_workspace(atoi(op + 10)); + else if (strncmp(op, "movetoworkspace ", 16) == 0) + move_focus_to_workspace(atoi(op + 16)); + else if (strcmp(op, "close") == 0) { + if (g.kbd_focus && g.kbd_focus->xdg_toplevel) + xdg_toplevel_send_close(g.kbd_focus->xdg_toplevel); + } else if (strncmp(op, "exec ", 5) == 0) + kwlctl_exec(op + 5); + else { + kwlctl_send(c->fd, "err unknown dispatch\n", 21); + return 0; + } + kwlctl_send(c->fd, "ok\n", 3); + return 0; + } + if (strcmp(line, "--listen") == 0) { + for (int i = 0; i < MAX_KWLCTL_CONNS; i++) + if (!g.listeners[i]) { + g.listeners[i] = c; + c->listening = 1; + kwlctl_send(c->fd, "listening\n", 10); + return 1; + } + kwlctl_send(c->fd, "err too many listeners\n", 23); + return 0; + } + kwlctl_send(c->fd, "err unknown command\n", 20); + return 0; +} + +static int kwlctl_conn_readable(int fd, uint32_t mask, void *data) { + (void)mask; + struct kwlctl_conn *c = data; + char line[1024]; + ssize_t r = read(fd, line, sizeof(line) - 1); + if (r <= 0) { kwlctl_conn_close(c); return 0; } + while (r > 0 && (line[r - 1] == '\n' || line[r - 1] == '\r')) r--; + line[r] = '\0'; + if (!kwlctl_handle(c, line)) kwlctl_conn_close(c); + return 0; +} + +static int kwlctl_listen_readable(int fd, uint32_t mask, void *data) { + (void)mask; (void)data; + int cfd = accept(fd, NULL, NULL); + if (cfd < 0) return 0; + /* Don't leak this control fd into `dispatch exec` children: an inherited + * copy keeps the socket half-open so the kwlctl client never sees EOF. */ + fcntl(cfd, F_SETFD, FD_CLOEXEC); + struct kwlctl_conn *c = calloc(1, sizeof(*c)); + if (!c) { close(cfd); return 0; } + c->fd = cfd; + c->src = wl_event_loop_add_fd(g.loop, cfd, WL_EVENT_READABLE, + kwlctl_conn_readable, c); + return 0; +} + +static int setup_kwlctl(void) { + unlink(KWLCTL_SOCKET_PATH); + int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) { perror("socket kwlctl"); return -1; } + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, KWLCTL_SOCKET_PATH, sizeof(addr.sun_path) - 1); + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + perror("bind kwlctl"); close(fd); return -1; + } + if (listen(fd, 8) < 0) { perror("listen kwlctl"); close(fd); return -1; } + wl_event_loop_add_fd(g.loop, fd, WL_EVENT_READABLE, kwlctl_listen_readable, + NULL); + return 0; +} + static int setup_socket(void) { unlink(WL_SOCKET_PATH); /* clear a stale socket */ @@ -2007,6 +2919,17 @@ int main(void) { if (!g.display) { fprintf(stderr, "wl_display_create\n"); return 1; } g.loop = wl_display_get_event_loop(g.display); + /* Layout policy. Absent/unknown WLC_LAYOUT keeps the floating desktop so + * /?demo=wayland is unchanged; WLC_LAYOUT=dwindle selects the tiler. */ + g.active_ws = 1; + const char *want_layout = getenv("WLC_LAYOUT"); + if (want_layout && strcmp(want_layout, "dwindle") == 0) + g.layout = LAYOUT_DWINDLE; + printf("WLC_LAYOUT %s\n", + g.layout == LAYOUT_DWINDLE ? "dwindle" : "floating"); + fflush(stdout); + load_config(); + if (setup_drm() != 0) return 1; if (setup_wallpaper() != 0) return 1; /* GPU compositing is best-effort: on hosts without WebGL2 (Node @@ -2021,8 +2944,12 @@ int main(void) { if (!wl_global_create(g.display, &wl_compositor_interface, 4, NULL, compositor_bind) || !wl_global_create(g.display, &wl_shm_interface, 1, NULL, shm_bind) || + !wl_global_create(g.display, &zwp_linux_dmabuf_v1_interface, 3, NULL, + dmabuf_bind) || !wl_global_create(g.display, &xdg_wm_base_interface, 1, NULL, wm_base_bind) || + !wl_global_create(g.display, &zxdg_decoration_manager_v1_interface, 1, + NULL, decoration_mgr_bind) || !wl_global_create(g.display, &wl_seat_interface, 1, NULL, seat_bind) || !wl_global_create(g.display, &wl_output_interface, 2, NULL, output_bind)) { @@ -2044,6 +2971,10 @@ int main(void) { if (setup_socket() != 0) return 1; + /* Auto-reap `dispatch exec` children so they don't linger as zombies. */ + signal(SIGCHLD, SIG_IGN); + if (setup_kwlctl() != 0) return 1; + printf("COMPOSITOR_UP w=%u h=%u\n", g.width, g.height); fflush(stdout); diff --git a/programs/wlcompositor/wldmabuf-test.c b/programs/wlcompositor/wldmabuf-test.c new file mode 100644 index 0000000000..76e563aaae --- /dev/null +++ b/programs/wlcompositor/wldmabuf-test.c @@ -0,0 +1,246 @@ +/* + * wldmabuf-test — the PR11 gate's Wayland client. A minimal raw + * libwayland-client program that drives wlcompositor's zwp_linux_dmabuf_v1 + * path so host/test/wlcompositor-dmabuf-smoke.test.ts can assert the + * compositor imports and composites a dmabuf-supplied buffer: + * + * 1. connect, bind the globals it needs (wl_compositor, zwp_linux_dmabuf_v1, + * xdg_wm_base), and confirm the dmabuf advertises XRGB8888 + LINEAR. + * 2. create an xdg_toplevel, ack the compositor's configure. + * 3. allocate a renderD128 dumb-bo, paint it solid red, and turn its + * prime-fd into a wl_buffer via zwp_linux_buffer_params_v1.create_immed + * (offset 0, LINEAR) — the dmabuf equivalent of wlclient-test's wl_shm + * pool, exercising the GPU-tier client-buffer path (PR10 §7.1). + * 4. attach + commit + request a frame callback; when it fires, the + * compositor has imported our dmabuf and flipped it onto card0. + * + * Prints markers the test asserts and exits 0; the compositor exits 0 once + * we disconnect. Input routing is covered by wlclient-test — this gate is + * purely the dmabuf buffer path. + */ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include "xdg-shell-client-protocol.h" +#include "linux-dmabuf-v1-client-protocol.h" + +#include +#include + +#define WL_SOCKET_PATH "/tmp/wayland-0" +#define WIN_W 200 +#define WIN_H 150 +#define RED 0x00ff0000u /* XRGB8888: opaque red (X byte ignored) */ + +struct client { + struct wl_compositor *compositor; + struct zwp_linux_dmabuf_v1 *dmabuf; + struct xdg_wm_base *wm_base; + + struct wl_surface *surface; + struct xdg_surface *xdg_surface; + struct xdg_toplevel *toplevel; + + int configured; /* got + acked the initial xdg configure */ + int frame_done; /* compositor imported + flipped our buffer */ + int saw_xrgb_linear; /* dmabuf advertised XRGB8888 with LINEAR */ +}; + +/* ---- zwp_linux_dmabuf_v1: format/modifier advertisement ---------------- */ + +static void dmabuf_format(void *data, struct zwp_linux_dmabuf_v1 *d, + uint32_t format) {} +static void dmabuf_modifier(void *data, struct zwp_linux_dmabuf_v1 *d, + uint32_t format, uint32_t mod_hi, uint32_t mod_lo) { + struct client *c = data; + uint64_t mod = ((uint64_t)mod_hi << 32) | mod_lo; + if (format == DRM_FORMAT_XRGB8888 && mod == DRM_FORMAT_MOD_LINEAR) + c->saw_xrgb_linear = 1; +} +static const struct zwp_linux_dmabuf_v1_listener dmabuf_listener = { + .format = dmabuf_format, + .modifier = dmabuf_modifier, +}; + +/* ---- registry ---------------------------------------------------------- */ + +static void registry_global(void *data, struct wl_registry *reg, uint32_t name, + const char *iface, uint32_t version) { + struct client *c = data; + if (strcmp(iface, "wl_compositor") == 0) + c->compositor = wl_registry_bind(reg, name, &wl_compositor_interface, + version < 4 ? version : 4); + else if (strcmp(iface, "zwp_linux_dmabuf_v1") == 0) { + c->dmabuf = wl_registry_bind(reg, name, &zwp_linux_dmabuf_v1_interface, + version < 3 ? version : 3); + zwp_linux_dmabuf_v1_add_listener(c->dmabuf, &dmabuf_listener, c); + } else if (strcmp(iface, "xdg_wm_base") == 0) + c->wm_base = wl_registry_bind(reg, name, &xdg_wm_base_interface, 1); +} +static void registry_global_remove(void *data, struct wl_registry *r, + uint32_t name) {} +static const struct wl_registry_listener registry_listener = { + .global = registry_global, + .global_remove = registry_global_remove, +}; + +/* ---- xdg_shell --------------------------------------------------------- */ + +static void wm_base_ping(void *data, struct xdg_wm_base *b, uint32_t serial) { + xdg_wm_base_pong(b, serial); +} +static const struct xdg_wm_base_listener wm_base_listener = { + .ping = wm_base_ping, +}; +static void xdg_surface_configure(void *data, struct xdg_surface *xs, + uint32_t serial) { + struct client *c = data; + xdg_surface_ack_configure(xs, serial); + c->configured = 1; +} +static const struct xdg_surface_listener xdg_surface_listener = { + .configure = xdg_surface_configure, +}; +static void toplevel_configure(void *data, struct xdg_toplevel *t, int32_t w, + int32_t h, struct wl_array *states) {} +static void toplevel_close(void *data, struct xdg_toplevel *t) {} +static const struct xdg_toplevel_listener toplevel_listener = { + .configure = toplevel_configure, + .close = toplevel_close, +}; + +/* ---- frame callback ---------------------------------------------------- */ + +static void frame_done(void *data, struct wl_callback *cb, uint32_t t) { + struct client *c = data; + c->frame_done = 1; + wl_callback_destroy(cb); +} +static const struct wl_callback_listener frame_listener = { + .done = frame_done, +}; + +/* ---- helpers ----------------------------------------------------------- */ + +static int connect_socket(void) { + int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) { perror("socket"); return -1; } + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, WL_SOCKET_PATH, sizeof(addr.sun_path) - 1); + for (int i = 0; i < 100; i++) { + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) + return fd; + usleep(10000); + } + perror("connect"); + close(fd); + return -1; +} + +/* Allocate a renderD128 dumb-bo, paint it red, and wrap its prime-fd as a + * dmabuf wl_buffer via zwp_linux_buffer_params_v1. Returns the wl_buffer. */ +static struct wl_buffer *make_dmabuf_buffer(struct client *c) { + int render = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); + if (render < 0) { perror("open renderD128"); return NULL; } + struct gbm_device *gbm = gbm_create_device(render); + if (!gbm) { fprintf(stderr, "gbm_create_device\n"); return NULL; } + struct gbm_bo *bo = gbm_bo_create(gbm, WIN_W, WIN_H, GBM_FORMAT_XRGB8888, + GBM_BO_USE_LINEAR | GBM_BO_USE_SCANOUT); + if (!bo) { fprintf(stderr, "gbm_bo_create\n"); return NULL; } + + uint32_t stride = 0; + void *map_data = NULL; + uint32_t *px = gbm_bo_map(bo, 0, 0, WIN_W, WIN_H, 0, &stride, &map_data); + if (!px) { fprintf(stderr, "gbm_bo_map\n"); return NULL; } + uint32_t stride_px = stride / 4; + for (int y = 0; y < WIN_H; y++) + for (int x = 0; x < WIN_W; x++) + px[y * stride_px + x] = RED; + gbm_bo_unmap(bo, map_data); + + int prime = gbm_bo_get_fd(bo); + if (prime < 0) { fprintf(stderr, "gbm_bo_get_fd\n"); return NULL; } + + struct zwp_linux_buffer_params_v1 *params = + zwp_linux_dmabuf_v1_create_params(c->dmabuf); + zwp_linux_buffer_params_v1_add( + params, prime, 0, 0, stride, + (uint32_t)(DRM_FORMAT_MOD_LINEAR >> 32), + (uint32_t)(DRM_FORMAT_MOD_LINEAR & 0xffffffffu)); + struct wl_buffer *buf = zwp_linux_buffer_params_v1_create_immed( + params, WIN_W, WIN_H, DRM_FORMAT_XRGB8888, 0); + zwp_linux_buffer_params_v1_destroy(params); + close(prime); /* the compositor dup'd it into its own bo */ + printf("DMABUF_BUFFER stride=%u\n", stride); + fflush(stdout); + return buf; +} + +int main(void) { + struct client c; + memset(&c, 0, sizeof(c)); + + int fd = connect_socket(); + if (fd < 0) return 1; + struct wl_display *display = wl_display_connect_to_fd(fd); + if (!display) { fprintf(stderr, "wl_display_connect_to_fd\n"); return 1; } + + struct wl_registry *registry = wl_display_get_registry(display); + wl_registry_add_listener(registry, ®istry_listener, &c); + wl_display_roundtrip(display); /* receive globals */ + wl_display_roundtrip(display); /* receive dmabuf format/modifier events */ + + if (!c.compositor || !c.dmabuf || !c.wm_base) { + fprintf(stderr, "missing globals: comp=%p dmabuf=%p wm=%p\n", + (void *)c.compositor, (void *)c.dmabuf, (void *)c.wm_base); + return 1; + } + if (!c.saw_xrgb_linear) { + fprintf(stderr, "dmabuf never advertised XRGB8888 + LINEAR\n"); + return 1; + } + printf("BOUND_ALL\n"); + fflush(stdout); + + xdg_wm_base_add_listener(c.wm_base, &wm_base_listener, &c); + + c.surface = wl_compositor_create_surface(c.compositor); + c.xdg_surface = xdg_wm_base_get_xdg_surface(c.wm_base, c.surface); + xdg_surface_add_listener(c.xdg_surface, &xdg_surface_listener, &c); + c.toplevel = xdg_surface_get_toplevel(c.xdg_surface); + xdg_toplevel_add_listener(c.toplevel, &toplevel_listener, &c); + xdg_toplevel_set_title(c.toplevel, "wldmabuf-test"); + wl_surface_commit(c.surface); + + while (!c.configured) + if (wl_display_dispatch(display) < 0) { fprintf(stderr, "dispatch\n"); return 1; } + printf("CONFIGURED\n"); + fflush(stdout); + + struct wl_buffer *buffer = make_dmabuf_buffer(&c); + if (!buffer) return 1; + + wl_surface_attach(c.surface, buffer, 0, 0); + wl_surface_damage(c.surface, 0, 0, WIN_W, WIN_H); + struct wl_callback *frame = wl_surface_frame(c.surface); + wl_callback_add_listener(frame, &frame_listener, &c); + wl_surface_commit(c.surface); + + while (!c.frame_done) + if (wl_display_dispatch(display) < 0) { fprintf(stderr, "dispatch\n"); return 1; } + printf("DMABUF_CLIENT_OK\n"); + fflush(stdout); + + wl_display_disconnect(display); + return 0; +} diff --git a/programs/wlpaint.c b/programs/wlpaint.c index 90a5309bbe..4c92638854 100644 --- a/programs/wlpaint.c +++ b/programs/wlpaint.c @@ -8,13 +8,22 @@ * state across motion events, and damage-driven commits from a third * concurrent client. * - * The canvas is an app-owned static buffer, not the wl_shm back buffer: - * libkwl double-buffers, so a stroke painted directly into one back buffer - * would flicker in and out on alternate commits. Every dirty frame blits - * canvas + toolbar into the current back buffer and commits. + * The canvas is an app-owned buffer, not the wl_shm back buffer: libkwl + * double-buffers, so a stroke painted directly into one back buffer would + * flicker in and out on alternate commits. Every dirty frame blits canvas + + * toolbar into the current back buffer and commits. + * + * Under a tiling compositor the window is resized to fill its slot: libkwl + * rebuilds the wl_shm buffers at the tile size and delivers KWL_RESIZE. The + * toolbar spans the full surface width and the canvas is reallocated to the + * new content area (preserving the overlapping painting), so wlpaint fills its + * tile instead of drawing a fixed 640×420 island in the corner. Floating + * clients ignore the initial configure(0,0) and never see KWL_RESIZE, so + * /?demo=wayland keeps the exact 640×420 layout the desktop gate asserts. * * Markers on stdout for the smoke gates: * WLPAINT_READY — window mapped + first frame committed + * WLPAINT_RESIZE w=… h=… — the compositor dictated a new size (tiling) * WLPAINT_STROKE x=… y=… — first stamp of each stroke (press) * WLPAINT_STROKE_END — the stroke's release arrived (drag over; * gates the pointer-grab/release routing) @@ -23,6 +32,7 @@ * WLPAINT_EXIT — clean shutdown (close box) */ #include +#include #include #include @@ -31,10 +41,9 @@ #include #include -#define WIN_W 640 +#define WIN_W 640 /* initial requested size; a tiler overrides it */ #define WIN_H 420 #define TOOLBAR_H 36 -#define CANVAS_H (WIN_H - TOOLBAR_H) #define SWATCH_SZ 24 #define SWATCH_X0 8 @@ -57,11 +66,38 @@ static const wpk_color palette[] = { #define CLEAR_W 56 #define CLEAR_H 24 -static uint32_t canvas[WIN_W * CANVAS_H]; +/* App-owned painting, canvas_w × canvas_h (the content area below the + * toolbar). Reallocated on resize rather than fixed, so the painting fills + * whatever tile the compositor hands us. */ +static uint32_t *canvas = NULL; +static int canvas_w = 0, canvas_h = 0; static int cur_color = 4; /* start on blue */ static void canvas_clear(void) { - for (int i = 0; i < WIN_W * CANVAS_H; i++) canvas[i] = CANVAS_BG; + for (int i = 0; i < canvas_w * canvas_h; i++) canvas[i] = CANVAS_BG; +} + +/* (Re)allocate the canvas to w×h, preserving the overlapping top-left region + * so an in-progress painting survives a retile. Returns 0 on success. */ +static int canvas_resize(int w, int h) { + if (w < 1) w = 1; + if (h < 1) h = 1; + if (canvas && w == canvas_w && h == canvas_h) return 0; + uint32_t *nc = malloc((size_t)w * h * sizeof(*nc)); + if (!nc) return -1; + for (int i = 0; i < w * h; i++) nc[i] = CANVAS_BG; + if (canvas) { + int cw = w < canvas_w ? w : canvas_w; + int ch = h < canvas_h ? h : canvas_h; + for (int y = 0; y < ch; y++) + memcpy(nc + (size_t)y * w, canvas + (size_t)y * canvas_w, + (size_t)cw * sizeof(*nc)); + free(canvas); + } + canvas = nc; + canvas_w = w; + canvas_h = h; + return 0; } /* Stamp a filled brush disc at canvas coordinates. */ @@ -70,8 +106,8 @@ static void stamp(int x, int y) { for (int dx = -BRUSH_R; dx <= BRUSH_R; dx++) { if (dx * dx + dy * dy > BRUSH_R * BRUSH_R) continue; int px = x + dx, py = y + dy; - if (px < 0 || px >= WIN_W || py < 0 || py >= CANVAS_H) continue; - canvas[py * WIN_W + px] = palette[cur_color]; + if (px < 0 || px >= canvas_w || py < 0 || py >= canvas_h) continue; + canvas[py * canvas_w + px] = palette[cur_color]; } } } @@ -89,8 +125,8 @@ static void stroke(int x0, int y0, int x1, int y1) { } static void render(struct wpk_surface *s, struct wpk_font *font) { - /* Toolbar. */ - wpk_rect(s, 0, 0, WIN_W, TOOLBAR_H, WPK_RGB(0x2e, 0x33, 0x42)); + /* Toolbar spans the full surface width. */ + wpk_rect(s, 0, 0, s->w, TOOLBAR_H, WPK_RGB(0x2e, 0x33, 0x42)); for (int i = 0; i < N_COLORS; i++) { int x = SWATCH_X0 + i * SWATCH_STEP; int y = (TOOLBAR_H - SWATCH_SZ) / 2; @@ -110,10 +146,18 @@ static void render(struct wpk_surface *s, struct wpk_font *font) { "drag to paint", WPK_RGB(0x8a, 0x93, 0xaa)); } - /* Canvas. */ - for (int y = 0; y < CANVAS_H; y++) + /* Canvas fills the area below the toolbar. Paint the background first so + * any surface region the canvas doesn't cover (should be none while the + * two are kept in sync) never shows stale bytes, then blit the stored + * painting clipped to the overlap. */ + int area_h = s->h - TOOLBAR_H; + if (area_h < 0) area_h = 0; + wpk_rect(s, 0, TOOLBAR_H, s->w, area_h, CANVAS_BG); + int cw = s->w < canvas_w ? s->w : canvas_w; + int ch = area_h < canvas_h ? area_h : canvas_h; + for (int y = 0; y < ch; y++) memcpy(s->pixels + (size_t)(y + TOOLBAR_H) * (s->stride / 4), - canvas + (size_t)y * WIN_W, WIN_W * 4); + canvas + (size_t)y * canvas_w, (size_t)cw * 4); } int main(void) { @@ -121,8 +165,14 @@ int main(void) { if (!win) { fprintf(stderr, "kwl_window_create failed\n"); return 1; } struct wpk_font *font = wpk_font_load_default(14); - canvas_clear(); - render(kwl_window_surface(win), font); + /* Size the canvas from the surface the compositor actually gave us: 640×420 + * floating, or the tile size a tiler dictated before the first frame. */ + struct wpk_surface *s0 = kwl_window_surface(win); + if (canvas_resize(s0->w, s0->h - TOOLBAR_H) != 0) { + fprintf(stderr, "canvas alloc failed\n"); + return 1; + } + render(s0, font); kwl_window_commit(win); printf("WLPAINT_READY\n"); fflush(stdout); @@ -179,6 +229,15 @@ int main(void) { dirty = 1; } break; + case KWL_RESIZE: + /* The compositor tiled us into a new slot. libkwl already + * rebuilt the wl_shm buffers; grow the painting to match and + * redraw so the toolbar + canvas fill the whole tile. */ + printf("WLPAINT_RESIZE w=%d h=%d\n", ev.x, ev.y); + fflush(stdout); + canvas_resize(ev.x, ev.y - TOOLBAR_H); + dirty = 1; + break; case KWL_CLOSE: running = 0; break; @@ -198,5 +257,6 @@ int main(void) { fflush(stdout); if (font) wpk_font_destroy(font); kwl_window_destroy(win); + free(canvas); return 0; } diff --git a/programs/wlterm/vt100.c b/programs/wlterm/vt100.c index 758c535704..ee91286fae 100644 --- a/programs/wlterm/vt100.c +++ b/programs/wlterm/vt100.c @@ -65,6 +65,34 @@ void vt100_destroy(struct vt100 *t) { free(t); } +int vt100_resize(struct vt100 *t, int cols, int rows) { + if (cols < 4 || cols > 512 || rows < 4 || rows > 256) return 0; + if (cols == t->cols && rows == t->rows) return 0; + + struct cell *grid = calloc((size_t)cols * rows, sizeof(struct cell)); + uint8_t *dirty = calloc((rows + 7) / 8, 1); + if (!grid || !dirty) { free(grid); free(dirty); return 0; } + + /* Preserve the overlapping top-left block so visible output survives a + * retile. */ + int cpy_rows = rows < t->rows ? rows : t->rows; + int cpy_cols = cols < t->cols ? cols : t->cols; + for (int y = 0; y < cpy_rows; y++) + memcpy(&grid[(size_t)y * cols], &t->grid[(size_t)y * t->cols], + (size_t)cpy_cols * sizeof(struct cell)); + + free(t->grid); + free(t->dirty); + t->grid = grid; + t->dirty = dirty; + t->cols = cols; + t->rows = rows; + if (t->cx >= cols) t->cx = cols - 1; + if (t->cy >= rows) t->cy = rows - 1; + vt100_mark_dirty_all(t); + return 1; +} + static void scroll_up(struct vt100 *t) { memmove(&t->grid[0], &t->grid[t->cols], (size_t)(t->rows - 1) * t->cols * sizeof(struct cell)); diff --git a/programs/wlterm/vt100.h b/programs/wlterm/vt100.h index fbdce2a30f..7762c8251d 100644 --- a/programs/wlterm/vt100.h +++ b/programs/wlterm/vt100.h @@ -31,6 +31,12 @@ struct vt100; /* opaque */ struct vt100 *vt100_create(int cols, int rows); void vt100_destroy(struct vt100 *t); +/* Resize the grid to cols × rows (a tiling compositor changed the window). + * The overlapping top-left cells are preserved; the cursor is clamped into + * the new bounds. A no-op (returns 0) if the size is unchanged or out of the + * vt100_create() bounds; returns 1 if the grid was rebuilt. */ +int vt100_resize(struct vt100 *t, int cols, int rows); + /* Feed raw bytes from the child's stdout; advances the cursor and mutates * cells per the VT100 subset. */ void vt100_feed(struct vt100 *t, const char *bytes, size_t len); diff --git a/programs/wlterm/wlterm.c b/programs/wlterm/wlterm.c index 28b74461d7..9b43976ec3 100644 --- a/programs/wlterm/wlterm.c +++ b/programs/wlterm/wlterm.c @@ -14,9 +14,13 @@ * forkpty() forks, so this binary MUST be run through * scripts/run-wasm-fork-instrument.sh at build time (see build-programs.sh). * + * Under a tiling compositor the window is resized to its slot; wlterm + * recomputes the grid from the new pixel size and re-sizes the PTY. + * * Markers on stdout drive host/test/wlterm-smoke.test.ts: * WLTERM_READY — window mapped + first frame committed * WLTERM_GRID "" — is now visible in the cell grid + * WLTERM_RESIZE cols=.. rows=.. — the compositor dictated a new size * WLTERM_EXIT code= — shell exited, clean shutdown * WLTERM_EXIT signal= — shell died on a signal */ @@ -28,6 +32,7 @@ #include #include #include +#include #include #include @@ -155,6 +160,28 @@ int main(int argc, char **argv) { } } else if (ev.type == KWL_CLOSE) { running = 0; + } else if (ev.type == KWL_RESIZE) { + /* Re-derive the grid from the new pixel size and tell the PTY, + * so the shell reflows to the tile. */ + int ncols = ev.x / cell_w, nrows = ev.y / cell_h; + if (ncols < 4) ncols = 4; + if (nrows < 4) nrows = 4; + if (vt100_resize(term, ncols, nrows)) { + cols = ncols; + rows = nrows; + struct winsize nws = { + .ws_row = (unsigned short)rows, + .ws_col = (unsigned short)cols, + .ws_xpixel = (unsigned short)ev.x, + .ws_ypixel = (unsigned short)ev.y, + }; + ioctl(master, TIOCSWINSZ, &nws); + if (pid > 0) kill(pid, SIGWINCH); + vt100_render(term, s, font, 0, 0); + kwl_window_commit(win); + printf("WLTERM_RESIZE cols=%d rows=%d\n", cols, rows); + fflush(stdout); + } } } @@ -189,10 +216,23 @@ int main(int argc, char **argv) { } } - /* Reap the shell. */ + /* Tear the surface down FIRST so the compositor removes and retiles the + * pane immediately — otherwise a window-close (killactive) request would + * leave the tile on screen until the shell reap below returns. */ + kwl_window_destroy(win); + + /* Reap the shell. The loop may have ended on a window-close request + * (KWL_CLOSE) with the shell still running: closing the pty master is + * meant to hang up the slave's foreground group, but we also SIGHUP the + * child explicitly so the pane closes even when that hangup doesn't + * propagate — a wedged shell must not keep waitpid (and the window) + * blocked forever. SIGHUP on an already-exited pid is a harmless ESRCH. */ close(master); int status = 0; - if (pid > 0) waitpid(pid, &status, 0); + if (pid > 0) { + kill(pid, SIGHUP); + waitpid(pid, &status, 0); + } /* WEXITSTATUS is undefined unless WIFEXITED, so a signal death cannot be * reported through code=. */ if (WIFSIGNALED(status)) { @@ -204,6 +244,5 @@ int main(int argc, char **argv) { vt100_destroy(term); wpk_font_destroy(font); - kwl_window_destroy(win); return 0; } diff --git a/scripts/build-programs.sh b/scripts/build-programs.sh index 687792d593..87c73f5544 100755 --- a/scripts/build-programs.sh +++ b/scripts/build-programs.sh @@ -546,9 +546,23 @@ for src in "$REPO_ROOT/programs/"*.c; do sdl2_*.c) # SDL2's KMSDRM backend calls into gbm and libdrm, so both # follow libSDL2.a in the link order. + # + # libwayland-client + libffi: SDL2 is built with + # `--enable-video-wayland --disable-wayland-shared`, so libSDL2.a's + # video bootstrap array lists Wayland_bootstrap BEFORE + # KMSDRM_bootstrap and direct-references wl_display_connect (no + # dlopen). SDL_Init(VIDEO) probes Wayland first: the REAL + # wl_display_connect(NULL) returns NULL in this env (no + # XDG_RUNTIME_DIR / compositor), so SDL falls through to KMSDRM — + # the real-hardware auto-select path. Without these archives + # wl_display_connect resolves to the host's throw-on-call stub and + # the probe aborts the program. libffi backs libwayland-client's + # wl_closure marshalling. build_program "$src" "$OUT_DIR_32" \ "$SYSROOT/lib/libSDL2.a" \ - "$SYSROOT/lib/libgbm.a" "$SYSROOT/lib/libdrm.a" + "$SYSROOT/lib/libwayland-client.a" \ + "$SYSROOT/lib/libgbm.a" "$SYSROOT/lib/libdrm.a" \ + "$SYSROOT/lib/libffi.a" ;; posix-timer-thread.c) # Keep the fixture's pthread capacity small so its timer-helper @@ -617,6 +631,7 @@ if ls "$REPO_ROOT"/programs/wlcompositor/*.c >/dev/null 2>&1; then ln -sfn "$WLC_LIBFFI/lib/libffi.a" "$SYSROOT/lib/libffi.a" ln -sfn "$WLC_LIBWL/lib/libwayland-server.a" "$SYSROOT/lib/libwayland-server.a" ln -sfn "$WLC_LIBWL/lib/libwayland-client.a" "$SYSROOT/lib/libwayland-client.a" + ln -sfn "$WLC_LIBWL/lib/libwayland-cursor.a" "$SYSROOT/lib/libwayland-cursor.a" ln -sfn "$WLC_LIBXKB/lib/libxkbcommon.a" "$SYSROOT/lib/libxkbcommon.a" mkdir -p "$SYSROOT/include/xkbcommon" for h in "$WLC_LIBXKB/include/xkbcommon"/*.h; do @@ -632,6 +647,42 @@ if ls "$REPO_ROOT"/programs/wlcompositor/*.c >/dev/null 2>&1; then wayland-scanner server-header "$XDG_XML" "$WLC_GEN/xdg-shell-server-protocol.h" wayland-scanner client-header "$XDG_XML" "$WLC_GEN/xdg-shell-client-protocol.h" + # Same for zwp_linux_dmabuf_v1 (PR11): the compositor's GPU-tier client + # buffer path. Server side is compiled into wlcompositor; the client + # header + private-code drive wldmabuf-test. + DMABUF_XML="$REPO_ROOT/packages/registry/wayland-protocols/xml/linux-dmabuf-v1.xml" + wayland-scanner private-code "$DMABUF_XML" "$WLC_GEN/linux-dmabuf-v1-protocol.c" + wayland-scanner server-header "$DMABUF_XML" "$WLC_GEN/linux-dmabuf-v1-server-protocol.h" + wayland-scanner client-header "$DMABUF_XML" "$WLC_GEN/linux-dmabuf-v1-client-protocol.h" + + # Same for zxdg_decoration_manager_v1 (PR14e): server-side decoration + # negotiation. The compositor forces SERVER_SIDE so tiled clients drop CSD; + # the client header + private-code drive wlclient-test's decoration request. + DECOR_XML="$REPO_ROOT/packages/registry/wayland-protocols/xml/xdg-decoration-unstable-v1.xml" + wayland-scanner private-code "$DECOR_XML" "$WLC_GEN/xdg-decoration-v1-protocol.c" + wayland-scanner server-header "$DECOR_XML" "$WLC_GEN/xdg-decoration-v1-server-protocol.h" + wayland-scanner client-header "$DECOR_XML" "$WLC_GEN/xdg-decoration-v1-client-protocol.h" + + # libwayland-egl (step 12a): the wl_egl_window shim that SDL2's upstream + # Wayland+GLES backend uses as its EGLNativeWindowType. It allocates the + # GPU-tier bo the window renders into and wraps it as a zwp_linux_dmabuf_v1 + # wl_buffer; libEGL targets that bo's FBO and attach+commits it on swap + # (see libc/glue/libwayland-egl.c). Self-contained: bundles the dmabuf + # client glue since neither SDL2 nor libwayland ships it, so a GL client + # only links libwayland-egl.a + libEGL.a. Public headers are vendored + # verbatim from wayland 1.24.0 under libc/glue/wayland-egl-include/. + echo " Building libwayland-egl.a (wl_egl_window shim)..." + for h in wayland-egl.h wayland-egl-core.h wayland-egl-backend.h; do + ln -sfn "$GLUE_DIR/wayland-egl-include/$h" "$SYSROOT/include/$h" + done + "$CC" "${CFLAGS[@]}" "-I$WLC_GEN" "-I$GLUE_DIR" \ + "-I$GLUE_DIR/wayland-egl-include" -c \ + "$GLUE_DIR/libwayland-egl.c" -o "$WLC_GEN/libwayland-egl.o" + "$CC" "${CFLAGS[@]}" "-I$WLC_GEN" -c \ + "$WLC_GEN/linux-dmabuf-v1-protocol.c" -o "$WLC_GEN/linux-dmabuf-v1-protocol.o" + "$LLVM_BIN/llvm-ar" rcs "$SYSROOT/lib/libwayland-egl.a" \ + "$WLC_GEN/libwayland-egl.o" "$WLC_GEN/linux-dmabuf-v1-protocol.o" + # Server. Link order: dependents (compositor + xdg glue) before # dependencies; libffi last so wl_closure_invoke's ffi_call resolves. # libwpkdraw renders the compositor's wallpaper (gradient + wordmark); @@ -642,6 +693,8 @@ if ls "$REPO_ROOT"/programs/wlcompositor/*.c >/dev/null 2>&1; then "$CC" "${CFLAGS[@]}" "-I$WLC_GEN" "-I$WLC_LIBINPUT/include" \ "$REPO_ROOT/programs/wlcompositor/wlcompositor.c" \ "$WLC_GEN/xdg-shell-protocol.c" \ + "$WLC_GEN/linux-dmabuf-v1-protocol.c" \ + "$WLC_GEN/xdg-decoration-v1-protocol.c" \ "${LINK_PRE_LIBS[@]}" \ "$SYSROOT/lib/libwayland-server.a" \ "$SYSROOT/lib/libwpkdraw.a" \ @@ -664,6 +717,7 @@ if ls "$REPO_ROOT"/programs/wlcompositor/*.c >/dev/null 2>&1; then "$CC" "${CFLAGS[@]}" "-I$WLC_GEN" \ "$REPO_ROOT/programs/wlcompositor/wlclient-test.c" \ "$WLC_GEN/xdg-shell-protocol.c" \ + "$WLC_GEN/xdg-decoration-v1-protocol.c" \ "${LINK_PRE_LIBS[@]}" \ "$SYSROOT/lib/libwayland-client.a" \ "$SYSROOT/lib/libgbm.a" "$SYSROOT/lib/libdrm.a" \ @@ -672,6 +726,41 @@ if ls "$REPO_ROOT"/programs/wlcompositor/*.c >/dev/null 2>&1; then -o "$client_wasm" "$FORK_INSTRUMENT" "$client_wasm" -o "$client_wasm.instr" mv "$client_wasm.instr" "$client_wasm" + + # kwlctl (PR14c): the hyprctl-analog CLI over the compositor's + # /tmp/kwlctl-0 control socket. Plain libc + sockets, no wayland libs. + if [ -f "$REPO_ROOT/programs/wlcompositor/kwlctl.c" ]; then + kwlctl_wasm="$OUT_DIR_32/kwlctl.wasm" + echo " Compiling kwlctl (control CLI)..." + "$CC" "${CFLAGS[@]}" \ + "$REPO_ROOT/programs/wlcompositor/kwlctl.c" \ + "${LINK_PRE_LIBS[@]}" \ + "${LINK_POST_LIBS[@]}" \ + -o "$kwlctl_wasm" + "$FORK_INSTRUMENT" "$kwlctl_wasm" -o "$kwlctl_wasm.instr" + mv "$kwlctl_wasm.instr" "$kwlctl_wasm" + fi + + # dmabuf client (PR11): drives the zwp_linux_dmabuf_v1 buffer path so + # host/test/wlcompositor-dmabuf-smoke.test.ts can assert the compositor + # composites a dmabuf-imported buffer. Links the dmabuf client glue. + if [ -f "$REPO_ROOT/programs/wlcompositor/wldmabuf-test.c" ]; then + dmabuf_wasm="$OUT_DIR_32/wldmabuf-test.wasm" + echo " Compiling wldmabuf-test (dmabuf client)..." + "$CC" "${CFLAGS[@]}" "-I$WLC_GEN" \ + "$REPO_ROOT/programs/wlcompositor/wldmabuf-test.c" \ + "$WLC_GEN/xdg-shell-protocol.c" \ + "$WLC_GEN/linux-dmabuf-v1-protocol.c" \ + "${LINK_PRE_LIBS[@]}" \ + "$SYSROOT/lib/libwayland-client.a" \ + "$SYSROOT/lib/libgbm.a" "$SYSROOT/lib/libdrm.a" \ + "$SYSROOT/lib/libffi.a" \ + "${LINK_POST_LIBS[@]}" \ + -o "$dmabuf_wasm" + "$FORK_INSTRUMENT" "$dmabuf_wasm" -o "$dmabuf_wasm.instr" + mv "$dmabuf_wasm.instr" "$dmabuf_wasm" + fi + fi # libkwl (PR7 Phase 2): in-tree Wayland toolkit over libwayland-client. @@ -706,6 +795,7 @@ if [ -d "$LIBKWL_DIR/src" ]; then "$CC" "${CFLAGS[@]}" "-I$KWL_GEN" \ "$REPO_ROOT/programs/$kwl_app.c" \ "$KWL_GEN/xdg-shell-protocol.c" \ + "$KWL_GEN/xdg-decoration-v1-protocol.c" \ "${LINK_PRE_LIBS[@]}" \ "$SYSROOT/lib/libkwl.a" \ "$SYSROOT/lib/libwpkdraw.a" \ @@ -738,6 +828,7 @@ if ls "$REPO_ROOT"/programs/wlterm/*.c >/dev/null 2>&1; then "$REPO_ROOT/programs/wlterm/wlterm.c" \ "$REPO_ROOT/programs/wlterm/vt100.c" \ "$KWL_GEN/xdg-shell-protocol.c" \ + "$KWL_GEN/xdg-decoration-v1-protocol.c" \ "${LINK_PRE_LIBS[@]}" \ "$SYSROOT/lib/libkwl.a" \ "$SYSROOT/lib/libwpkdraw.a" \ diff --git a/scripts/ci-vitest-evidence-classes.tsv b/scripts/ci-vitest-evidence-classes.tsv index e19185755d..e83ace7b69 100644 --- a/scripts/ci-vitest-evidence-classes.tsv +++ b/scripts/ci-vitest-evidence-classes.tsv @@ -331,13 +331,20 @@ host/test/wasm64.test.ts prepared-product host/test/wayland-protocols-scanner.test.ts source-only host/test/webgl-bridge.test.ts source-only host/test/webgl-foreign-texture.test.ts source-only +host/test/webgl-gpu-bo.test.ts source-only host/test/webgl-main-forward.test.ts source-only host/test/webgl-muxer.test.ts source-only host/test/webgl-registry.test.ts source-only host/test/webgl-shadow.test.ts source-only host/test/webgl-submit-drain.test.ts source-only host/test/webgl-submit-queue.test.ts source-only +host/test/wlcompositor-decoration-smoke.test.ts prepared-product +host/test/wlcompositor-dmabuf-smoke.test.ts prepared-product +host/test/wlcompositor-keybind-smoke.test.ts prepared-product +host/test/wlcompositor-kwlctl-smoke.test.ts prepared-product +host/test/wlcompositor-resize-smoke.test.ts prepared-product host/test/wlcompositor-smoke.test.ts prepared-product +host/test/wlcompositor-tiling-smoke.test.ts prepared-product host/test/wldesktop-liveness-smoke.test.ts prepared-product host/test/wldesktop-smoke.test.ts prepared-product host/test/wlterm-smoke.test.ts prepared-product