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 f44044b415..87624340a5 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -95,6 +95,8 @@ import { OMARCHY_FONTS_CONF, OMARCHY_MAKO_CONFIG, OMARCHY_MAKO_CONFIG_PATH, + OMARCHY_QUICKSHELL_DIR, + OMARCHY_QUICKSHELL_SHELLS, OMARCHY_THEME_DIR, OMARCHY_THEME_HOOK, OMARCHY_THEMES, @@ -275,6 +277,18 @@ const OPTIONAL_BINARY_URLS = { ...import.meta.glob("../../../../../binaries/programs/wasm32/foot.wasm", { query: "?url", import: "default", }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/qtgallery.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/qtgallery.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../local-binaries/programs/wasm32/quickshell.wasm", { + query: "?url", import: "default", + }), + ...import.meta.glob("../../../../../binaries/programs/wasm32/quickshell.wasm", { + query: "?url", import: "default", + }), } as Record Promise>; async function optionalBinaryUrl( @@ -1920,11 +1934,16 @@ async function bootProfile( const busEnv = omarchy ? [`DBUS_SESSION_BUS_ADDRESS=unix:path=${OMARCHY_BUS_SOCKET}`] : []; + // QtQuick clients (quickshell) must select the software scenegraph + // — there is no GL surface — and `dispatch exec` spawns them with + // the compositor's environment. + const quickEnv = omarchy ? ["QT_QUICK_BACKEND=software"] : []; const compositorExit = spawnBg("/usr/local/bin/wlcompositor", "wlcompositor", [ "WLC_LAYOUT=dwindle", "WLC_CONFIG=/etc/kandelo/wlcompositor.conf", `WLC_SCALE=${outputScale}`, ...busEnv, + ...quickEnv, ]); if (omarchy) { @@ -2224,13 +2243,15 @@ async function stageWaylandDesktopRuntime( if (!profile.omarchyDemo) return; const [barBytes, launcherBytes, daemonBytes, makoBytes, - notifySendBytes, footBytes, fontBytes] = await Promise.all([ + notifySendBytes, footBytes, qtgalleryBytes, quickshellBytes, fontBytes] = await Promise.all([ fetchBinary("waybar.wasm", "waybar.wasm"), fetchBinary("wldesktop/klauncher.wasm", "klauncher.wasm"), fetchBinary("dbus/dbus-daemon.wasm", "dbus-daemon.wasm"), fetchBinary("mako/mako.wasm", "mako.wasm"), fetchBinary("wldesktop/notify-send.wasm", "notify-send.wasm"), fetchBinary("foot.wasm", "foot.wasm"), + fetchBinary("qtgallery.wasm", "qtgallery.wasm"), + fetchBinary("quickshell.wasm", "quickshell.wasm"), fetch(inconsolataFontUrl).then(failOn("Inconsolata-Regular.ttf")) .then((r) => r.arrayBuffer()), ]); @@ -2248,6 +2269,11 @@ async function stageWaylandDesktopRuntime( writeVfsBinary(fs, "/usr/local/bin/notify-send", new Uint8Array(notifySendBytes), 0o755); writeVfsFile(fs, "/usr/local/bin/omarchy-theme-changed", OMARCHY_THEME_HOOK, 0o755); writeVfsBinary(fs, "/usr/local/bin/foot", new Uint8Array(footBytes), 0o755); + writeVfsBinary(fs, "/usr/local/bin/qtgallery", new Uint8Array(qtgalleryBytes), 0o755); + writeVfsBinary(fs, "/usr/local/bin/quickshell", new Uint8Array(quickshellBytes), 0o755); + ensureDirRecursive(fs, OMARCHY_QUICKSHELL_DIR); + for (const [file, qml] of Object.entries(OMARCHY_QUICKSHELL_SHELLS)) + writeVfsFile(fs, `${OMARCHY_QUICKSHELL_DIR}/${file}`, qml, 0o644); ensureDirRecursive(fs, "/etc/dbus-1"); writeVfsFile(fs, "/etc/dbus-1/session.conf", OMARCHY_DBUS_SESSION_CONF, 0o644); ensureDirRecursive(fs, dirname(OMARCHY_MAKO_CONFIG_PATH)); diff --git a/apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts b/apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts index 114bc6fe54..d9b4258b11 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts @@ -36,6 +36,7 @@ export const OMARCHY_WAYBAR_CONFIG_PATH = "/usr/share/kandelo/waybar/config.json export const OMARCHY_WAYBAR_STYLE_SEED_PATH = "/usr/share/kandelo/waybar/style.css"; export const OMARCHY_WAYBAR_STYLE_PATH = "/tmp/waybar-style.css"; export const OMARCHY_MAKO_CONFIG_PATH = "/usr/share/kandelo/mako/config"; +export const OMARCHY_QUICKSHELL_DIR = "/usr/share/kandelo/quickshell"; /** * The compositor config. SUPER is what real Hyprland (and Omarchy) binds, but @@ -109,22 +110,23 @@ bind = CTRL SHIFT, 3, movetoworkspace, 3 * and not a list inside the launcher. */ export const OMARCHY_APPS: Record = { - "terminal.conf": "name = Terminal\nexec = /usr/local/bin/wlterm\n", "clock.conf": "name = Clock\nexec = /usr/local/bin/wlclock\n", "paint.conf": "name = Paint\nexec = /usr/local/bin/wlpaint\n", "vim.conf": "name = Vim\nexec = /usr/local/bin/wlterm /usr/bin/vim\n", "nethack.conf": "name = NetHack\nexec = /usr/local/bin/wlterm /usr/bin/nethack\n", "nano.conf": "name = Nano\nexec = /usr/local/bin/wlterm /usr/bin/nano\n", - "bash.conf": "name = Bash\nexec = /usr/local/bin/wlterm /usr/bin/bash -i\n", "foot.conf": - "name = Foot\nexec = /usr/local/bin/foot --term=vt100 --override=main.workers=0 /usr/bin/bash -i\n", + "name = Terminal\nexec = /usr/local/bin/foot --term=vt100 --override=main.workers=0 /usr/bin/bash -i\n", + "qtgallery.conf": "name = Theme Gallery\nexec = /usr/local/bin/qtgallery\n", + "quickshell.conf": + "name = Quickshell\nexec = /usr/local/bin/quickshell -p /usr/share/kandelo/quickshell/island.qml\n", }; /** - * The fontconfig configuration foot reads at startup. The demo stages one - * font (Inconsolata) under /usr/share/fonts and aliases the generic - * "monospace" family to it, so foot's default font pattern resolves without - * a per-user configuration. + * The fontconfig configuration foot and qtgallery read at startup. The demo + * stages one font (Inconsolata) under /usr/share/fonts and aliases the + * generic "monospace" and "sans-serif" families to it, so both clients' + * default font patterns resolve without a per-user configuration. */ export const OMARCHY_FONTS_CONF = ` @@ -135,6 +137,10 @@ export const OMARCHY_FONTS_CONF = ` monospace Inconsolata + + sans-serif + Inconsolata + `; @@ -172,6 +178,102 @@ text-color=#c0caf5 border-color=#7aa2f7 `; +/** + * The staged Quickshell shells, one QML file each: the shell IS a QML file, + * so swapping the file swaps the shell — the demonstration Quickshell exists + * for, and what qtgallery's shell cards do. island.qml is the default the + * launcher entry runs. All three render through QtQuick's software scenegraph + * (QT_QUICK_BACKEND=software comes from the compositor's environment) and + * anchor to the bottom edge because Waybar owns the top. The palette is the + * tokyo-night the desktop boots with; Quickshell reads QML once at startup, + * so like mako it keeps its colours across theme switches. + * + * The tab's per-worker compiled-code budget bounds these shells (see + * docs/browser-support.md#quickshell-qml-limits): each guest thread holds + * its own compiled copy of the Qt module, and the QML content decides how + * much cold Qt/ICU code each worker compiles. Single-threading the Qt + * client roughly halved the baseline, but a shell that touches heavy code + * paths still crosses the ceiling. Each shell here stays in the tested + * envelope — one item, numeric date fields (no localized dddd/MMMM name + * fields), regular weight — which compiles little beyond the base client. + */ +export const OMARCHY_QUICKSHELL_SHELLS: Record = { + "shell.qml": `import Quickshell +import QtQuick + +ShellRoot { + SystemClock { + id: clock + precision: SystemClock.Seconds + } + PanelWindow { + anchors { + bottom: true + left: true + right: true + } + implicitHeight: 30 + color: "#1a1b26" + Text { + anchors.centerIn: parent + color: "#c0caf5" + text: "Quickshell — " + Qt.formatDateTime(clock.date, "hh:mm:ss") + } + } +} +`, + "island.qml": `import Quickshell +import QtQuick + +ShellRoot { + SystemClock { + id: clock + precision: SystemClock.Seconds + } + PanelWindow { + anchors { + bottom: true + } + margins { + bottom: 14 + } + implicitWidth: 380 + implicitHeight: 44 + color: "#1a1b26" + Text { + anchors.centerIn: parent + color: "#7dcfff" + text: "[ island ] " + Qt.formatDateTime(clock.date, "hh:mm:ss") + } + } +} +`, + "status.qml": `import Quickshell +import QtQuick + +ShellRoot { + SystemClock { + id: clock + precision: SystemClock.Seconds + } + PanelWindow { + anchors { + bottom: true + left: true + right: true + } + implicitHeight: 34 + color: "#16161e" + Text { + anchors.centerIn: parent + color: "#c0caf5" + text: "KANDELO " + Qt.formatDateTime(clock.date, "yyyy-MM-dd hh:mm:ss") + } + } +} +`, +}; + /** * Waybar config, translated from Omarchy's (config.jsonc): same top bar * with workspaces left and the clock centered. The module lineup keeps diff --git a/apps/browser-demos/pages/kandelo/presets.ts b/apps/browser-demos/pages/kandelo/presets.ts index dcd0a9b612..3549d18e01 100644 --- a/apps/browser-demos/pages/kandelo/presets.ts +++ b/apps/browser-demos/pages/kandelo/presets.ts @@ -164,7 +164,7 @@ export const PRESET_LIBRARY: Preset[] = [ { id: "omarchy", title: "Omarchy desktop", - summary: "The Omarchy desktop on Kandelo: the tiling compositor with the shell Omarchy is made of. Unmodified Waybar — the real GTK3 status bar, on wlr-layer-shell, its hyprland modules reading the compositor's Hyprland IPC sockets — reserves the top strip (workspaces, focused window, clock) and the windows tile beneath it. CTRL+Space opens klauncher, a keyboard-exclusive overlay launcher reading /usr/share/kandelo/apps; type to filter and Enter to launch. CTRL+SHIFT+Space cycles six themes (Tokyo Night, Catppuccin, Gruvbox, Nord, Everforest, Rosé Pine), each with its own Omarchy background image, recolouring the borders, gaps, wallpaper, bar and launcher live from one palette file. CTRL+Return spawns a terminal, CTRL+K a clock, CTRL+P a paint canvas, CTRL+W closes, CTRL+1..9 switch workspaces — every bind mirrored on SUPER for real Hyprland.", + summary: "The Omarchy desktop on Kandelo: the tiling compositor with the shell Omarchy is made of. Unmodified Waybar — the real GTK3 status bar, on wlr-layer-shell, its hyprland modules reading the compositor's Hyprland IPC sockets — reserves the top strip (workspaces, focused window, clock) and the windows tile beneath it. CTRL+Space opens klauncher, a keyboard-exclusive overlay launcher reading /usr/share/kandelo/apps; type to filter and Enter to launch — including Theme Gallery, a QtGui application whose cards preview every installed theme and restyle the whole desktop on click, or swap the running Quickshell for another QML shell. CTRL+SHIFT+Space cycles six themes (Tokyo Night, Catppuccin, Gruvbox, Nord, Everforest, Rosé Pine), each with its own Omarchy background image, recolouring the borders, gaps, wallpaper, bar and launcher live from one palette file. CTRL+Return spawns a terminal, CTRL+K a clock, CTRL+P a paint canvas, CTRL+W closes, CTRL+1..9 switch workspaces — every bind mirrored on SUPER for real Hyprland.", base: SHELL_BASE, packages: ["bash@local", "coreutils@local"], accent: "#7aa2f7", diff --git a/apps/browser-demos/source-only-vite-assets.ts b/apps/browser-demos/source-only-vite-assets.ts index 2e865b1e74..d9496281da 100644 --- a/apps/browser-demos/source-only-vite-assets.ts +++ b/apps/browser-demos/source-only-vite-assets.ts @@ -25,7 +25,7 @@ const MODULE_PREFIX = "virtual:kandelo-source-only-asset:"; const PUBLIC_PREFIX = "__kandelo_source_only_assets__"; /** Bound the complete Vite snapshot set before the consumer allocates bytes. */ -export const SOURCE_ONLY_VITE_RETAINED_MAX_BYTES = 512 * 1024 * 1024; +export const SOURCE_ONLY_VITE_RETAINED_MAX_BYTES = 1024 * 1024 * 1024; interface RetainedSourceOnlyAsset { relPath: string; diff --git a/apps/browser-demos/test/kandelo-omarchy.spec.ts b/apps/browser-demos/test/kandelo-omarchy.spec.ts index de03044883..92dc2e1431 100644 --- a/apps/browser-demos/test/kandelo-omarchy.spec.ts +++ b/apps/browser-demos/test/kandelo-omarchy.spec.ts @@ -84,7 +84,7 @@ const SETUP_FAILURE = * CTRL+Space, and switchable themes — and every piece is driven from the * keyboard. Skips (via gotoOrSkip) when the binaries aren't built. */ -test("Kandelo omarchy boots a themed tiling desktop with a bar, a launcher, and live theme switching", async ({ page }) => { +test("Kandelo omarchy boots a themed tiling desktop with a bar, a launcher, and live theme switching", async ({ page, browserName }) => { test.setTimeout(300_000); await gotoOrSkip(page, "/?demo=omarchy"); @@ -200,8 +200,9 @@ test("Kandelo omarchy boots a themed tiling desktop with a bar, a launcher, and .poll(() => syslogStream(page), { timeout: 60_000 }) .toMatch(/KLAUNCHER_READY n=8/); - // "te" narrows the eight entries (Bash, Clock, Foot, Nano, NetHack, Paint, - // Terminal, Vim) to Terminal alone — "t" alone still matches Paint. + // "te" narrows the eight entries (Clock, Nano, NetHack, Paint, Quickshell, + // Terminal, Theme Gallery, Vim) to Terminal alone — "t" alone still + // matches Paint. await openSurface(page, "Demo"); await page.locator("body").click({ position: { x: 5, y: 5 } }); await page.keyboard.press("KeyT"); @@ -212,23 +213,36 @@ test("Kandelo omarchy boots a themed tiling desktop with a bar, a launcher, and .toMatch(/KLAUNCHER_FILTER q=te n=1/); // Enter launches the one match (Terminal) through the compositor's kwlctl - // socket and dismisses the launcher. The desktop went in with three tiled - // windows, so the launched terminal shows up as a fourth tile — the - // connection count alone would not prove it, since the launcher's own - // session ends at the same moment and frees its slot. + // socket and dismisses the launcher. The entry runs an unmodified upstream + // client, stock foot 1.17.2 — wl_display_connect via XDG_RUNTIME_DIR, + // fontconfig resolving "monospace" through the staged fonts.conf, fcft + // rasterizing the staged Inconsolata. The desktop went in with three tiled + // windows, so foot shows up as a fourth tile — the connection count alone + // would not prove it, since the launcher's own session ends at the same + // moment and frees its slot. await openSurface(page, "Demo"); await page.locator("body").click({ position: { x: 5, y: 5 } }); await page.keyboard.press("Enter"); await openSurface(page, "Internals"); await expect .poll(() => syslogStream(page), { timeout: 60_000 }) - .toMatch(/KLAUNCHER_EXEC cmd=\/usr\/local\/bin\/wlterm/); + .toMatch(/KLAUNCHER_EXEC cmd=\/usr\/local\/bin\/foot /); await expect .poll(() => syslogStream(page), { timeout: 60_000 }) .toMatch(/KLAUNCHER_EXIT/); await expect - .poll(() => syslogStream(page), { timeout: 60_000 }) + .poll(() => syslogStream(page), { timeout: 120_000 }) .toMatch(/TILE n=4 i=3 /); + expect(await syslogText(page), "foot binary does not match the kernel ABI") + .not.toMatch(/ABI version mismatch/); + // GLDRAW is the compositor's proof that it drew this window's texture. + // Every marker above is protocol — map, focus, tile — and all of them + // fire for a window whose wl_shm pool the GPU cannot import (a memfd + // pool instead of a gbm prime fd). foot carries the gbm-pool patch that + // keeps its pools importable; this is the gate that notices if it stops. + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/GLDRAW app_id=foot/); // Gate 5b: a real application through the same path. "vi" narrows to Vim; // its entry runs unmodified vim inside a wlterm, fetched lazily from @@ -249,25 +263,69 @@ test("Kandelo omarchy boots a themed tiling desktop with a bar, a launcher, and expect(await syslogText(page), "vim binary does not match the kernel ABI") .not.toMatch(/ABI version mismatch/); - // Gate 5c: an unmodified upstream client through the same path. "fo" - // narrows to Foot; its entry runs stock foot 1.17.2 — wl_display_connect - // via XDG_RUNTIME_DIR, fontconfig resolving "monospace" through the staged - // fonts.conf, fcft rasterizing the staged Inconsolata — and the sixth tile - // only appears once foot maps its first frame through all of it. - const beforeFoot = await launcherSessions(page); + // Gate 5d: a Qt application through the same path. "ga" narrows to Theme + // Gallery; its entry runs qtgallery — QtGui's wayland QPA plugin connecting + // via XDG_RUNTIME_DIR, xdg-shell configure, the raster backing store + // through wl_shm, fontconfig resolving "sans-serif" through the staged + // fonts.conf — and the sixth tile only appears once Qt maps its first + // frame. The gallery reads the same six themes the compositor scanned. + const beforeQt = await launcherSessions(page); await pressCtrl(page, "Space"); await expect .poll(() => launcherSessions(page), { timeout: 60_000 }) - .toBeGreaterThan(beforeFoot); - await pressKeys(page, ["KeyF", "KeyO", "Enter"]); + .toBeGreaterThan(beforeQt); + await pressKeys(page, ["KeyG", "KeyA", "Enter"]); await expect .poll(() => syslogStream(page), { timeout: 60_000 }) - .toMatch(/KLAUNCHER_EXEC cmd=\/usr\/local\/bin\/foot /); + .toMatch(/KLAUNCHER_EXEC cmd=\/usr\/local\/bin\/qtgallery/); + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/GALLERY_PLATFORM=wayland/); + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/GALLERY_THEMES n=6/); await expect .poll(() => syslogStream(page), { timeout: 120_000 }) .toMatch(/TILE n=6 i=5 /); - expect(await syslogText(page), "foot binary does not match the kernel ABI") + expect(await syslogText(page), "qtgallery binary does not match the kernel ABI") .not.toMatch(/ABI version mismatch/); + // The invisible-window gate. Qt's stock backing store allocates memfd + // pools; the GL renderer cannot import those, skips the surface, and + // every gate above still passes — that is exactly how the invisible + // first Qt window shipped. Qt now carries the same gbm-pool patch as + // foot and GTK, and GLDRAW only fires once the window's texture was drawn. + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/GLDRAW app_id=qtgallery/); + // The card→dispatch→theme-switch loop is proven authoritatively by the Node + // smoke (host/test/qtgallery-smoke.test.ts injects a real pointer click on a + // card and reads the compositor's THEME line back). This browser gate proves + // the browser-specific half: Qt maps and its texture reaches the GL renderer. + + // Gate 5e: a QtQuick application through the same path. "qu" narrows to + // Quickshell; its entry runs quickshell with the staged island.qml. The QML + // engine loads, the scenegraph renders through the software adaptation + // (QT_QUICK_BACKEND=software from the compositor's environ), and the + // PanelWindow maps as a wlr-layer-shell surface under Quickshell's default + // namespace — layer surfaces never emit GLDRAW, so the LAYER line is the + // mapping proof. Firefox is excluded: the running desktop's wasm code plus + // Quickshell's main and pthread modules exceeds SpiderMonkey's fixed 2 GiB + // per-process executable-code arena, so Quickshell's first QThread::start + // fails — see docs/browser-support.md#firefox-executable-code-limit. + if (browserName !== "firefox") { + const beforeQs = await launcherSessions(page); + await pressCtrl(page, "Space"); + await expect + .poll(() => launcherSessions(page), { timeout: 60_000 }) + .toBeGreaterThan(beforeQs); + await pressKeys(page, ["KeyQ", "KeyU", "Enter"]); + await expect + .poll(() => syslogStream(page), { timeout: 60_000 }) + .toMatch(/KLAUNCHER_EXEC cmd=\/usr\/local\/bin\/quickshell/); + await expect + .poll(() => syslogStream(page), { timeout: 120_000 }) + .toMatch(/LAYER ns=quickshell /); + } // Gate 6: CTRL+SHIFT+Space cycles the theme. One palette file repaints the // whole desktop — the compositor's borders, gaps and wallpaper, and the diff --git a/crates/fork-instrument/src/instrument.rs b/crates/fork-instrument/src/instrument.rs index d294c9501d..4ef709e8e0 100644 --- a/crates/fork-instrument/src/instrument.rs +++ b/crates/fork-instrument/src/instrument.rs @@ -953,6 +953,26 @@ struct CatchStateLocals { catch_selector: LocalId, } +/// From this many fork-path call sites on, a function routes every caught +/// unwind edge through one shared handler instead of a per-site boundary. +/// Below the threshold, per-site boundaries keep the zero-extra-local shape +/// that preserves recursion depth (PR #713); at or above it, one selector +/// local replaces roughly fifteen boundary instructions per call site — +/// the term that pushed call-heavy Qt functions past what TurboFan's +/// register allocator can compile. +const SHARED_UNWIND_BOUNDARY_MIN_CALLS: usize = 8; + +#[derive(Debug, Clone, Copy)] +struct SharedUnwindBoundary { + /// Written with the static call index immediately before every routed + /// call; read once by the shared handler after a caught unwind edge. + selector: LocalId, + /// The block wrapping the whole dispatch cascade. Every per-site + /// `try_table` catches the private unwind tag to this label, landing on + /// the shared frame-select handler emitted right after the block. + catch_target: InstrSeqId, +} + #[derive(Debug, Clone, Copy)] struct AbortDispatch { /// Partial allocation failure branches back to the dispatch loop after @@ -966,6 +986,10 @@ struct AbortDispatch { /// Cold module helper which reserves/selects the frame and writes the /// statically supplied call index, returning one on reservation success. frame_select: FunctionId, + /// Present when the function uses the shared unwind boundary; per-site + /// emitters then set the selector and catch to the shared label instead + /// of emitting their own boundary blocks and handler. + shared: Option, } #[allow(clippy::too_many_arguments)] @@ -1139,6 +1163,8 @@ fn instrument_one_function_switch( assert_reference_call_alignment(reference_analysis, &call_sites); // Allocate per-function synthetic locals. + let shared_selector = + (n_calls >= SHARED_UNWIND_BOUNDARY_MIN_CALLS).then(|| module.locals.add(ValType::I32)); let catch_state_locals = if catch_plan.is_empty() && plain_catches.is_empty() { None } else { @@ -1158,16 +1184,11 @@ fn instrument_one_function_switch( plan_call_arg_materialization(module, &chunks[site_idx], arg_types) }) .collect(); - let arg_materializations: Vec = pending_arg_materializations - .into_iter() - .map(|pending| allocate_call_arg_materialization(module, pending)) - .collect(); - for (site_idx, materialization) in arg_materializations.iter().enumerate() { - truncate_materialized_tail(&mut chunks[site_idx], materialization.tail_len()); - } // Sub-commit 2.4c: per-call operand-stack carryovers (computed - // pre-take, see above). Allocate spill locals for each. + // pre-take, see above). Spill locals come from the shared pool, + // with each site's args and carryovers drawn from one begin_site + // window so they never collide within the site. // Length mismatch or None falls back to per-call empty carryovers // — matches pre-2.4c switch-dispatch behavior for the no-carryover // case. The dispatch decision in `instrument_one_function` only @@ -1177,21 +1198,49 @@ fn instrument_one_function_switch( Some(v) if v.len() == n_calls => v, _ => vec![Vec::new(); n_calls], }; + let mut spill_pool = SpillLocalPool::default(); + let mut arg_materializations: Vec = Vec::with_capacity(n_calls); let mut carryover_spills: Vec> = Vec::with_capacity(n_calls); - for site_carryovers in &carryover_types { + for (pending, site_carryovers) in pending_arg_materializations + .into_iter() + .zip(&carryover_types) + { + spill_pool.begin_site(); + arg_materializations.push(allocate_call_arg_materialization( + module, + &mut spill_pool, + pending, + )); let spills: Vec = site_carryovers .iter() - .map(|&ty| (module.locals.add(spill_storage_type(ty)), ty)) + .map(|&ty| (spill_pool.take(module, ty), ty)) .collect(); carryover_spills.push(spills); } + for (site_idx, materialization) in arg_materializations.iter().enumerate() { + truncate_materialized_tail(&mut chunks[site_idx], materialization.tail_len()); + } let plain_catch_state = allocate_plain_catch_state(module, plain_catches); - // Combined scalar locals for the frame (user locals first, then + // Combined scalar locals for the frame (live user locals first, then // frame-backed per-call arg spills in call order, then per-call // carryover spills in call order — added 2.4c). - let mut frame_scalars: Vec<(LocalId, ValType)> = user_scalar_locals.clone(); + let mut pure_replay_locals = HashSet::new(); + for materialization in &arg_materializations { + let CallArgMaterialization::PureTail { tail, .. } = materialization else { + continue; + }; + pure_tail_scalar_locals(module, tail, &mut pure_replay_locals); + } + let saved_scalars = + plan_saved_scalar_locals(module, func_id, reference_analysis, pure_replay_locals); + let mut frame_scalars: Vec<(LocalId, ValType)> = user_scalar_locals + .iter() + .copied() + .filter(|(lid, _)| saved_scalars.contains(lid)) + .collect(); + let mut seen_spills: HashSet = HashSet::new(); for (site_idx, cs) in call_sites.iter().enumerate() { let arg_types = call_arg_types(module, cs); for (&lid, &ty) in arg_materializations[site_idx] @@ -1199,14 +1248,14 @@ fn instrument_one_function_switch( .iter() .zip(arg_types.iter()) { - if is_scalar(ty) { + if is_scalar(ty) && seen_spills.insert(lid) { frame_scalars.push((lid, ty)); } } } for spills in &carryover_spills { for &(lid, ty) in spills { - if is_scalar(ty) { + if is_scalar(ty) && seen_spills.insert(lid) { frame_scalars.push((lid, ty)); } } @@ -1284,9 +1333,17 @@ fn instrument_one_function_switch( .dangling_instr_seq(InstrSeqType::Simple(None)) .id(); let restart_loop = local.builder_mut().dangling_instr_seq(restart_loop_ty).id(); + let shared_boundary = shared_selector.map(|selector| SharedUnwindBoundary { + selector, + catch_target: local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(), + }); let abort = AbortDispatch { restart_loop, frame_select: unwind_frame_select, + shared: shared_boundary, }; let catch_scalar_restore_dispatch = catch_state_locals.and_then(|catch_state| { build_plain_catch_scalar_dispatch( @@ -1322,8 +1379,10 @@ fn instrument_one_function_switch( frame_size, ); + let cascade_seq = shared_boundary.map_or(unwind_save, |shared| shared.catch_target); populate_dispatch_structure( local, + cascade_seq, unwind_save, &post_seqs, &chunks, @@ -1338,6 +1397,15 @@ fn instrument_one_function_switch( catch_state_locals, abort, ); + if let Some(shared) = shared_boundary { + push_instr( + &mut local.block_mut(unwind_save).instrs, + Instr::Block(Block { + seq: shared.catch_target, + }), + ); + emit_shared_unwind_handler(local, unwind_save, ptr_ty, frame_size, shared, abort); + } // Postamble lives outside $unwind_save, in the entry block, right // after the Block($unwind_save) instruction. It commits this @@ -2589,15 +2657,48 @@ fn plan_call_arg_materialization( } } +/// One pool of spill locals shared by every fork-path call landing in a +/// function. A landing's arg/carryover spills are written at its own chunk +/// tail and read back at its own post-call landing; the window in between +/// only executes the callee, so two landings' spill windows never overlap +/// inside one activation. The pool therefore hands the same locals to every +/// landing, keyed by storage type and per-landing ordinal. This bounds the +/// instrument-added locals — and their frame bytes and save/restore code — +/// by the widest single landing instead of the landing count. +#[derive(Default)] +struct SpillLocalPool { + slots: HashMap>, + site_counts: HashMap, +} + +impl SpillLocalPool { + fn begin_site(&mut self) { + self.site_counts.clear(); + } + + fn take(&mut self, module: &mut Module, ty: ValType) -> LocalId { + let storage = spill_storage_type(ty); + let ordinal = self.site_counts.entry(storage).or_default(); + let slots = self.slots.entry(storage).or_default(); + if *ordinal == slots.len() { + slots.push(module.locals.add(storage)); + } + let local = slots[*ordinal]; + *ordinal += 1; + local + } +} + fn allocate_call_arg_materialization( module: &mut Module, + spill_pool: &mut SpillLocalPool, pending: PendingCallArgMaterialization, ) -> CallArgMaterialization { match pending { PendingCallArgMaterialization::Spill { arg_types } => { let locals = arg_types .iter() - .map(|&ty| module.locals.add(spill_storage_type(ty))) + .map(|&ty| spill_pool.take(module, ty)) .collect(); CallArgMaterialization::Spill { locals, @@ -3092,6 +3193,7 @@ fn populate_internal_dispatch( #[allow(clippy::too_many_arguments)] fn populate_dispatch_structure( local: &mut LocalFunction, + cascade_seq: InstrSeqId, unwind_save: InstrSeqId, post_seqs: &[InstrSeqId], chunks: &[Vec<(Instr, InstrLocId)>], @@ -3112,7 +3214,7 @@ fn populate_dispatch_structure( // followed by `return`. Should not normally happen for a // fork-path function, but keep it validator-clean. if n_calls == 0 { - let s = &mut local.block_mut(unwind_save).instrs; + let s = &mut local.block_mut(cascade_seq).instrs; for (instr, loc) in &chunks[0] { s.push((instr.clone(), *loc)); } @@ -3124,7 +3226,7 @@ fn populate_dispatch_structure( emit_dispatch_node( local, &tree, - unwind_save, + cascade_seq, unwind_save, true, post_seqs, @@ -3614,6 +3716,67 @@ fn emit_static_call_unwind_handler( } } +/// The one handler every shared-boundary catch edge lands on. Emitted into +/// `unwind_save` right after `Block(catch_target)`; the selector local holds +/// the call index the active site stored before its routed call. +fn emit_shared_unwind_handler( + local: &mut LocalFunction, + unwind_save: InstrSeqId, + ptr_ty: ValType, + frame_size: u32, + shared: SharedUnwindBoundary, + abort: AbortDispatch, +) { + let reserve_succeeded = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + let reserve_failed = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); + + { + let s = &mut local.block_mut(unwind_save).instrs; + push_instr(s, ptr_const(ptr_ty, frame_size as i64)); + push_instr( + s, + Instr::LocalGet(LocalGet { + local: shared.selector, + }), + ); + push_instr( + s, + Instr::Call(Call { + func: abort.frame_select, + }), + ); + push_instr( + s, + Instr::IfElse(IfElse { + consequent: reserve_succeeded, + alternative: reserve_failed, + }), + ); + push_instr(s, Instr::Unreachable(Unreachable {})); + } + + { + let s = &mut local.block_mut(reserve_failed).instrs; + push_instr( + s, + Instr::Br(Br { + block: abort.restart_loop, + }), + ); + } + + { + let s = &mut local.block_mut(reserve_succeeded).instrs; + push_instr(s, Instr::Br(Br { block: unwind_save })); + } +} + // ---------------------------------------------------------------------- // Preamble / postamble // ---------------------------------------------------------------------- @@ -3641,6 +3804,27 @@ fn reference_plan_runs( runs } +/// Call-index runs that share one (slots, nulls) body, in first-appearance +/// order. Emitting the body once per distinct set instead of once per run +/// keeps generated code O(distinct sets × live refs + runs) instead of +/// O(runs × live refs) — the term that inflated exception-heavy Qt functions +/// past what TurboFan's register allocator can compile. +fn reference_plan_groups( + plan: &ReferenceFramePlan, +) -> Vec<(Vec<(usize, usize)>, Vec, Vec<(LocalId, RefType)>)> { + let mut groups: Vec<(Vec<(usize, usize)>, Vec, Vec<(LocalId, RefType)>)> = Vec::new(); + for (first, last, slots, nulls) in reference_plan_runs(plan) { + let existing = groups + .iter_mut() + .find(|(_, group_slots, group_nulls)| *group_slots == slots && *group_nulls == nulls); + match existing { + Some((ranges, _, _)) => ranges.push((first, last)), + None => groups.push((vec![(first, last)], slots, nulls)), + } + } + groups +} + fn push_call_index_in_range( out: &mut Vec<(Instr, InstrLocId)>, runtime: &Runtime, @@ -3692,6 +3876,26 @@ fn push_call_index_in_range( ); } +fn push_call_index_in_ranges( + out: &mut Vec<(Instr, InstrLocId)>, + runtime: &Runtime, + memory: MemoryId, + ptr_ty: ValType, + ranges: &[(usize, usize)], +) { + for (position, &(first, last)) in ranges.iter().enumerate() { + push_call_index_in_range(out, runtime, memory, ptr_ty, first, last); + if position > 0 { + push_instr( + out, + Instr::Binop(Binop { + op: BinaryOp::I32Or, + }), + ); + } + } +} + #[allow(clippy::too_many_arguments)] fn emit_reference_restore_dispatch( local: &mut LocalFunction, @@ -3707,7 +3911,7 @@ fn emit_reference_restore_dispatch( let vector_get = runtime .reference_vector_get .expect("linked fork reference plan requires recipe-vector lookup"); - for (first, last, slots, nulls) in reference_plan_runs(plan) { + for (ranges, slots, nulls) in reference_plan_groups(plan) { let then_seq = local .builder_mut() .dangling_instr_seq(InstrSeqType::Simple(None)) @@ -3745,7 +3949,7 @@ fn emit_reference_restore_dispatch( } } let out = &mut local.block_mut(seq).instrs; - push_call_index_in_range(out, runtime, memory, ptr_ty, first, last); + push_call_index_in_ranges(out, runtime, memory, ptr_ty, &ranges); push_instr( out, Instr::IfElse(IfElse { @@ -3783,7 +3987,7 @@ fn build_reference_save_dispatch( .builder_mut() .dangling_instr_seq(InstrSeqType::Simple(None)) .id(); - for (first, last, slots, _nulls) in reference_plan_runs(plan) { + for (ranges, slots, _nulls) in reference_plan_groups(plan) { if slots.is_empty() { continue; } @@ -3841,7 +4045,7 @@ fn build_reference_save_dispatch( push_instr(out, store_i32(memory, REFERENCE_VECTOR_OFFSET)); } let out = &mut local.block_mut(root).instrs; - push_call_index_in_range(out, runtime, memory, ptr_ty, first, last); + push_call_index_in_ranges(out, runtime, memory, ptr_ty, &ranges); push_instr( out, Instr::IfElse(IfElse { @@ -4152,33 +4356,35 @@ fn emit_replay_routed_call( arguments: &CallArgMaterialization, runtime: &Runtime, ) { - let branch_ty = InstrSeqType::MultiValue(resume_ty); - let normal = local.builder_mut().dangling_instr_seq(branch_ty).id(); - let replay = local.builder_mut().dangling_instr_seq(branch_ty).id(); - populate_lexical_call(local, normal, target, sig_ty, location, arguments); if direct_activation { // WHY: adding a no-argument resume thunk in front of every ordinary // recursive activation doubles native rewind depth. A materialized // direct callee already owns the selected event; its preamble // validates activation/function identity through frame_next before - // consuming it. Tail-transparent, indirect, and reference calls still - // require the process router because their lexical target need not be - // the next materialized activation. + // consuming it. NORMAL and replay reissue the identical materialized + // call, so one copy serves both states without a dispatch branch. + // Tail-transparent, indirect, and reference calls still require the + // process router because their lexical target need not be the next + // materialized activation. debug_assert!(matches!(target, CallTarget::Direct(_))); - populate_lexical_call(local, replay, target, sig_ty, location, arguments); - } else { - emit_resume_selected_call( - local, - replay, - target, - sig_ty, - resume_ty, - location, - arguments, - runtime, - sig_ty.index() as i32, - ); + populate_lexical_call(local, sequence, target, sig_ty, location, arguments); + return; } + let branch_ty = InstrSeqType::MultiValue(resume_ty); + let normal = local.builder_mut().dangling_instr_seq(branch_ty).id(); + let replay = local.builder_mut().dangling_instr_seq(branch_ty).id(); + populate_lexical_call(local, normal, target, sig_ty, location, arguments); + emit_resume_selected_call( + local, + replay, + target, + sig_ty, + resume_ty, + location, + arguments, + runtime, + sig_ty.index() as i32, + ); let out = &mut local.block_mut(sequence).instrs; push_instr( out, @@ -4232,11 +4438,6 @@ fn emit_replay_routed_call_with_unwind_boundary( abort: AbortDispatch, ) { let result_ty = InstrSeqType::MultiValue(resume_ty); - let result_boundary = local.builder_mut().dangling_instr_seq(result_ty).id(); - let catch_boundary = local - .builder_mut() - .dangling_instr_seq(InstrSeqType::Simple(None)) - .id(); let call_body = local.builder_mut().dangling_instr_seq(result_ty).id(); emit_replay_routed_call( @@ -4250,6 +4451,39 @@ fn emit_replay_routed_call_with_unwind_boundary( arguments, runtime, ); + if let Some(shared) = abort.shared { + let out = &mut local.block_mut(sequence).instrs; + push_instr( + out, + Instr::Const(Const { + value: Value::I32(call_idx as i32), + }), + ); + push_instr( + out, + Instr::LocalSet(LocalSet { + local: shared.selector, + }), + ); + push_instr( + out, + Instr::TryTable(TryTable { + seq: call_body, + catches: vec![TryTableCatch::Catch { + tag: runtime + .unwind_tag + .expect("fork call boundary requires private unwind tag"), + label: shared.catch_target, + }], + }), + ); + return; + } + let result_boundary = local.builder_mut().dangling_instr_seq(result_ty).id(); + let catch_boundary = local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(); { let out = &mut local.block_mut(catch_boundary).instrs; push_instr( @@ -4652,6 +4886,46 @@ fn collect_user_locals(module: &Module, func_id: FunctionId) -> Vec<(LocalId, Va .collect() } +/// Collect the scalar locals a pure-replay tail reads. Replay runs after the +/// preamble's frame restore, so every scalar `local.get` in a tail must stay +/// frame-owned even when liveness at the landing would drop it. +fn pure_tail_scalar_locals( + module: &Module, + tail: &[(Instr, InstrLocId)], + saved: &mut HashSet, +) { + for (instr, _) in tail { + let Instr::LocalGet(LocalGet { local }) = instr else { + continue; + }; + if !matches!(module.locals.get(*local).ty(), ValType::Ref(_)) { + saved.insert(*local); + } + } +} + +/// The scalar user locals that must round-trip through the fork frame: +/// locals live on any successor of a fork landing, scalar inputs of +/// pure-replay tails, and every parameter (the resume thunk reads resumed +/// parameters by frame offset). Everything else is dead at every resume +/// point and needs no save/restore code or frame bytes. +fn plan_saved_scalar_locals( + module: &Module, + func_id: FunctionId, + reference_analysis: &FunctionReferenceAnalysis, + pure_replay_locals: HashSet, +) -> HashSet { + let mut saved = pure_replay_locals; + for site in &reference_analysis.call_sites { + saved.extend(site.live_scalar_locals_on_any_successor.iter().copied()); + } + let FunctionKind::Local(local) = &module.funcs.get(func_id).kind else { + return saved; + }; + saved.extend(local.args.iter().copied()); + saved +} + fn append_resume_parameter_references( module: &Module, func_id: FunctionId, @@ -7373,26 +7647,15 @@ fn instrument_one_function_nested_switch( } } } - let mut arg_materializations: HashMap = HashMap::new(); - for site in &sites { - let pending = pending_arg_materializations - .remove(&site.call_idx) - .unwrap_or_else(|| PendingCallArgMaterialization::Spill { - arg_types: nested_call_arg_types(module, site), - }); - arg_materializations.insert( - site.call_idx, - allocate_call_arg_materialization(module, pending), - ); - } - // Sub-commit 2.5b: per-call operand-stack carryovers at direct // fork-path call landings inside any fork-bearing seq. Mirrors // 2.4c's carryover_spills wiring at top-level switch-dispatch: // at each call site, after popping the args, also pop the // carryover values into per-call carryover spill locals. They // round-trip through the fork frame so REWIND can reload them - // beneath the call's result. + // beneath the call's result. Spill locals come from the shared + // pool, with each site's args and carryovers drawn from one + // begin_site window so they never collide within the site. // // Classification already proved the exact typed stack model. Do not turn // a later analyzer disagreement into empty carryovers: that would silently @@ -7401,26 +7664,40 @@ fn instrument_one_function_nested_switch( compute_nested_carryover_types(module, func_id, fork_path).unwrap_or_else(|| { panic!("typed nested carryover analysis changed after classification") }); + let mut spill_pool = SpillLocalPool::default(); + let mut arg_materializations: HashMap = HashMap::new(); let mut carryover_spills: HashMap> = HashMap::new(); for site in &sites { + spill_pool.begin_site(); + let pending = pending_arg_materializations + .remove(&site.call_idx) + .unwrap_or_else(|| PendingCallArgMaterialization::Spill { + arg_types: nested_call_arg_types(module, site), + }); + arg_materializations.insert( + site.call_idx, + allocate_call_arg_materialization(module, &mut spill_pool, pending), + ); let cr_types: &[ValType] = nested_carryover_types .get(&site.call_idx) .map(Vec::as_slice) .unwrap_or_else(|| panic!("typed carryover plan omitted call {}", site.call_idx)); let spills: Vec = cr_types .iter() - .map(|&ty| (module.locals.add(spill_storage_type(ty)), ty)) + .map(|&ty| (spill_pool.take(module, ty), ty)) .collect(); carryover_spills.insert(site.call_idx, spills); } let plain_catch_state = allocate_plain_catch_state(module, plain_catches); - // Combined scalar locals for the function frame: existing user - // scalars + frame-backed per-call arg-spill locals (in call_idx - // order) + per-call carryover-spill locals (in call_idx order; - // sub-commit 2.5b). - let mut frame_scalars: Vec<(LocalId, ValType)> = user_scalar_locals.clone(); + // Frame-backed spill locals: per-call arg spills (in call_idx order), + // then per-call carryover spills (in call_idx order; sub-commit 2.5b), + // then SubRegion carryover spills (appended during plan building + // below). Live user scalars are prepended once every pure-replay plan + // exists, so the liveness filter can keep their replay inputs. + let mut spill_scalars: Vec<(LocalId, ValType)> = Vec::new(); + let mut seen_spills: HashSet = HashSet::new(); for site in &sites { let arg_types = nested_call_arg_types(module, site); for (&lid, &ty) in arg_materializations[&site.call_idx] @@ -7428,15 +7705,15 @@ fn instrument_one_function_nested_switch( .iter() .zip(arg_types.iter()) { - if is_scalar(ty) { - frame_scalars.push((lid, ty)); + if is_scalar(ty) && seen_spills.insert(lid) { + spill_scalars.push((lid, ty)); } } } for site in &sites { for &(lid, ty) in &carryover_spills[&site.call_idx] { - if is_scalar(ty) { - frame_scalars.push((lid, ty)); + if is_scalar(ty) && seen_spills.insert(lid) { + spill_scalars.push((lid, ty)); } } } @@ -7452,10 +7729,12 @@ fn instrument_one_function_nested_switch( // (preserve original cond while computing force_flag and // is_rewind without touching the operand stack). let cond_swap_local = module.locals.add(ValType::I32); + let shared_selector = + (n_calls >= SHARED_UNWIND_BOUNDARY_MIN_CALLS).then(|| module.locals.add(ValType::I32)); // Pre-pass: walk each fork-bearing seq, identify its // SubRegion-with-1-i32-carryover landings, and pre-allocate spill // locals (+ tmp_result_local for blocks producing 1 i32). The - // locals are added to `frame_scalars` so they round-trip through + // locals are added to `spill_scalars` so they round-trip through // the fork frame. They are stored by (seq_id, landing_index) and // attached to landings during partition. // @@ -7540,7 +7819,7 @@ fn instrument_one_function_nested_switch( for &ty in &types { let lid = module.locals.add(spill_storage_type(ty)); if is_scalar(ty) { - frame_scalars.push((lid, ty)); + spill_scalars.push((lid, ty)); } spill_locals.push((lid, ty)); } @@ -7601,6 +7880,29 @@ fn instrument_one_function_nested_switch( } } + let mut pure_replay_locals = HashSet::new(); + for site in &sites { + let CallArgMaterialization::PureTail { tail, .. } = &arg_materializations[&site.call_idx] + else { + continue; + }; + pure_tail_scalar_locals(module, tail, &mut pure_replay_locals); + } + for plan in carryover_plans.values() { + let CarryoverPlan::PureTail { tail, .. } = plan else { + continue; + }; + pure_tail_scalar_locals(module, tail, &mut pure_replay_locals); + } + let saved_scalars = + plan_saved_scalar_locals(module, func_id, reference_analysis, pure_replay_locals); + let mut frame_scalars: Vec<(LocalId, ValType)> = user_scalar_locals + .iter() + .copied() + .filter(|(lid, _)| saved_scalars.contains(lid)) + .collect(); + frame_scalars.extend(spill_scalars); + let locals_with_offsets = assign_local_offsets(&frame_scalars, LOCALS_START_OFFSET); let ordinary_scalar_end = HEADER_SIZE + user_locals_size(&frame_scalars); let catch_scalar_frame = plan_plain_catch_scalar_frame(&plain_catch_state, ordinary_scalar_end); @@ -7695,9 +7997,17 @@ fn instrument_one_function_nested_switch( .dangling_instr_seq(InstrSeqType::Simple(None)) .id(); let restart_loop = local.builder_mut().dangling_instr_seq(restart_loop_ty).id(); + let shared_boundary = shared_selector.map(|selector| SharedUnwindBoundary { + selector, + catch_target: local + .builder_mut() + .dangling_instr_seq(InstrSeqType::Simple(None)) + .id(), + }); let abort = AbortDispatch { restart_loop, frame_select: unwind_frame_select, + shared: shared_boundary, }; let catch_scalar_restore_dispatch = catch_state_locals.and_then(|catch_state| { build_plain_catch_scalar_dispatch( @@ -7852,7 +8162,16 @@ fn instrument_one_function_nested_switch( // unwind_save here, then install the wrapper structure in entry. let entry_body: Vec<(Instr, InstrLocId)> = std::mem::take(&mut local.block_mut(entry_id).instrs); - { + if let Some(shared) = shared_boundary { + local.block_mut(shared.catch_target).instrs.extend(entry_body); + push_instr( + &mut local.block_mut(unwind_save).instrs, + Instr::Block(Block { + seq: shared.catch_target, + }), + ); + emit_shared_unwind_handler(local, unwind_save, ptr_ty, frame_size, shared, abort); + } else { let s = &mut local.block_mut(unwind_save).instrs; s.extend(entry_body); } diff --git a/crates/fork-instrument/src/reference_analysis.rs b/crates/fork-instrument/src/reference_analysis.rs index 9d65f4d143..c622af00e3 100644 --- a/crates/fork-instrument/src/reference_analysis.rs +++ b/crates/fork-instrument/src/reference_analysis.rs @@ -9,7 +9,7 @@ //! //! * stable, depth-first call-site identities; //! * a structured control-flow graph, including exception edges; -//! * backward reference-local liveness; and +//! * backward reference-local and scalar-local liveness; and //! * a conservative forward definitely-null analysis. //! //! `MaybeNonNull` intentionally includes both non-null values and values whose @@ -123,6 +123,10 @@ pub struct ReferenceCallSite { /// separate makes exceptional CFG coverage testable without making a /// throwing-only cleanup value look live on deterministic fork replay. pub live_ref_locals_on_any_successor: BTreeSet, + /// Scalar locals needed on any normal or exceptional successor. The + /// frame planner saves only these (plus parameters and pure-replay + /// inputs) instead of every scalar local the function mentions. + pub live_scalar_locals_on_any_successor: BTreeSet, pub local_nullability_before_call: BTreeMap, pub reachable: bool, } @@ -151,8 +155,11 @@ pub fn analyze_function_references( }; let reference_locals = collect_reference_locals(module, local); + let scalar_locals = collect_scalar_locals(module, local); let mut cfg = StructuredCfg::build(module, local, fork_path_targets)?; - let (live_in, live_out) = compute_reference_liveness(&cfg, &reference_locals); + let tracked_references: BTreeSet = reference_locals.keys().copied().collect(); + let reference_liveness = compute_local_liveness(&cfg, &tracked_references); + let scalar_liveness = compute_local_liveness(&cfg, &scalar_locals); let nullability = compute_nullability(module, local, &cfg, &reference_locals); annotate_stack_carryovers(module, local, &mut cfg.calls); @@ -180,7 +187,7 @@ pub fn analyze_function_references( let normal_live = call .normal_successor - .map(|node| live_in[node].clone()) + .map(|node| reference_liveness.live_in_set(node)) .unwrap_or_default(); let state = nullability[call.node].clone(); call_sites.push(ReferenceCallSite { @@ -193,7 +200,8 @@ pub fn analyze_function_references( reference_carryovers: call.reference_carryovers, carryover_precision: call.carryover_precision, live_ref_locals_on_normal_return: normal_live, - live_ref_locals_on_any_successor: live_out[call.node].clone(), + live_ref_locals_on_any_successor: reference_liveness.live_out_set(call.node), + live_scalar_locals_on_any_successor: scalar_liveness.live_out_set(call.node), local_nullability_before_call: state.clone().unwrap_or_default(), reachable: state.is_some(), }); @@ -683,32 +691,115 @@ fn collect_reference_locals(module: &Module, local: &LocalFunction) -> BTreeMap< .collect() } -fn compute_reference_liveness( +fn collect_scalar_locals(module: &Module, local: &LocalFunction) -> BTreeSet { + struct Collector { + locals: BTreeSet, + } + + impl<'a> walrus::ir::Visitor<'a> for Collector { + fn visit_local_id(&mut self, local: &LocalId) { + self.locals.insert(*local); + } + } + + let mut collector = Collector { + locals: local.args.iter().copied().collect(), + }; + walrus::ir::dfs_in_order(&mut collector, local, local.entry_block()); + collector + .locals + .into_iter() + .filter(|local| !matches!(module.locals.get(*local).ty(), ValType::Ref(_))) + .collect() +} + +/// Per-node liveness stored as dense bitsets over the tracked locals. A +/// giant function can track thousands of scalar locals across tens of +/// thousands of CFG nodes; one `BTreeSet` per node at that scale +/// costs gigabytes, while one bit per tracked local costs megabytes. +struct LocalLiveness { + tracked: Vec, + words_per_node: usize, + live_in: Vec, + live_out: Vec, +} + +impl LocalLiveness { + fn live_in_set(&self, node: NodeId) -> BTreeSet { + self.decode(&self.live_in[node * self.words_per_node..][..self.words_per_node]) + } + + fn live_out_set(&self, node: NodeId) -> BTreeSet { + self.decode(&self.live_out[node * self.words_per_node..][..self.words_per_node]) + } + + fn decode(&self, words: &[u64]) -> BTreeSet { + let mut set = BTreeSet::new(); + for (word_index, &word) in words.iter().enumerate() { + let mut bits = word; + while bits != 0 { + let bit = bits.trailing_zeros() as usize; + set.insert(self.tracked[word_index * 64 + bit]); + bits &= bits - 1; + } + } + set + } +} + +fn compute_local_liveness( cfg: &StructuredCfg<'_>, - reference_locals: &BTreeMap, -) -> (Vec>, Vec>) { - let mut live_in = vec![BTreeSet::new(); cfg.nodes.len()]; - let mut live_out = vec![BTreeSet::new(); cfg.nodes.len()]; + tracked_locals: &BTreeSet, +) -> LocalLiveness { + let tracked: Vec = tracked_locals.iter().copied().collect(); + let bit_of: BTreeMap = tracked + .iter() + .enumerate() + .map(|(bit, &local)| (local, bit)) + .collect(); + let words_per_node = tracked.len().div_ceil(64); + let uses_and_defs: Vec<(Option, Option)> = (0..cfg.nodes.len()) + .map(|node| { + let (used, defined) = cfg.nodes[node] + .point + .map(|point| local_uses_and_defs(cfg.local, point, tracked_locals)) + .unwrap_or_default(); + ( + used.first().map(|local| bit_of[local]), + defined.map(|local| bit_of[&local]), + ) + }) + .collect(); + + let mut live_in = vec![0u64; cfg.nodes.len() * words_per_node]; + let mut live_out = vec![0u64; cfg.nodes.len() * words_per_node]; + let mut next = vec![0u64; words_per_node]; loop { let mut changed = false; for node in (0..cfg.nodes.len()).rev() { - let mut next_out = BTreeSet::new(); + next.fill(0); for &successor in &cfg.nodes[node].successors { - next_out.extend(live_in[successor].iter().copied()); + let successor_in = &live_in[successor * words_per_node..][..words_per_node]; + for (word, &successor_word) in next.iter_mut().zip(successor_in) { + *word |= successor_word; + } } - let (used, defined) = cfg.nodes[node] - .point - .map(|point| local_uses_and_defs(cfg.local, point, reference_locals)) - .unwrap_or_default(); - let mut next_in = next_out.clone(); - if let Some(defined) = defined { - next_in.remove(&defined); + let out_words = &mut live_out[node * words_per_node..][..words_per_node]; + if out_words != next.as_slice() { + out_words.copy_from_slice(&next); + changed = true; + } + let (used, defined) = uses_and_defs[node]; + if let Some(bit) = defined { + next[bit / 64] &= !(1u64 << (bit % 64)); } - next_in.extend(used); - if next_in != live_in[node] || next_out != live_out[node] { - live_in[node] = next_in; - live_out[node] = next_out; + if let Some(bit) = used { + next[bit / 64] |= 1u64 << (bit % 64); + } + let in_words = &mut live_in[node * words_per_node..][..words_per_node]; + if in_words != next.as_slice() { + in_words.copy_from_slice(&next); changed = true; } } @@ -716,23 +807,28 @@ fn compute_reference_liveness( break; } } - (live_in, live_out) + LocalLiveness { + tracked, + words_per_node, + live_in, + live_out, + } } fn local_uses_and_defs( local: &LocalFunction, point: OriginalProgramPoint, - reference_locals: &BTreeMap, + tracked_locals: &BTreeSet, ) -> (BTreeSet, Option) { let instruction = &local.block(point.sequence).instrs[point.instruction_index].0; match instruction { - Instr::LocalGet(get) if reference_locals.contains_key(&get.local) => { + Instr::LocalGet(get) if tracked_locals.contains(&get.local) => { (BTreeSet::from([get.local]), None) } - Instr::LocalSet(set) if reference_locals.contains_key(&set.local) => { + Instr::LocalSet(set) if tracked_locals.contains(&set.local) => { (BTreeSet::new(), Some(set.local)) } - Instr::LocalTee(tee) if reference_locals.contains_key(&tee.local) => { + Instr::LocalTee(tee) if tracked_locals.contains(&tee.local) => { (BTreeSet::new(), Some(tee.local)) } _ => (BTreeSet::new(), None), diff --git a/crates/fork-instrument/tests/instrument.rs b/crates/fork-instrument/tests/instrument.rs index 1b405c9433..f3a747d92e 100644 --- a/crates/fork-instrument/tests/instrument.rs +++ b/crates/fork-instrument/tests/instrument.rs @@ -1061,7 +1061,6 @@ fn call_with_pure_args_replays_tail_without_spill_locals() { // $unwind_save: // Block($POST_0), // , Call, - // GlobalGet, Const, Binop, IfElse, // Return // $POST_0: // Block($dispatch_normal), @@ -1071,7 +1070,11 @@ fn call_with_pure_args_replays_tail_without_spill_locals() { // replaying the pure tail here preserves the call arguments without // adding frame-backed arg locals. let lexical = sequences_with_direct_call(&module, "caller_with_args", "leaf"); - assert_eq!(lexical.len(), 2, "NORMAL and direct-replay lexical calls"); + assert_eq!( + lexical.len(), + 1, + "one lexical call serves both NORMAL and direct-activation replay", + ); assert!( lexical .iter() @@ -1102,7 +1105,11 @@ fn call_with_non_pure_arg_falls_back_to_spill_local() { let caller = func_by_name(&module, "caller_with_load_arg"); let unwind_save = protected_unwind_body_seq(&module, caller); let lexical = sequences_with_direct_call(&module, "caller_with_load_arg", "leaf"); - assert_eq!(lexical.len(), 2, "NORMAL and direct-replay lexical calls"); + assert_eq!( + lexical.len(), + 1, + "one lexical call serves both NORMAL and direct-activation replay", + ); assert!( lexical .iter() @@ -1132,7 +1139,11 @@ fn call_with_i64_shift_arg_replays_shift_tail() { let caller = func_by_name(&module, "caller_with_i64_shift_arg"); let unwind_save = protected_unwind_body_seq(&module, caller); let lexical = sequences_with_direct_call(&module, "caller_with_i64_shift_arg", "leaf"); - assert_eq!(lexical.len(), 2, "NORMAL and direct-replay lexical calls"); + assert_eq!( + lexical.len(), + 1, + "one lexical call serves both NORMAL and direct-activation replay", + ); assert!(lexical.iter().all(|kinds| { kinds == &vec![ diff --git a/crates/fork-instrument/tests/large_dispatcher.rs b/crates/fork-instrument/tests/large_dispatcher.rs index 82ee5add41..9245485ef8 100644 --- a/crates/fork-instrument/tests/large_dispatcher.rs +++ b/crates/fork-instrument/tests/large_dispatcher.rs @@ -238,6 +238,36 @@ fn collect_br_targets(local: &LocalFunction) -> Vec { out } +fn collect_catch_labels(local: &LocalFunction) -> Vec { + fn walk(local: &LocalFunction, seq: InstrSeqId, out: &mut Vec) { + for (instr, _) in &local.block(seq).instrs { + if let Instr::TryTable(ir::TryTable { seq, catches }) = instr { + for catch in catches { + if let ir::TryTableCatch::Catch { label, .. } = catch { + out.push(*label); + } + } + walk(local, *seq, out); + continue; + } + let children: Vec = match instr { + Instr::Block(ir::Block { seq }) | Instr::Loop(ir::Loop { seq }) => vec![*seq], + Instr::IfElse(ir::IfElse { + consequent, + alternative, + }) => vec![*consequent, *alternative], + _ => Vec::new(), + }; + for child in children { + walk(local, child, out); + } + } + } + let mut out = Vec::new(); + walk(local, local.entry_block(), &mut out); + out +} + /// Project-level structural ceiling for the old single-leaf cases. /// The exact engine failure threshold is implementation-dependent; this /// test pins the shape that downstream work had to avoid with a skip @@ -388,12 +418,13 @@ fn bucketed_depth_indirect_dispatcher_passes_v8_limit() { } } -/// Every per-call private-tag handler must target `$unwind_save` after a -/// successful reservation and the live-restart loop after synchronous +/// Above `SHARED_UNWIND_BOUNDARY_MIN_CALLS`, every per-call `try_table` +/// must catch the private unwind tag to the one shared catch block, and +/// exactly one shared handler must branch to `$unwind_save` after a +/// successful reservation and to the live-restart loop after synchronous /// allocation failure. A regression re-pointing a site at a leaf-local /// `$child_K` / `$dispatch_normal` would still validate as wasm but scramble -/// the fork frame on the next REWIND. Exact target counts pin one statically -/// indexed boundary per lexical call, with no function-wide selector handler. +/// the fork frame on the next REWIND. /// /// N=33 straddles `BUCKET_SIZE=32` to force one full leaf + one /// singleton leaf — exercises both first-leaf and last-leaf paths. @@ -433,23 +464,30 @@ fn leaf_unwind_br_targets_function_level_unwind_save() { .iter() .filter(|&&target| target == unwind_save) .count(), - n, - "{label} N={n}: each static call boundary must branch to unwind-save after commit", + 1, + "{label} N={n}: only the shared handler branches to unwind-save after commit", ); assert_eq!( targets .iter() .filter(|&&target| target == restart_loop) .count(), - n, - "{label} N={n}: each static call boundary must branch to restart on allocation failure", + 1, + "{label} N={n}: only the shared handler branches to restart on allocation failure", ); + + let catch_labels = collect_catch_labels(local); assert_eq!( - targets.len(), - 3 * n, - "{label} N={n}: expected one normal result-boundary branch, \ - one successful-unwind branch, and one abort-restart branch \ - per static call", + catch_labels.len(), + n, + "{label} N={n}: one private-tag try_table per static call", + ); + let shared_target = catch_labels[0]; + assert!( + catch_labels + .iter() + .all(|&target| target == shared_target), + "{label} N={n}: every call boundary must catch to the one shared block", ); } } diff --git a/crates/fork-instrument/tests/shared_boundary_node.rs b/crates/fork-instrument/tests/shared_boundary_node.rs new file mode 100644 index 0000000000..bc26cd7fb0 --- /dev/null +++ b/crates/fork-instrument/tests/shared_boundary_node.rs @@ -0,0 +1,273 @@ +use std::{ + fs, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use fork_instrument::{Options, instrument}; + +/// Nine direct fork-path landings put `run` above +/// `SHARED_UNWIND_BOUNDARY_MIN_CALLS`, so every landing catches the private +/// unwind tag to the one shared handler. `$mid` keeps its single landing on +/// the per-site path, so one capture crosses both boundary shapes. +const SHARED_BOUNDARY_FIXTURE: &str = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (memory (export "memory") 4) + + (func $mid (param i32) (result i32) + local.get 0 + i32.const 5 + i32.eq + if (result i32) + call $fork + else + local.get 0 + end) + + (func (export "run") (result i32) + i32.const 1 + call $mid + i32.const 2 + call $mid + i32.add + i32.const 3 + call $mid + i32.add + i32.const 4 + call $mid + i32.add + i32.const 5 + call $mid + i32.add + i32.const 6 + call $mid + i32.add + i32.const 7 + call $mid + i32.add + i32.const 8 + call $mid + i32.add + i32.const 9 + call $mid + i32.add)) +"#; + +fn run_node_fixture(test_js: &str, label: &str) { + let input = wat::parse_str(SHARED_BOUNDARY_FIXTURE).expect("parse shared-boundary fixture"); + let output = instrument(&input, &Options::default()).expect("instrument shared-boundary"); + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "kandelo-fork-shared-boundary-{label}-{}-{nonce}", + std::process::id(), + )); + fs::create_dir(&directory).expect("create shared-boundary fixture directory"); + fs::write(directory.join("fixture.wasm"), output).expect("write shared-boundary fixture"); + fs::write(directory.join("test.mjs"), test_js).expect("write Node shared-boundary test"); + + let result = Command::new("node") + .arg("--experimental-wasm-exnref") + .arg(directory.join("test.mjs")) + .output() + .expect("run Node shared-boundary test"); + let _ = fs::remove_dir_all(&directory); + assert!( + result.status.success(), + "Node shared-boundary {label} test failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr), + ); +} + +const HARNESS_PRELUDE: &str = r#" +import { readFileSync } from "node:fs"; + +const module = new WebAssembly.Module( + readFileSync(new URL("./fixture.wasm", import.meta.url)), +); +const imports = {}; +let instance; +const root = 0x10000; +let payloadCursor = 0x20000; +const committed = []; +const sizes = new Map(); +let reserveCalls = 0; +let frameNextCalls = 0; +let frameCommitCalls = 0; +let forkCalls = 0; +let failReserveAt = 0; +"#; + +const HARNESS_IMPORTS: &str = r#" +for (const descriptor of WebAssembly.Module.imports(module)) { + const namespace = imports[descriptor.module] ??= {}; + switch (descriptor.kind) { + case "function": + namespace[descriptor.name] = (...args) => hostCall(descriptor, args); + break; + case "table": + namespace[descriptor.name] = new WebAssembly.Table({ + element: descriptor.name === "__wpk_fork_ref_gc_transit" + ? "anyref" + : "anyfunc", + initial: 64, + }); + break; + case "global": + namespace[descriptor.name] = + descriptor.name === "__wpk_fork_module_state_table_generation_addr" + ? new WebAssembly.Global({ value: "i64", mutable: false }, 0n) + : new WebAssembly.Global({ value: "i32", mutable: false }, 0); + break; + case "tag": + namespace[descriptor.name] = new WebAssembly.Tag({ parameters: [] }); + break; + default: + throw new Error( + `unexpected import ${descriptor.module}.${descriptor.name} (${descriptor.kind})`, + ); + } +} + +instance = new WebAssembly.Instance(module, imports); +"#; + +const HARNESS_HOSTCALL: &str = r#" +function hostCall(descriptor, args) { + if (descriptor.name === "__wpk_fork_frame_reserve") { + reserveCalls += 1; + if (reserveCalls === failReserveAt) { + instance.exports.wpk_fork_abort_begin(root); + return 0; + } + const size = Number(args[0]); + const payload = payloadCursor; + payloadCursor += (size + 7) & ~7; + sizes.set(payload, size); + return payload; + } + if (descriptor.name === "__wpk_fork_frame_commit") { + frameCommitCalls += 1; + committed.push(args[0]); + return; + } + if (descriptor.name === "__wpk_fork_frame_next") { + frameNextCalls += 1; + const payload = committed.pop(); + if (payload === undefined) { + throw new Error("frame_next with no committed frame"); + } + if (sizes.get(payload) !== Number(args[0])) { + throw new Error( + `frame_next size mismatch: recorded ${sizes.get(payload)}, asked ${args[0]}`, + ); + } + return payload; + } + if (descriptor.name === "__wpk_fork_resume_peek") { + return 0; + } + if (descriptor.module === "kernel" && descriptor.name === "kernel_fork") { + forkCalls += 1; + const state = instance.exports.wpk_fork_state(); + if (state === 0) { + instance.exports.wpk_fork_unwind_begin(root); + return 0; + } + if (state === 2) { + instance.exports.wpk_fork_rewind_end(); + return 7; + } + if (state === 3) { + instance.exports.wpk_fork_abort_end(); + return 7; + } + throw new Error(`kernel_fork observed unexpected state ${state}`); + } + return 0; +} +"#; + +#[test] +fn shared_boundary_unwinds_and_rewinds_through_the_selected_landing() { + let test_js = format!( + r#"{HARNESS_PRELUDE} +{HARNESS_HOSTCALL} +{HARNESS_IMPORTS} + +let threw = false; +try {{ + instance.exports.run(); +}} catch (error) {{ + threw = true; +}} +if (!threw) {{ + throw new Error("capture must escape run() through the private unwind tag"); +}} +if (instance.exports.wpk_fork_state() !== 1) {{ + throw new Error("capture must leave the module UNWINDING"); +}} +if (frameCommitCalls !== 2) {{ + throw new Error(`expected mid + run frames committed, got ${{frameCommitCalls}}`); +}} +const runPayload = committed[1]; +const callIndex = new DataView(instance.exports.memory.buffer) + .getUint32(runPayload + 4, true); +if (callIndex !== 4) {{ + throw new Error(`run frame selected call index ${{callIndex}}, expected 4`); +}} + +instance.exports.wpk_fork_unwind_end(); +instance.exports.wpk_fork_rewind_begin(root); +const result = instance.exports.run(); +if (result !== 47) {{ + throw new Error(`replayed run() returned ${{result}}, expected 47`); +}} +if (instance.exports.wpk_fork_state() !== 0) {{ + throw new Error("replay did not return the module to NORMAL"); +}} +if (forkCalls !== 2 || frameNextCalls !== 2) {{ + throw new Error( + `expected two fork entries and two consumed frames; got ${{forkCalls}}/${{frameNextCalls}}`, + ); +}} +"# + ); + run_node_fixture(&test_js, "rewind"); +} + +#[test] +fn shared_boundary_abort_restarts_the_live_activation() { + let test_js = format!( + r#"{HARNESS_PRELUDE} +{HARNESS_HOSTCALL} +failReserveAt = 2; + +{HARNESS_IMPORTS} + +const result = instance.exports.run(); +if (result !== 47) {{ + throw new Error(`abort restart returned ${{result}}, expected 47`); +}} +if (instance.exports.wpk_fork_state() !== 0) {{ + throw new Error("abort restart did not return the module to NORMAL"); +}} +if (reserveCalls !== 2 || frameCommitCalls !== 1) {{ + throw new Error( + `expected one committed mid frame and one failed run reserve; got ${{reserveCalls}}/${{frameCommitCalls}}`, + ); +}} +if (frameNextCalls !== 1 || forkCalls !== 2) {{ + throw new Error( + `abort replay must consume the committed mid frame exactly once; got ${{frameNextCalls}}/${{forkCalls}}`, + ); +}} +"# + ); + run_node_fixture(&test_js, "abort"); +} diff --git a/crates/fork-instrument/tests/switch_dispatch.rs b/crates/fork-instrument/tests/switch_dispatch.rs index 3903c10ead..32b86c8f4e 100644 --- a/crates/fork-instrument/tests/switch_dispatch.rs +++ b/crates/fork-instrument/tests/switch_dispatch.rs @@ -998,6 +998,34 @@ fn catch_all_forms_use_one_retained_exception_local_and_no_frame_payload() { } } +#[test] +fn dead_scalar_local_is_not_frame_saved() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $kernel_fork (result i32))) + (memory (export "memory") 1) + (func $caller (export "dead_local") (result i32) + (local $dead i32) (local $live i32) + i32.const 7 + local.set $dead + local.get $dead + local.set $live + call $kernel_fork + drop + local.get $live)) + "#; + let input = wat::parse_str(wat).expect("wat parse"); + let output = instrument(&input, &Options::default()).expect("instrument"); + validate(&output); + let frame_sizes = frame_reserve_sizes(&output, "dead_local"); + assert!( + frame_sizes.iter().all(|size| *size == 20), + "only the scalar live at the landing joins the 16-byte header; a \ + local last read before the call must not consume frame bytes: \ + {frame_sizes:?}", + ); +} + // -- Helper predicates ---------------------------------------------- fn find_func(module: &Module, name: &str) -> FunctionId { diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 5cc6873321..189801963b 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -5493,17 +5493,13 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6], scratch_region: ChannelScr kernel_fchmodat(a1, path, channel_cstr_len!(path), a3 as u32, a4 as u32) } - // --- inotify stubs: create eventfd-like fd --- - 247 | 381 => { - // SYS_INOTIFY_INIT1 / SYS_INOTIFY_INIT - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - match syscalls::sys_inotify_init(proc) { - Ok(fd) => fd, - Err(e) => -(e as i32), - } - } - 248 => 1, // SYS_INOTIFY_ADD_WATCH: return dummy watch descriptor - 249 => 0, // SYS_INOTIFY_RM_WATCH: no-op success + // --- inotify: unimplemented. A fake-success fd never delivers + // events, which defeats the polling fallback in Qt's + // QFileSystemWatcher and glib's GFileMonitor — both only fall + // back when inotify_init fails. --- + 247 | 381 => -(Errno::ENOSYS as i32), // SYS_INOTIFY_INIT1 / SYS_INOTIFY_INIT + 248 => -(Errno::ENOSYS as i32), // SYS_INOTIFY_ADD_WATCH + 249 => -(Errno::ENOSYS as i32), // SYS_INOTIFY_RM_WATCH // --- mknod/mknodat: create regular files and FIFOs --- // S_IFIFO nodes are real named pipes (see `crate::fifo`); other node diff --git a/crates/runtime-core/src/memory.rs b/crates/runtime-core/src/memory.rs index b9b4f090e9..35a71363cf 100644 --- a/crates/runtime-core/src/memory.rs +++ b/crates/runtime-core/src/memory.rs @@ -118,7 +118,11 @@ impl MemoryManager { }; let addr = if hint != 0 && (flags & MAP_FIXED) != 0 { - // MAP_FIXED: use the exact address, removing any overlapping mappings + // MAP_FIXED: use the exact address, carving the range out of any + // overlapping mappings. A fixed map over part of a mapping splits + // it and keeps the remainder mapped, as munmap does — dropping the + // whole mapping would let a later first-fit hand out addresses the + // program still uses. // Reject if the region extends past max_addr or overlaps // host-reserved control pages. let end = hint.saturating_add(aligned_len); @@ -130,11 +134,7 @@ impl MemoryManager { { return wasm_posix_shared::mmap::MAP_FAILED; } - self.mappings.retain(|m| { - let m_end = m.addr.saturating_add(m.len); - // Keep mappings that don't overlap [hint, end) - m_end <= hint || m.addr >= end - }); + self.munmap(hint, aligned_len); hint } else { // A non-null address without MAP_FIXED is a placement hint. Wasm @@ -1337,6 +1337,26 @@ mod tests { assert_eq!(mm.mappings()[0].len, 0x10000); } + #[test] + fn test_map_fixed_inside_mapping_splits_it() { + let mut mm = MemoryManager::new(); + let rw = PROT_READ | PROT_WRITE; + let anon = MAP_PRIVATE | MAP_ANONYMOUS; + + let base = mm.mmap_anonymous(0, 0x40000, rw, anon); + assert_ne!(base, MAP_FAILED); + + let fixed = mm.mmap_anonymous(base + 0x10000, 0x10000, PROT_NONE, MAP_FIXED | anon); + assert_eq!(fixed, base + 0x10000); + + assert!(mm.is_mapped(base)); + assert!(mm.is_mapped(base + 0x20000)); + assert!(mm.is_mapped(base + 0x30000)); + + let other = mm.mmap_anonymous(0, 0x10000, rw, anon); + assert!(other >= base + 0x40000); + } + #[test] fn test_host_reserved_region_blocks_mmap_and_reuses_next_gap() { let mut mm = MemoryManager::new(); diff --git a/crates/runtime-core/src/syscalls.rs b/crates/runtime-core/src/syscalls.rs index b812bf578c..1eeca5a326 100644 --- a/crates/runtime-core/src/syscalls.rs +++ b/crates/runtime-core/src/syscalls.rs @@ -15760,16 +15760,6 @@ pub fn sys_eventfd2(proc: &mut Process, initval: u32, flags: u32) -> Result Result { - // Reuse eventfd with counter=0 to get a valid, pollable fd - sys_eventfd2(proc, 0, O_CLOEXEC | O_NONBLOCK) -} - /// epoll_create1 — create an epoll instance. /// /// Returns a file descriptor for the new epoll instance. @@ -38519,27 +38509,6 @@ mod tests { assert_eq!(u64::from_le_bytes(buf), 7); } - // ── inotify tests ──────────────────────────────────────────────────── - - #[test] - fn test_inotify_init_returns_fd() { - let mut proc = Process::new(1); - let fd = sys_inotify_init(&mut proc).unwrap(); - assert!(fd >= 3); - // The fd should be valid and closeable - let mut host = MockHostIO::new(); - assert_eq!(sys_close(&mut proc, &mut host, fd), Ok(())); - } - - #[test] - fn test_inotify_init_fd_is_cloexec() { - let mut proc = Process::new(1); - let fd = sys_inotify_init(&mut proc).unwrap(); - let entry = proc.fd_table.get(fd).unwrap(); - // inotify_init stub sets O_CLOEXEC - assert_ne!(entry.fd_flags & wasm_posix_shared::fd_flags::FD_CLOEXEC, 0); - } - // ── timerfd tests ──────────────────────────────────────────────────── #[test] diff --git a/docs/browser-support.md b/docs/browser-support.md index af16e0c195..da9b303dd7 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -325,7 +325,7 @@ Located in `apps/browser-demos/pages/`: | 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. | -| omarchy | wlcompositor (dwindle) + Waybar + mako + klauncher + themes | `kernel.boot` + spawn | The tiling desktop with its shell: unmodified Waybar on layer shell, `Ctrl+Space` launcher, `Ctrl+Shift+Space` theme cycling. Boots to wallpaper + bar with no windows open — see [Omarchy desktop demo](#omarchy-desktop-demo) below. | +| omarchy | wlcompositor (dwindle) + Waybar + mako + klauncher + qtgallery + themes | `kernel.boot` + spawn | The tiling desktop with its shell: unmodified Waybar on layer shell, `Ctrl+Space` launcher, `Ctrl+Shift+Space` theme cycling, a QtGui client in the launcher. Boots to wallpaper + bar with no windows open — see [Omarchy desktop demo](#omarchy-desktop-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. @@ -642,12 +642,28 @@ on the compositor's own process rather than on a foreground terminal. dismiss. Entries come from `/usr/share/kandelo/apps`, one file per app. The registry offers real software from the shell image alongside the demo clients: Vim, NetHack and Nano run unmodified inside a `wlterm` (their - binaries lazy-fetch from the image's archives on first launch), plus a Bash - terminal. The Foot entry is different in kind: stock upstream foot 1.17.2 - as its own Wayland client on the ported font stack — + binaries lazy-fetch from the image's archives on first launch). The + Terminal entry is different in kind: stock upstream foot 1.17.2 as its own + Wayland client on the ported font stack — freetype/fontconfig/fcft rasterizing the staged Inconsolata through `/etc/fonts/fonts.conf` — not a `wlterm` wrapper (see [architecture.md](architecture.md#stock-upstream-clients-foot--the-font-stack)). + The Theme Gallery entry is the first Qt client: a `QRasterWindow` from + the `qtgallery` package, painting one card per installed theme — each a + miniature desktop rendered from that theme's own `theme.conf` palette — + through QtGui's raster engine and the wayland QPA plugin onto `wl_shm`, + antialiased where the wpkdraw clients are not. Clicking a card (or + arrows + Enter) writes `dispatch theme ` to the compositor's + kwlctl socket, and the whole desktop restyles; a second card row swaps + the running Quickshell between the staged QML shells. It reads the same + `/etc/fonts/fonts.conf`, which aliases `sans-serif` alongside `monospace` + to the staged Inconsolata for it. `Esc` or `Q` closes it. The Quickshell + entry is the first QtQuick client: **Quickshell 0.3.1** runs the staged + `/usr/share/kandelo/quickshell/island.qml`, the QML engine renders through + the scenegraph's software adaptation (`QT_QUICK_BACKEND=software` from the + compositor's environ), and its `PanelWindow` maps as a wlr-layer-shell + clock island floating above the bottom edge — except on Firefox, see + [the executable-code limit below](#firefox-executable-code-limit). - **The menu.** `Ctrl+Alt+Space` opens the Omarchy menu — the same launcher at its root level (Apps, Theme). `Enter` descends; the Theme submenu lists the installed themes and `Enter` switches live. `Esc` in a submenu goes @@ -672,6 +688,100 @@ on the compositor's own process rather than on a foreground terminal. bind is mirrored on `SUPER` for a real Hyprland session; use `CTRL` in the browser, which reserves `SUPER` (see the caveat above). +#### Quickshell QML limits + +One host cost bounds Quickshell in the browser: compiled wasm code. +`quickshell.wasm` is ~93 MB and Chromium compiles it to hundreds of MB of +machine code — and it compiles that copy **per Web Worker**, because a +worker is a separate V8 isolate and isolates do not share a module's +compiled code even when the same `WebAssembly.Module` is posted to each. +Every guest pthread is one worker, so each thread Quickshell starts costs +another compiled copy on top of the running desktop +(compositor, Waybar, qtgallery, foot, mako, dbus-daemon). + +Two facts about this cost were established by measurement, correcting two +earlier half-explanations: + +- It is **not** a guest leak. The guest process holds a flat ~55 MB + through a 50/s repaint storm on the Node host; only the browser tab's + native memory grows. +- The **worker count multiplies** the cost, and the **QML content decides + the per-worker amount**. More workers move the whole tab up (before the + thread cuts below, 13→17 workers moved the tab from ~4 GB to its ~10 GB + ceiling). Independently, a content-heavy shell compiles more cold Qt/ICU + code in every worker: a nested-item panel with localized + `Qt.formatDateTime` name fields (`dddd`, `MMMM`) maps at ~9 GB where the + single-item numeric shell settles at ~5 GB with the same worker count. + Both the worker count and the shell content are real; neither alone is + the whole story. + +The port cuts the per-worker copies three ways: + +- **Fewer threads.** The QML type loader runs synchronously + (`FEATURE_qml_type_loader_thread=OFF` in `build-qtdeclarative.sh`, Qt's + own single-threaded wasm shape), Quickshell's logger runs on the main + thread (`packages/registry/quickshell/src/on-thread-logger-on-wasm.patch`), + and the Wayland QPA pumps the display fd from a `QSocketNotifier` on + the main loop instead of its two reader threads + (`packages/registry/qtbase/src/wayland-fd-notifier-on-wasm.patch`). + A Qt client on this port runs single-threaded. + +- **One instance across shell swaps.** The gallery's shell cards no + longer kill and respawn Quickshell — a respawn recompiles the process + module and its thread-patched twin from scratch while the old copies + await GC, which is what crashed a second or third swap. qtgallery + stages the selected QML as `/tmp/qtgallery-active.qml` once, starts one + Quickshell on it, and later cards rewrite the file in place; Quickshell's + own file watcher reloads the config inside the running process. + +- **A watcher that actually fires.** The kernel used to stub + `inotify_init` with a fake-success fd that never delivered events, + which silently disabled the file watching that the reload path needs — + Qt's `QFileSystemWatcher` (and glib's `GFileMonitor`) only fall back to + their polling engines when `inotify_init` *fails*. The stubs now return + `ENOSYS` and the pollers work everywhere. + +Together these roughly halved the tab baseline (single-item shells that +crashed at ~10 GB now settle at ~5 GB and survive), which is why the +staged shells run where they used to die. The ceiling is not gone, +though. Two cases still cross it and bound what a shell may do: + +- **Content-heavy shells.** A panel with more than one item, a localized + `Qt.formatDateTime` name field (`dddd`, `ddd`, `MMMM`, `MMM`), or + `font.bold` (synthesized, since only a regular Inconsolata face is + staged) compiles enough extra cold code per worker to reach the ceiling + on its own. The staged shells in + `apps/browser-demos/pages/kandelo/kernel-host/omarchy-desktop.ts` stay + in the tested envelope: one item, numeric date fields, regular weight. + +- **The reload transient.** A live config reload briefly holds two engine + generations, and its spike still crosses the ceiling on some swaps. The + single-instance path removed the far larger respawn recompile, but the + reload spike itself is not yet bounded. + +Both remain open; the fix belongs in the Qt/Quickshell layer, below this +demo. + +#### Firefox executable-code limit + +SpiderMonkey reserves one fixed 2 GiB region per content process for all +JIT and wasm compiled code (`MaxCodeBytesPerProcess`), shared by every +worker on the page. Compiled wasm code is several times the module's size: +the 93 MB `quickshell.wasm` alone costs ~620 MB of that region. With the +full desktop running — compositor, Waybar, qtgallery, foot, mako, dbus-daemon, +klauncher — the region already holds ~1.2 GB of live code. Quickshell's +launch compile fits (~1.9 GB), but any `pthread_create` compiles the +thread-patched module as a second full copy, which cannot fit; the compile +throws SpiderMonkey's `InternalError: out of memory`, Qt logs +`QThread::start: Thread creation error`, and the panel never maps. The +thread cuts above shrink how many second copies a Qt client makes but do +not lift the 2 GiB cap, and a single desktop-plus-Quickshell code image +already exceeds it, so Firefox stays excluded. Chrome and WebKit have no +fixed per-process code cap, so the same desktop passes there. This is an engine limit, not a memory shortage — the process RSS +stays far below the machine's capacity when it hits. The omarchy spec +therefore skips its Quickshell gate (5e) on Firefox; everything up to and +after it runs on all three engines. + See [architecture.md](architecture.md#desktop-shell-zwlr_layer_shell_v1-kbar-klauncher-themes). Gated node-side by diff --git a/docs/fork-instrumentation.md b/docs/fork-instrumentation.md index e3fae252c4..53b66a7e40 100644 --- a/docs/fork-instrumentation.md +++ b/docs/fork-instrumentation.md @@ -583,7 +583,13 @@ so these historical measurements are constraints on generated shape, not a current performance claim. Synthetic scalar locals include only call arguments and operand-stack -carryovers that cannot be replayed directly. Catch code uses one exact-arm +carryovers that cannot be replayed directly, and every direct-call landing +draws them from one function-wide pool keyed by storage type and per-landing +ordinal. A landing's spills are written at its own chunk tail and read back +at its own post-call landing, and only the callee executes in between, so +two landings' spill windows never overlap inside one activation. The pool +bounds instrument-added locals and their frame bytes by the widest single +landing instead of the landing count. Catch code uses one exact-arm selector per function and one typed operand-scratch union sized to the maximum simultaneously selected payload, not one tuple per static arm or region. Reference-bearing and untagged exceptions are retained through the complete @@ -648,6 +654,24 @@ in those chunks never re-execute by construction. The previous Phase 4g side-effect gating and Phase 4c non-fork-path call gating are no longer needed. +Both shapes also pick a per-function **unwind boundary** by call-site count +(`SHARED_UNWIND_BOUNDARY_MIN_CALLS`, currently 8). Below the threshold, each +fork-path call keeps its own result-typed `try_table` boundary and a +statically indexed frame-select handler — the zero-extra-local shape that +preserves recursion depth. At or above it, one i32 selector local is written +with the static call index before every routed call, every per-site +`try_table` catches the private unwind tag to one block wrapping the whole +cascade, and a single shared handler runs frame-select from the selector. +This replaces roughly fifteen boundary instructions per call site — the +per-site term that made call-heavy Qt functions too large for TurboFan's +register allocator. Two further emission bounds serve the same goal: a +direct-activation landing emits its materialized call once, with no +`state >= REWINDING` branch, because NORMAL and lexical replay reissue the +identical call; and the per-call reference save/restore dispatch emits one +body per distinct (live-slot, definitely-null) set, with each body guarded +by the union of its call-index ranges, instead of one body per coalesced +run. + The tool's `instrument_one_function` (in `crates/fork-instrument/src/instrument.rs`) inspects the original body, runs `classify_nested_pattern` to decide whether the per-region transform applies, and routes to either @@ -1236,8 +1260,12 @@ K-04, and K-07 cover the current behavior. serialized as a frame (func_index + call_index) and reconstructed during rewind. The child resumes at the exact call site from which the parent invoked `fork()`. -- **Scalar user locals.** All i32, i64, f32, f64, and v128 locals on the - fork-path are saved to linear memory at unwind and restored at rewind. +- **Scalar user locals.** Every i32, i64, f32, f64, and v128 local that is + live on any successor of a fork landing — plus function parameters and the + scalar inputs of pure-replay tails — is saved to linear memory at unwind + and restored at rewind. A local whose value is dead at every resume point + consumes no frame bytes and no save/restore code; scalar liveness reuses + the reference analysis' structured CFG, including exception edges. - **Fresh-instance ownership.** Every accepted replay value is either scalar activation state in the linked continuation or state rebuilt by an explicit, versioned reconstruction owner. Instrumented modules carry diff --git a/docs/porting-guide.md b/docs/porting-guide.md index 25c9cc69f1..7d348985a9 100644 --- a/docs/porting-guide.md +++ b/docs/porting-guide.md @@ -71,6 +71,67 @@ headers from an arbitrary host LLVM install; the libcxx package generates and ships a version-matched header tree with its `libc++.a` and `libc++abi.a`. See `packages/registry/mariadb/build-mariadb.sh` for a complete example. +**Host code generators**: Some build systems run their own generators on the +build machine and compile the output for the target — `moc`/`rcc`/`uic` for Qt, +the same role `wayland-scanner` fills for Wayland. Declare the generator as a +`[[host_tools]]` entry and take it from `flake.nix`, not from an ambient host +install. When the generator ships as part of the same project being +cross-compiled, its version is usually locked to the target version: Qt's CMake +reads the host tools through `QT_HOST_PATH` and refuses a host/target mismatch, +so `flake.nix`'s pinned Qt fixes the version the recipe may declare. The +resolver understands only `>=` constraints, so a recipe needing an exact match +must check it itself and fail loudly. See +`packages/registry/qtbase/build-qtbase.sh`. + +**A generator the pinned host package does not ship**: A host package built for +the build machine carries only the generators that machine's platform enables, +which is not always the set a cross-build needs. nixpkgs' darwin `qtbase` has no +`qtwaylandscanner`, because qtbase looks for the `Wayland::Scanner` it depends +on only `if(LINUX)`; a Qt Wayland cross-build on a Mac therefore stops at +`Failed to find the host tool "Qt6::qtwaylandscanner"`. Build the missing +generator in `flake.nix` from the same source and version the host package +pins, and publish the CMake package the cross-build looks for. Gate it on the +same condition the host package uses, so the platform that already ships the +generator does not receive a second copy. Keep it out of the recipe's own +output: a package archive is content-hashed and published, so a host binary +inside one makes an arm64 Mac and an x86\_64 runner produce different archives +for the same source. See `nix/qtwaylandscanner/` and +`host/test/qtwaylandscanner-host-tool.test.ts`. + +Write that package's version file by hand, or clear `CMAKE_SIZEOF_VOID_P` +before `write_basic_package_version_file`. CMake's helper bakes the pointer +width of the machine that built the tool into a bitness check, and a wasm32 +consumer then rejects the package it just found — reported as +`version: 6.10.2 (64bit)` under "configuration files were considered but not +accepted". A test project written in `LANGUAGES NONE` has no pointer width and +will not reproduce it; set `CMAKE_SIZEOF_VOID_P` to 4 so it does. + +**OS detection in headers**: `CMAKE_SYSTEM_NAME=Linux` settles the build +system only. Source that branches on preprocessor macros still sees a +toolchain defining `__unix__` and `__wasm32__` but not `__linux__`, which the +SDK withholds on purpose — Kandelo has no Linux kernel (`sdk/config.site`). +A port whose headers demand a known OS needs `-D__linux__=1` in its own recipe, +as `basu`, `erlang`, `spidermonkey` and `qtbase` do. Expect the consequence: +the source then takes Linux paths, and each one must be backed. Qt needed its +futex path disabled and an empty `` supplied from the package's own +include directory. + +Every consumer of that library needs the same defines. They are not recorded in +the archive: a program including `` without `-D__linux__=1` +stops at the same `qsystemdetection.h` error the recipe hit, and one without +`-DQT_LINUXBASE` compiles against headers that disagree with the archives about +the futex. Carry the recipe's target defines into the consumer's compile line — +see `packages/registry/qtbase/test/build-gui-smoke.sh`. + +**Baked install prefixes**: A library that records its configure-time +`CMAKE_INSTALL_PREFIX` writes the resolver's staging directory — whose name +carries the builder's PID — into the shipped artifact, so two builds of the +same source differ. Configure against the guest path the code will run under +and relocate at install time with `cmake --install --prefix`, then assert the +staging path is absent before the recipe exits. Qt does this through +`qt_prfxpath` in `libQt6Core.a`; fontconfig and gdk-pixbuf did it through +`--with-templatedir` and `--localedir`. + ### OSS playback with `/dev/dsp` Kandelo's standard low-level audio API is OSS `/dev/dsp`, backed by the generic diff --git a/docs/posix-status.md b/docs/posix-status.md index b0bd43f10d..8c083f83d9 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -345,7 +345,7 @@ proves and reserves only the 128-byte prefix the kernel can write. | `timerfd_create()` | Full | Creates a refcounted kernel-global timerfd backing with CLOCK_REALTIME or CLOCK_MONOTONIC. Inherited descriptors observe the same timer and expiration count; non-CLOEXEC state survives exec. TFD_NONBLOCK and TFD_CLOEXEC are supported. | | `timerfd_settime()` / `timerfd_gettime()` | Full | Arms/disarms the shared timerfd backing with interval and initial expiration. TFD_TIMER_ABSTIME is supported; read returns the shared expiration count and poll reports POLLIN when expired. | | `inotify_init()` / `inotify_init1()` | Stub | Returns ENOSYS. | -| `inotify_add_watch()` / `inotify_rm_watch()` | Stub | Returns EBADF. | +| `inotify_add_watch()` / `inotify_rm_watch()` | Stub | Returns ENOSYS. The whole family fails so watchers (Qt's QFileSystemWatcher, glib's GFileMonitor, tail -f) take their polling fallbacks instead of trusting an fd that never delivers events. | | `fanotify_init()` / `fanotify_mark()` | Stub | Returns ENOSYS. | | `timer_create()` | Partial | Supports `CLOCK_REALTIME`, `CLOCK_MONOTONIC`, and monotonic-equivalent `CLOCK_BOOTTIME` with `SIGEV_SIGNAL`, `SIGEV_NONE`, Linux `SIGEV_THREAD_ID`, and POSIX `SIGEV_THREAD` through musl's exact-thread helper. Expirations preserve `SI_TIMER`, timer ID, overrun metadata, and the complete caller-native `union sigval` on Node and browser hosts. The generated 64-byte `sigevent` layout and explicit process pointer width preserve a wasm64 `sival_ptr`; wasm32 delivery uses its target-native 32-bit union width. | | `timer_settime()` / `timer_gettime()` | Partial | Absolute (`TIMER_ABSTIME`) and relative timers and automatic interval rearming use host timers with millisecond granularity. `timer_gettime()` and `timer_settime()`'s old-value result currently report the last configured value rather than decreasing remaining time. | diff --git a/examples/run-example.ts b/examples/run-example.ts index 7a7e555cbf..1363cc91c2 100644 --- a/examples/run-example.ts +++ b/examples/run-example.ts @@ -106,7 +106,7 @@ const diffWasm = optionalBinary("programs/diffutils/diff.wasm"); const cmpWasm = optionalBinary("programs/diffutils/cmp.wasm"); const sdiffWasm = optionalBinary("programs/diffutils/sdiff.wasm"); const diff3Wasm = optionalBinary("programs/diffutils/diff3.wasm"); -const perlWasm = optionalBinary("programs/perl.wasm"); +const perlWasm = optionalBinary("programs/perl/perl.wasm"); const nanoWasm = optionalBinary("programs/nano.wasm"); const tclshWasm = optionalBinary("programs/tcl.wasm"); const testfixtureBuild = resolve( diff --git a/flake.nix b/flake.nix index 33351e3a24..0026e09423 100644 --- a/flake.nix +++ b/flake.nix @@ -43,6 +43,61 @@ llvmPkg.libcxx.dev ]; }; + qtHostVersion = pkgs.qt6.qtbase.version; + + # nixpkgs' qtbase takes wayland only where the platform has it + # (modules/qtbase/default.nix:82), and that is what decides whether it + # finds the Wayland::Scanner its own qtwaylandscanner is gated on + # (qtbase src/tools/configure.cmake:5). Linux gets the generator and + # its Qt6WaylandScannerTools CMake package from qtbase itself; darwin + # gets neither, and a Qt Wayland cross-build there stops at + # `Failed to find the host tool "Qt6::qtwaylandscanner"`. + qtHostHasWaylandScanner = + pkgs.lib.meta.availableOn pkgs.stdenv.hostPlatform pkgs.wayland; + + # The generator qtbase declined to build, built from qtbase's own src + # and version so it cannot drift from the moc/rcc/uic beside it in + # qtHostTree. Reusing that source also means no second fetch. The tool + # is 1332 lines over QtCore alone; nix/qtwaylandscanner/CMakeLists.txt + # carries the details. + qtWaylandScanner = pkgs.stdenv.mkDerivation { + pname = "qtwaylandscanner"; + inherit (pkgs.qt6.qtbase) version src; + nativeBuildInputs = [ pkgs.cmake pkgs.ninja ]; + buildInputs = [ pkgs.qt6.qtbase ]; + # nixpkgs' qtPreHook fails any qtbase consumer that states no + # wrapping choice. The scanner reads XML and writes C++; it loads no + # Qt plugin, so it wants the plain binary Qt itself installs. + dontWrapQtApps = true; + postPatch = '' + mkdir host-qtwaylandscanner + cp ${./nix/qtwaylandscanner/CMakeLists.txt} host-qtwaylandscanner/CMakeLists.txt + ''; + # $PWD is the unpacked qtbase root: preConfigure runs before the + # cmake hook descends into its build directory. + preConfigure = '' + cmakeFlagsArray+=( "-DQTBASE_SRC=$PWD" ) + ''; + cmakeDir = "../host-qtwaylandscanner"; + }; + + # Combined tree so QT_HOST_PATH is one prefix, the shape Qt's + # cross-build CMake requires: it reads moc/rcc/uic out of + # /libexec and the QML generators out of the same place, + # then loads the Qt6*Tools CMake packages from /lib/cmake. + # nixpkgs splits those across four derivations, so a bare + # qtbase prefix resolves moc but fails on qmltyperegistrar. + qtHostTree = pkgs.symlinkJoin { + name = "qt-${qtHostVersion}-host-tree"; + paths = [ + pkgs.qt6.qtbase + pkgs.qt6.qtbase.dev + pkgs.qt6.qtdeclarative + pkgs.qt6.qtdeclarative.dev + pkgs.qt6.qtshadertools + pkgs.qt6.qtshadertools.dev + ] ++ pkgs.lib.optional (!qtHostHasWaylandScanner) qtWaylandScanner; + }; devShellPackages = [ rustToolchain # The wrapper supplies host `cc`, `c++`, `ar`, and linker tools @@ -68,6 +123,10 @@ # WebKitGTK, and Xorg just to run `erl`/`erlc`. pkgs.beam_minimal.interpreters.erlang_28 pkgs.cmake + # ninja — Qt's own configure selects it as the generator + # whenever it is on PATH (QtProcessConfigureArgs.cmake:1136), + # so it is the generator Qt builds and tests against. + pkgs.ninja pkgs.autoconf pkgs.automake pkgs.libtool @@ -108,6 +167,30 @@ # wayland version so its runtime matches the generated glue. # Consumers declare it as a `[[host_tools]]` prerequisite. pkgs.wayland-scanner + # spirv-opt — qsb shells out to it for every shader built with + # qt_add_shaders(... OPTIMIZED), which Quickshell's widgets + # module uses. + pkgs.spirv-tools + # Qt 6 host tools — the code generators a Qt cross-build runs on + # the build machine: moc, rcc, uic, qmltyperegistrar and + # qmlcachegen. They emit plain C++ that consumers compile to + # wasm32, so their role is wayland-scanner's, one layer up. + # + # Qt cross-builds require a host Qt of the *same* version as the + # target: CMake reads it through QT_HOST_PATH and refuses a + # mismatch. So nixpkgs' 6.10.2 fixes the version the qtbase and + # qtdeclarative recipes pin. Bumping nixpkgs moves both. + # + # Taken from nixpkgs rather than built from the qtbase recipe's + # own source: a host build would compile Qt twice per cold cache, + # once natively and once for wasm32, to obtain generators whose + # output is platform-independent. + # + # Both are darwin+linux clean — they are the ordinary desktop + # derivations, and only their generators are used, never their + # libraries, which target the host and cannot link into a + # wasm32 program. + qtHostTree # cbindgen — required by Mozilla's JS/SpiderMonkey configure # path once Rust support is enabled. pkgs.rust-cbindgen @@ -287,6 +370,10 @@ # tools instead of ambient host binaries. export AR="$LLVM_BIN/llvm-ar" export RANLIB="$LLVM_BIN/llvm-ranlib" + # Qt cross-builds resolve their host generators through this + # prefix and refuse a host/target version mismatch, so it also + # fixes the version a Qt recipe may declare. + export QT_HOST_PATH=${qtHostTree} export WASM_POSIX_LLVM_LIBCXX_SOURCE=${llvmPkg.libcxx.src} export WASM_POSIX_LLVM_LIBUNWIND_SOURCE=${llvmPkg.libunwind.src} # CA bundle for HTTPS — pure-shell strips the user's diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index c498d49403..4f3ab712ad 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -24,7 +24,7 @@ import type { ForkBorrowedReplayWorkspace, ForkContinuationContext, ResolvedSpawnProgram, - SpawnProgramResolution, + SpawnCandidateResolution, ThreadChannelAttachment, } from "./kernel-worker"; import { BrowserWorkerAdapter } from "./worker-adapter-browser"; @@ -280,7 +280,7 @@ async function resolveExecutableForLaunch( path: string, argv: string[], depth = 0, -): Promise { +): Promise { if (depth > MAX_SHEBANG_DEPTH) return null; const bytes = await readExecFileFromFs(path); if (!bytes) return null; @@ -292,18 +292,11 @@ async function resolveExecutableForLaunch( expectedAbi: kernelWorker.getKernelAbiVersion(), }); if (artifactFailures.length > 0) return { errno: ENOEXEC }; - let programModule: WebAssembly.Module; - try { - programModule = await WebAssembly.compile(bytes); - } catch (error) { - if (error instanceof WebAssembly.CompileError) return { errno: ENOEXEC }; - throw error; - } const declaredAbi = extractAbiVersion(bytes); if (declaredAbi !== null && declaredAbi !== kernelWorker.getKernelAbiVersion()) { return { errno: ENOEXEC }; } - return { programBytes: bytes, programModule, argv }; + return { programBytes: bytes, argv }; } const scriptArgv = [ @@ -3057,13 +3050,14 @@ async function handleExec( * Pre-flight resolver — see node-kernel-worker-entry.ts:handlePosixSpawnResolve. * Browser-side equivalent: materialize the lazy file (async fetch via * the memfs lazy-loader, avoiding sync-XHR + SW deadlocks), reads its - * contents from the VFS, follows shebangs, and compiles the final Wasm - * module. Safe to call before the kernel applies spawn file actions. + * contents from the VFS, and follows shebangs. Compilation is deferred to the + * shared worker's isolated candidate snapshot. Safe to call before the kernel + * applies spawn file actions. */ async function handlePosixSpawnResolve( path: string, argv: string[], -): Promise { +): Promise { return resolveExecutableForLaunch(path, argv); } diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index fcdef84748..7479e1d067 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -2019,6 +2019,17 @@ export interface ResolvedSpawnProgram { argv: string[]; } +/** + * A resolver's spawn candidate carries bytes only. Compilation happens exactly + * once, in `compileSpawnCandidateSnapshot`; a resolver-side module would be + * discarded there while still pinning executable memory (a full desktop of + * duplicate modules exhausts SpiderMonkey's 2 GiB per-process code arena). + */ +export interface ResolvedSpawnCandidate { + programBytes: ArrayBuffer; + argv: string[]; +} + export interface SpawnResolveError { errno: number; } @@ -2107,6 +2118,8 @@ function createThreadChannelAttachment( export type SpawnProgramResolution = ResolvedSpawnProgram | SpawnResolveError; +export type SpawnCandidateResolution = ResolvedSpawnCandidate | SpawnResolveError; + interface ReservedSpawnScratch { // A token exists before its pointer/capacity can be validated. Keeping that // token in the value even when region construction fails guarantees the @@ -2244,7 +2257,7 @@ interface BlockingRetryWakeTargets { } function isSpawnResolveError( - resolution: SpawnProgramResolution, + resolution: SpawnProgramResolution | SpawnCandidateResolution, ): resolution is SpawnResolveError { return "errno" in resolution && typeof resolution.errno === "number"; @@ -2306,9 +2319,10 @@ export interface CentralizedKernelCallbacks { /** * Pre-flight resolution step for SYS_SPAWN. Returns the validated program - * bytes, their compiled module, and launch argv for `path`, `{ errno }` for - * a located but unlaunchable program, or `null` for ENOENT. **Must NOT have - * side effects** — + * bytes and launch argv for `path`, `{ errno }` for a located but + * unlaunchable program, or `null` for ENOENT. The shared worker compiles + * the candidate exactly once, from its own isolated byte snapshot. + * **Must NOT have side effects** — * `handleSpawn` calls this BEFORE `kernel_spawn_process` so that file * actions never run on a doomed PATH-iteration. POSIX requires * file_actions to run "exactly once," and `posix_spawnp`'s PATH-walk @@ -2319,7 +2333,7 @@ export interface CentralizedKernelCallbacks { * * Required if `onSpawn` is set; together they form the spawn surface. */ - onResolveSpawn?: (path: string, argv: string[]) => Promise; + onResolveSpawn?: (path: string, argv: string[]) => Promise; /** * Launch a worker for the spawned child with bytes and module derived from diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index e5ba377042..745670aa58 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -32,7 +32,7 @@ import type { ForkBorrowedReplayWorkspace, ForkContinuationContext, ResolvedSpawnProgram, - SpawnProgramResolution, + SpawnCandidateResolution, ThreadChannelAttachment, } from "./kernel-worker"; import { NodePlatformIO } from "./platform/node"; @@ -871,7 +871,7 @@ async function resolveExecutableForLaunch( path: string, argv: string[], depth = 0, -): Promise { +): Promise { if (depth > MAX_SHEBANG_DEPTH) return null; const bytes = await resolveExec(path); if (!bytes) return null; @@ -883,18 +883,11 @@ async function resolveExecutableForLaunch( expectedAbi: kernelWorker.getKernelAbiVersion(), }); if (artifactFailures.length > 0) return { errno: ENOEXEC }; - let programModule: WebAssembly.Module; - try { - programModule = await WebAssembly.compile(bytes); - } catch (error) { - if (error instanceof WebAssembly.CompileError) return { errno: ENOEXEC }; - throw error; - } const declaredAbi = extractAbiVersion(bytes); if (declaredAbi !== null && declaredAbi !== kernelWorker.getKernelAbiVersion()) { return { errno: ENOEXEC }; } - return { programBytes: bytes, programModule, argv }; + return { programBytes: bytes, argv }; } const scriptArgv = [ @@ -2701,10 +2694,11 @@ async function handleExec( /** * Pre-flight resolver for SYS_SPAWN. Side-effect-free: looks up program - * bytes for `path` through the spawn-only execPrograms/main-thread fallback, - * follows shebangs, and compiles the final Wasm module. Exec never enters - * this resolver: its bytes come only from the retained kernel target. Returns - * null on ENOENT and `{ errno }` when the located target cannot be launched. + * bytes for `path` through the spawn-only execPrograms/main-thread fallback + * and follows shebangs. Compilation is deferred to the shared worker's + * isolated candidate snapshot. Exec never enters this resolver: its bytes + * come only from the retained kernel target. Returns null on ENOENT and + * `{ errno }` when the located target cannot be launched. * * `handleSpawn` in `host/src/kernel-worker.ts` calls this BEFORE * `kernel_spawn_process` so that file_actions (which the kernel runs @@ -2714,7 +2708,7 @@ async function handleExec( async function handlePosixSpawnResolve( path: string, argv: string[], -): Promise { +): Promise { return resolveExecutableForLaunch(path, argv); } diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 56b543675f..99ae74ea52 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -128,6 +128,9 @@ export interface RunProgramOptions { programPath: string; /** Optional pre-compiled module for programPath. */ programModule?: WebAssembly.Module; + /** Kernel wasm bytes (defaults to resolving `kernel.wasm` via the + * binary resolver). */ + kernelWasmBytes?: ArrayBuffer; /** Environment variables as KEY=VALUE strings */ env?: string[]; /** Program arguments */ @@ -303,7 +306,7 @@ async function runInWorkerThread(options: RunProgramOptions): Promise { - const kernelWasmBytes = loadKernelWasm(); + const kernelWasmBytes = options.kernelWasmBytes ?? loadKernelWasm(); const programBytes = loadProgramWasm(options.programPath); const timeout = options.timeout ?? 30_000; const ptrWidth = detectPtrWidth(programBytes); @@ -485,17 +488,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise { const mappedProgram = options.execPrograms?.get(path); if (!mappedProgram) return null; - const spawnProgramBytes = loadProgramWasm(mappedProgram); - try { - return { - programBytes: spawnProgramBytes, - programModule: await WebAssembly.compile(spawnProgramBytes), - argv, - }; - } catch (error) { - if (error instanceof WebAssembly.CompileError) return { errno: 8 }; - throw error; - } + return { programBytes: loadProgramWasm(mappedProgram), argv }; }, onSpawn: async (_parentPid, childPid, program, envp) => { if (!kernelWorker.shouldLaunchPendingChild(childPid)) return 0; diff --git a/host/test/fork-abort-unwind.test.ts b/host/test/fork-abort-unwind.test.ts index fe4a3f3d5b..974d0ffdb5 100644 --- a/host/test/fork-abort-unwind.test.ts +++ b/host/test/fork-abort-unwind.test.ts @@ -21,18 +21,26 @@ describe("instrumented ABORT_UNWINDING", () => { // The outer function has a roughly 72 KiB scalar payload. Its caller // (run) first commits a small frame into the root chunk, then outer's // reservation requires a second mapping where failure is injected. + // Every local is read after the call site: the instrumenter's scalar + // liveness filter drops dead locals from the frame, and the fixture + // needs the full payload to reach the second mapping. const outerLocalCount = 9_000; const outerLocalInit = Array.from( { length: outerLocalCount }, (_, index) => `i64.const ${index} local.set ${index}`, ).join("\n"); + const outerLocalReads = Array.from( + { length: outerLocalCount }, + (_, index) => `local.get ${index} drop`, + ).join("\n"); const wat = `(module (import "kernel" "kernel_fork" (func $fork (result i32))) (import "env" "memory" (memory 8)) (func $leaf (result i32) call $fork) (func $outer (result i32) (local ${"i64 ".repeat(outerLocalCount)}) ${outerLocalInit} - call $leaf) + call $leaf + ${outerLocalReads}) (func (export "run") (result i32) (local $saved i32) i32.const 7 local.set $saved @@ -159,11 +167,17 @@ describe("instrumented ABORT_UNWINDING", () => { try { const rawPath = join(dir, "abort-catch.wasm"); const instrumentedPath = join(dir, "abort-catch.instrumented.wasm"); + // As in the fixture above, every local is read after the call site so + // the scalar liveness filter keeps the full payload in the frame. const outerLocalCount = 9_000; const outerLocalInit = Array.from( { length: outerLocalCount }, (_, index) => `i64.const ${index} local.set ${index}`, ).join("\n"); + const outerLocalReads = Array.from( + { length: outerLocalCount }, + (_, index) => `local.get ${index} drop`, + ).join("\n"); const wat = `(module (import "kernel" "kernel_fork" (func $fork (result i32))) (import "env" "memory" (memory 8)) @@ -198,6 +212,7 @@ describe("instrumented ABORT_UNWINDING", () => { local.set $guard i32.const 2 call $recurse + ${outerLocalReads} local.get $guard i32.add))`; const watPath = join(dir, "abort-catch.wat"); diff --git a/host/test/fork-instrument-coverage.test.ts b/host/test/fork-instrument-coverage.test.ts index 2b0233e07e..fa0291ba38 100644 --- a/host/test/fork-instrument-coverage.test.ts +++ b/host/test/fork-instrument-coverage.test.ts @@ -318,7 +318,7 @@ describe("fork_instrument_coverage / K-* callback fork roots", () => { await runFixture("programs/k_02_fork_in_sigalrm_handler.wasm", { contains: ["REGISTERED", "ALARMED", "IN_HANDLER", "PRE_FORK", "CHILD: ok", "PASS: K-02"], }); - }); + }, 10_000); // K-03: pthread cleanup handlers run on a pthread worker channel, so // fork() here exercises the same fork-from-non-main-thread host path as @@ -393,7 +393,7 @@ describe("fork_instrument_coverage / P-* process & threading", () => { contains: ["POPEN_OPENED", "READ: hello-popen", "PCLOSE: status=0", "PASS: P-04"], execPrograms: popenExecMap, }); - }); + }, 10_000); it("P-05 posix_spawn — non-forking path, must remain unchanged by refactor", async () => { await runFixture("programs/p_05_posix_spawn.wasm", { diff --git a/host/test/inotify-enosys.test.ts b/host/test/inotify-enosys.test.ts new file mode 100644 index 0000000000..43823f63df --- /dev/null +++ b/host/test/inotify-enosys.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { tryResolveBinary } from "../src/binary-resolver"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const inotifyBinary = tryResolveBinary("programs/inotify-enosys.wasm"); + +describe("inotify syscall family", () => { + it.skipIf(!inotifyBinary)( + "every inotify entry point fails with ENOSYS so watchers take their polling fallbacks", + async () => { + const result = await runCentralizedProgram({ + programPath: inotifyBinary!, + argv: ["inotify-enosys"], + useDefaultRootfs: false, + }); + + expect(result.exitCode, `stderr=${result.stderr}`).toBe(0); + expect(result.stdout).toBe("PASS\n"); + expect(result.stderr).toBe(""); + }, + ); +}); diff --git a/host/test/qt-gui-smoke.test.ts b/host/test/qt-gui-smoke.test.ts new file mode 100644 index 0000000000..e50daa961e --- /dev/null +++ b/host/test/qt-gui-smoke.test.ts @@ -0,0 +1,130 @@ +/* + * Stage-2 gate for the Qt port (packages/registry/qtbase): QtGui and the + * Wayland platform plugin run on the kernel. + * + * Qt 6.10 moved the Wayland client platform plugin into qtbase + * (src/plugins/platforms/wayland), so one package delivers QtGui, + * QtWaylandClient, the `wayland` QPA key and the xdg-shell integration. + * test/build-gui-smoke.sh links them into a wasm32 program and + * test/qt_gui_smoke.cpp asserts each one from inside the guest. + * + * The font comes from a staged fonts.conf, as in the gtk3, foot, mako + * and waybar smokes. Without it fontconfig has no config and QtGui's + * font database is empty. + * + * The QProcess pass re-execs the program with --child through forkfd's + * generic fork() fallback (src/forkfd-generic-on-wasm.patch), staged as + * an execPrograms entry so the guest path resolves. + * + * The build runs the resolver, so this needs the dev shell and a + * buildable worktree. Outside those the test skips. + */ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { afterAll, describe, expect, it } from "vitest"; + +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, "../.."); +const BUILD_SCRIPT = join( + REPO_ROOT, + "packages/registry/qtbase/test/build-gui-smoke.sh", +); +const INCONSOLATA = join( + REPO_ROOT, + "examples/libs/wpkdraw/third_party/Inconsolata-Regular.ttf", +); + +function hasToolchain(): boolean { + try { + execFileSync("wasm32posix-c++", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const canBuild = hasToolchain(); +let workDir: string | null = null; + +afterAll(() => { + if (workDir) rmSync(workDir, { recursive: true, force: true }); +}); + +describe("qtbase — QtGui and the Wayland plugin on the kernel", () => { + it.skipIf(!canBuild)( + "registers the wayland QPA key, rasterises and typesets", + async () => { + workDir = mkdtempSync(join(tmpdir(), "qt-gui-smoke-")); + const fontDir = join(workDir, "fonts"); + const cacheDir = join(workDir, "cache"); + mkdirSync(fontDir); + mkdirSync(cacheDir); + copyFileSync(INCONSOLATA, join(fontDir, "Inconsolata-Regular.ttf")); + + const confPath = join(workDir, "fonts.conf"); + writeFileSync( + confPath, + ` + + + ${fontDir} + ${cacheDir} + + sans-serif + Inconsolata + + +`, + ); + + const programPath = join(workDir, "qt_gui_smoke.wasm"); + const built = execFileSync("bash", [BUILD_SCRIPT, programPath], { + encoding: "utf8", + }); + expect(built, built).toContain("QT_GUI_SMOKE_BUILT"); + + // argv[0] is the guest path the QProcess pass re-execs, so it must + // name the execPrograms entry, not a bare program name. + const guestPath = "/usr/local/bin/qt_gui_smoke"; + const result = await runCentralizedProgram({ + programPath, + argv: [guestPath], + env: [`FONTCONFIG_FILE=${confPath}`], + execPrograms: new Map([[guestPath, programPath]]), + timeout: 120_000, + }); + + const dump = `stdout=${result.stdout}\nstderr=${result.stderr}`; + expect(result.exitCode, dump).toBe(0); + expect(result.stdout).toContain("QT_VERSION=6.10.2"); + expect(result.stdout).toContain("PLATFORM=offscreen"); + expect(result.stdout).toContain("WAYLAND_PLUGIN=yes"); + expect(result.stdout).toContain("XDG_SHELL_PLUGIN=yes"); + expect(result.stdout).toContain("CORNER=ff000000"); + expect(result.stdout).toContain("CENTRE=ffff0000"); + expect(result.stdout).toContain("QPROCESS_EXIT=0"); + expect(result.stdout).toContain("QT_PROCESS_CHILD_OK"); + expect(result.stdout).toContain("QT_GUI_SMOKE_OK"); + + const families = Number( + result.stdout.match(/FONT_FAMILIES=(\d+)/)?.[1] ?? "0", + ); + expect(families, dump).toBeGreaterThan(0); + + // Ink, not a family name: fontconfig matches and FreeType rasterises + // the staged font, but QFontDatabase enumerates only Qt's generic + // families here, so the staged name never appears. + const inked = Number( + result.stdout.match(/GLYPH_PIXELS=(\d+)/)?.[1] ?? "0", + ); + expect(inked, dump).toBeGreaterThan(0); + }, + 900_000, + ); +}); diff --git a/host/test/qt-qml-smoke.test.ts b/host/test/qt-qml-smoke.test.ts new file mode 100644 index 0000000000..efdbfe23ea --- /dev/null +++ b/host/test/qt-qml-smoke.test.ts @@ -0,0 +1,111 @@ +/* + * Stage-3 gate for the Qt port (packages/registry/qtdeclarative): the + * QML engine and the QtQuick software scenegraph run on the kernel. + * + * test/build-qml-smoke.sh links the static QML layout — plugin + * archives under qml/ in the prefix, module runtimes under lib/ — and + * test/qt_qml_smoke.cpp loads QML from a byte array, renders it + * through the software scenegraph on the offscreen platform, and + * asserts pixels from QQuickWindow::grabWindow(). + * + * The font comes from a staged fonts.conf, as in the qtbase gui smoke: + * the QML Text item asserts ink, not a family name. + * + * The build runs the resolver, so this needs the dev shell and a + * buildable worktree. Outside those the test skips. + */ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { afterAll, describe, expect, it } from "vitest"; + +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, "../.."); +const BUILD_SCRIPT = join( + REPO_ROOT, + "packages/registry/qtdeclarative/test/build-qml-smoke.sh", +); +const INCONSOLATA = join( + REPO_ROOT, + "examples/libs/wpkdraw/third_party/Inconsolata-Regular.ttf", +); + +function hasToolchain(): boolean { + try { + execFileSync("wasm32posix-c++", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +const canBuild = hasToolchain(); +let workDir: string | null = null; + +afterAll(() => { + if (workDir) rmSync(workDir, { recursive: true, force: true }); +}); + +describe("qtdeclarative — QML and the software scenegraph on the kernel", () => { + it.skipIf(!canBuild)( + "loads QML, renders through the software scenegraph and grabs pixels", + async () => { + workDir = mkdtempSync(join(tmpdir(), "qt-qml-smoke-")); + const fontDir = join(workDir, "fonts"); + const cacheDir = join(workDir, "cache"); + mkdirSync(fontDir); + mkdirSync(cacheDir); + copyFileSync(INCONSOLATA, join(fontDir, "Inconsolata-Regular.ttf")); + + const confPath = join(workDir, "fonts.conf"); + writeFileSync( + confPath, + ` + + + ${fontDir} + ${cacheDir} + + sans-serif + Inconsolata + + +`, + ); + + const programPath = join(workDir, "qt_qml_smoke.wasm"); + const built = execFileSync("bash", [BUILD_SCRIPT, programPath], { + encoding: "utf8", + }); + expect(built, built).toContain("QT_QML_SMOKE_BUILT"); + + const result = await runCentralizedProgram({ + programPath, + argv: ["qt_qml_smoke"], + env: [`FONTCONFIG_FILE=${confPath}`], + timeout: 120_000, + }); + + const dump = `stdout=${result.stdout}\nstderr=${result.stderr}`; + expect(result.exitCode, dump).toBe(0); + expect(result.stdout).toContain("QT_VERSION=6.10.2"); + expect(result.stdout).toContain("PLATFORM=offscreen"); + expect(result.stdout).toContain("QML_WINDOW=yes"); + expect(result.stdout).toContain("SCENEGRAPH_SOFTWARE=yes"); + expect(result.stdout).toContain("CORNER=ff000000"); + expect(result.stdout).toContain("CENTRE=ffff0000"); + expect(result.stdout).toContain("QT_QML_SMOKE_OK"); + + const inked = Number( + result.stdout.match(/GLYPH_PIXELS=(\d+)/)?.[1] ?? "0", + ); + expect(inked, dump).toBeGreaterThan(0); + }, + 900_000, + ); +}); diff --git a/host/test/qtgallery-smoke.test.ts b/host/test/qtgallery-smoke.test.ts new file mode 100644 index 0000000000..a073c585d5 --- /dev/null +++ b/host/test/qtgallery-smoke.test.ts @@ -0,0 +1,236 @@ +/** + * Qt-on-the-desktop gate: qtgallery (the Omarchy theme gallery, a + * QRasterWindow through the wayland QPA plugin) maps as a real window on + * wlcompositor, receives a pointer click, and drives a theme switch. + * + * qt-gui-smoke proves QtGui paints on the offscreen platform; this is the + * other half — the Wayland client path end to end: Qt's xdg-shell + * integration configures against the compositor, the raster backing store + * lands in wl_shm, and the compositor composites non-black pixels from it. + * The injected click is the pointer half of that path: evdev → libinput → + * wl_pointer → Qt's xkb-independent mouse delivery — and the activated + * card's `dispatch theme` line on the kwlctl socket comes back as the + * compositor's THEME marker, the whole demo loop in one gate. A + * host-injected Escape then closes the window through Qt's xkb state and + * both processes exit 0. + * + * The font comes from a staged fonts.conf, as in qt-gui-smoke: without it + * fontconfig has no config and QtGui's font database is empty. The themes + * are fixtures staged under WLC_THEME_DIR, which both the compositor and + * the gallery read instead of /usr/share/kandelo/themes. + * + * Skips if the binaries aren't built (bare checkout). + */ +import { afterAll, describe, expect, it } from "vitest"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, "../.."); +const INCONSOLATA = join( + REPO_ROOT, + "examples/libs/wpkdraw/third_party/Inconsolata-Regular.ttf", +); + +const compositorBin = tryResolveBinary("programs/wldesktop/wlcompositor.wasm"); +const qtgalleryBin = tryResolveBinary("programs/qtgallery.wasm"); +const hasBinaries = !!compositorBin && !!qtgalleryBin; + +const CANVAS_W = 1920; +const CANVAS_H = 1080; + +// The compositor's cascade places the first unmatched window at (160, 120) +// (wlcompositor.c place_surface), and the gallery's first theme card sits at +// window-local (24, 88) with a 216x132 body (qtgallery.cpp layout constants). +const WINDOW_X = 160; +const WINDOW_Y = 120; +const CARD_CENTER_X = 24 + 108; +const CARD_CENTER_Y = 88 + 66; + +// linux/input-event-codes.h +const EV_SYN = 0x00; +const EV_KEY = 0x01; +const EV_ABS = 0x03; +const SYN_REPORT = 0x00; +const ABS_X = 0x00; +const ABS_Y = 0x01; +const BTN_LEFT = 0x110; +const KEY_ESC = 1; + +const THEME_CONF = `border_active = 0x7aa2f7 +wallpaper_top = 0x1a1b26 +wallpaper_bottom = 0x24283b +bar = 0x16161e +foreground = 0xc0caf5 +accent = 0x7aa2f7 +occupied = 0x292e42 +background = 0x1a1b26 +`; + +let workDir: string | null = null; + +afterAll(() => { + if (workDir) rmSync(workDir, { recursive: true, force: true }); +}); + +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 }, + pattern: string | RegExp, + timeoutMs: number, + context: () => string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const s = ref.value; + if (typeof pattern === "string" ? s.includes(pattern) : pattern.test(s)) + return; + await new Promise((r) => setTimeout(r, 5)); + } + throw new Error(`Timed out waiting for ${String(pattern)}.\n${context()}`); +} + +describe("qtgallery — the theme gallery on the wayland desktop", () => { + it.skipIf(!hasBinaries)( + "maps through xdg-shell, takes a click, and switches the theme", + async () => { + workDir = mkdtempSync(join(tmpdir(), "qtgallery-smoke-")); + const fontDir = join(workDir, "fonts"); + const cacheDir = join(workDir, "cache"); + const themesDir = join(workDir, "themes"); + const shellsDir = join(workDir, "shells"); + mkdirSync(fontDir); + mkdirSync(cacheDir); + for (const theme of ["bar", "foo"]) { + mkdirSync(join(themesDir, theme), { recursive: true }); + writeFileSync(join(themesDir, theme, "theme.conf"), THEME_CONF); + } + mkdirSync(shellsDir); + writeFileSync(join(shellsDir, "garply.qml"), "// fixture\n"); + copyFileSync(INCONSOLATA, join(fontDir, "Inconsolata-Regular.ttf")); + const confPath = join(workDir, "fonts.conf"); + writeFileSync( + confPath, + ` + + + ${fontDir} + ${cacheDir} + + sans-serif + Inconsolata + + + monospace + Inconsolata + + +`, + ); + + 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, 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); + }; + const moveTo = (x: number, y: number) => { + host.injectInputEvent(1, EV_ABS, ABS_X, x); + host.injectInputEvent(1, EV_ABS, ABS_Y, y); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + }; + const click = () => { + host.injectInputEvent(1, EV_KEY, BTN_LEFT, 1); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + host.injectInputEvent(1, EV_KEY, BTN_LEFT, 0); + host.injectInputEvent(1, EV_SYN, SYN_REPORT, 0); + }; + + try { + await host.init(); + host.setInputCanvasDims(CANVAS_W, CANVAS_H); + + const compExit = host.spawn(loadBytes(compositorBin!), ["wlcompositor"], { + env: [`WLC_THEME_DIR=${themesDir}`], + }); + await waitFor(out, "COMPOSITOR_UP", 20_000, dump); + + const galleryExit = host.spawn(loadBytes(qtgalleryBin!), ["qtgallery"], { + env: [ + "HOME=/root", + "XDG_RUNTIME_DIR=/tmp", + // The Node smokes run on host-FS passthrough, where the + // rootfs's /usr/share/X11/xkb doesn't exist — and + // xkb_context_new(NO_FLAGS) hard-fails without a readable + // include root. Any existing dir satisfies it; clients + // compile the compositor's keymap string, not disk data. + "XKB_CONFIG_ROOT=/tmp", + `FONTCONFIG_FILE=${confPath}`, + `WLC_THEME_DIR=${themesDir}`, + `QTGALLERY_SHELL_DIR=${shellsDir}`, + ], + }); + await waitFor(out, "GALLERY_PLATFORM=wayland", 30_000, dump); + await waitFor(out, "GALLERY_THEMES n=2", 10_000, dump); + await waitFor(out, "GALLERY_SHELLS n=1", 10_000, dump); + await waitFor(out, /GALLERY_EXPOSED \d+x\d+/, 30_000, dump); + + // The compositor's sample is one-shot, emitted on the first composite + // with a mapped surface — the gallery, the only client. + await waitFor(out, "COMPOSITE_SAMPLE", 10_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(); + expect(parseInt(sample![1], 16) & 0xffffff).not.toBe(0); + + // A real pointer click on the first theme card ("bar", first in + // sorted order): motion targets the card center, the press activates + // it, the gallery dispatches on the kwlctl socket, and the + // compositor answers ok and prints the switch. + moveTo(WINDOW_X + CARD_CENTER_X, WINDOW_Y + CARD_CENTER_Y); + moveTo(WINDOW_X + CARD_CENTER_X + 2, WINDOW_Y + CARD_CENTER_Y + 2); + click(); + await waitFor(out, "GALLERY_APPLY theme=bar reply=ok", 15_000, dump); + await waitFor(out, "THEME bar", 10_000, dump); + + // Escape closes the window: compositor keymap → Qt's xkb state → + // keyPressEvent → close(), and the compositor follows its last + // client out. + tap(KEY_ESC); + const galleryCode = await Promise.race([ + galleryExit, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`qtgallery timed out.\n${dump()}`)), 30_000)), + ]); + expect(galleryCode, `qtgallery 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(() => {}); + } + }, + 120_000, + ); +}); diff --git a/host/test/qtwaylandscanner-host-tool.test.ts b/host/test/qtwaylandscanner-host-tool.test.ts new file mode 100644 index 0000000000..fb7d23fc26 --- /dev/null +++ b/host/test/qtwaylandscanner-host-tool.test.ts @@ -0,0 +1,85 @@ +/* + * Gate for the Qt host generator flake.nix builds itself. qtbase compiles + * qtwaylandscanner only where it finds Wayland::Scanner, and it looks for + * that only `if(LINUX)` (src/tools/configure.cmake:5), so nixpkgs' darwin + * qtbase ships neither the tool nor its Qt6WaylandScannerTools CMake + * package and a Qt Wayland cross-build stops at + * Failed to find the host tool "Qt6::qtwaylandscanner". + * The derivation in flake.nix closes that gap from qtbase's own source. + * + * Two halves, because a cross-build needs both: the CMake package must + * answer the restricted find_package of QtToolHelpers.cmake:740, and the + * tool itself must generate the Qt glue. The probe project owns the first, + * this file runs it and owns the second. + * + * QT_HOST_PATH and cmake come from flake.nix, so this exercises the real + * dev-shell toolchain — the CLAUDE.md vitest gate runs inside + * scripts/dev-shell.sh. Outside it the test skips, like the sibling + * wayland-protocols-scanner test. + */ +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { afterAll, describe, expect, it } from "vitest"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, "../.."); +const PROBE = join(REPO_ROOT, "nix/qtwaylandscanner/test"); +const XDG_SHELL = join( + REPO_ROOT, + "packages/registry/wayland-protocols/xml/xdg-shell.xml", +); + +function hostQtVersion(): string | null { + if (!process.env.QT_HOST_PATH) return null; + try { + return execFileSync("qmake", ["-query", "QT_VERSION"], { + encoding: "utf8", + }).trim(); + } catch { + return null; + } +} + +const QT_VERSION = hostQtVersion(); +const workDirs: string[] = []; + +afterAll(() => { + for (const dir of workDirs) rmSync(dir, { recursive: true, force: true }); +}); + +function configureProbe(version: string): string { + const build = mkdtempSync(join(tmpdir(), "qtwaylandscanner-probe-")); + workDirs.push(build); + return execFileSync( + "cmake", + ["-S", PROBE, "-B", build, `-DQT_HOST_VERSION=${version}`], + { encoding: "utf8" }, + ); +} + +describe("qtwaylandscanner — the Qt host generator flake.nix builds", () => { + it.skipIf(!QT_VERSION)( + "answers the cross-build lookup for Qt6WaylandScannerTools", + () => { + const out = configureProbe(QT_VERSION!); + expect(out, out).toContain("QTWAYLANDSCANNER_OK"); + }, + ); + + it.skipIf(!QT_VERSION)("generates the Qt glue for xdg-shell", () => { + const out = configureProbe(QT_VERSION!); + const scanner = out.match(/QTWAYLANDSCANNER_OK (\S+)/)?.[1]; + expect(scanner).toBeTruthy(); + + const header = execFileSync(scanner!, ["client-header", XDG_SHELL], { + encoding: "utf8", + }); + expect(header).toContain("// This file was generated by qtwaylandscanner"); + expect(header).toContain("class xdg_wm_base"); + expect(header).toContain("class xdg_toplevel"); + }); +}); diff --git a/host/test/quickshell-smoke.test.ts b/host/test/quickshell-smoke.test.ts new file mode 100644 index 0000000000..28739745d4 --- /dev/null +++ b/host/test/quickshell-smoke.test.ts @@ -0,0 +1,36 @@ +/** + * Stage-4 gate for the Quickshell port (packages/registry/quickshell): + * the static QtQuick shell binary boots on the kernel. + * + * --version exercises the whole launcher path short of a Wayland + * connection — CLI11 parses argv, QtCore initializes, and the build's + * version metadata prints. The layer-shell window path is proven on + * the desktop by the Omarchy wiring, not here. + * + * Skips if the binary isn't built (bare checkout). + */ +import { describe, expect, it } from "vitest"; + +import { runCentralizedProgram } from "./centralized-test-helper"; +import { tryResolveBinary } from "../src/binary-resolver"; + +const quickshellBin = tryResolveBinary("programs/quickshell.wasm"); + +describe("quickshell — the QtQuick shell binary on the kernel", () => { + it.skipIf(!quickshellBin)( + "parses argv and reports its version", + async () => { + const result = await runCentralizedProgram({ + programPath: quickshellBin!, + argv: ["quickshell", "--version"], + timeout: 120_000, + }); + + const dump = `stdout=${result.stdout}\nstderr=${result.stderr}`; + expect(result.exitCode, dump).toBe(0); + expect(result.stdout, dump).toContain("Quickshell"); + expect(result.stdout, dump).toContain("0.3.1"); + }, + 300_000, + ); +}); diff --git a/host/test/spawn-blob-transport.test.ts b/host/test/spawn-blob-transport.test.ts index a088495cce..a693a115f6 100644 --- a/host/test/spawn-blob-transport.test.ts +++ b/host/test/spawn-blob-transport.test.ts @@ -404,32 +404,26 @@ describe("SYS_SPAWN blob transport", () => { expect(kernelReservedSpawn).toHaveBeenCalledTimes(2); }); - it("ignores a resolver module that was not compiled from its candidate bytes", async () => { + it("snapshots candidate bytes before the resolver mutates them after preflight", async () => { const candidateBytes = moduleWithNamedExport("aaaa"); - const mismatchedModule = new WebAssembly.Module( - moduleWithNamedExport("bbbb"), - ); const launched = await launchCandidateBindingSpawn({ candidateBytes, - candidateModule: mismatchedModule, - authoritativeBytes: candidateBytes, + authoritativeBytes: candidateBytes.slice(), + afterPreflight: () => candidateBytes.fill(0), }); expect(WebAssembly.Module.exports(launched.programModule)).toEqual([ { name: "aaaa", kind: "function" }, ]); - expect(launched.programModule).not.toBe(mismatchedModule); }); - it("binds the candidate module before resolver bytes mutate after preflight", async () => { + it("recompiles from committed bytes when the candidate mutates after preflight", async () => { const candidateBytes = moduleWithNamedExport("aaaa"); const authoritativeBytes = moduleWithNamedExport("bbbb"); - const resolverModule = new WebAssembly.Module(candidateBytes); const launched = await launchCandidateBindingSpawn({ candidateBytes, - candidateModule: resolverModule, authoritativeBytes, afterPreflight: () => candidateBytes.set(authoritativeBytes), }); @@ -437,7 +431,6 @@ describe("SYS_SPAWN blob transport", () => { expect(WebAssembly.Module.exports(launched.programModule)).toEqual([ { name: "bbbb", kind: "function" }, ]); - expect(launched.programModule).not.toBe(resolverModule); }); it("keeps ordinary spawn blobs in the existing channel-sized scratch", () => { @@ -1617,7 +1610,6 @@ function moduleWithNamedExport(name: "aaaa" | "bbbb"): Uint8Array { async function launchCandidateBindingSpawn(options: { candidateBytes: Uint8Array; - candidateModule: WebAssembly.Module; authoritativeBytes: Uint8Array; afterPreflight?: () => void; }): Promise<{ @@ -1660,7 +1652,6 @@ async function launchCandidateBindingSpawn(options: { callbacks: { onResolveSpawn: vi.fn(async () => ({ programBytes: options.candidateBytes.buffer as ArrayBuffer, - programModule: options.candidateModule, argv: ["/bin/child"], })), onSpawn, diff --git a/images/vfs/scripts/generate-coreutils-man.ts b/images/vfs/scripts/generate-coreutils-man.ts index 1b7050254d..b40ee3dc9c 100644 --- a/images/vfs/scripts/generate-coreutils-man.ts +++ b/images/vfs/scripts/generate-coreutils-man.ts @@ -16,9 +16,14 @@ * If a tool prints empty --help inside Kandelo, that tool is logged and * skipped — a missing page is an honest gap, never a fabricated one. * - * Usage: tsx generate-coreutils-man.ts + * Usage: tsx generate-coreutils-man.ts + * + * The kernel wasm is a declared build dependency passed in explicitly: a + * package build must not resolve the kernel through the checkout-wide + * source-only projection authority, which the aggregate build only + * finalizes after every package (including this one) has succeeded. */ -import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { runCentralizedProgram } from "../../../host/test/centralized-test-helper"; @@ -44,27 +49,39 @@ const TOOLS = [ "chroot", "cksum", "install", "kill", "runcon", "tail", ]; -async function capture(bin: string, tool: string, flag: string): Promise { +async function capture( + bin: string, + kernelWasmBytes: ArrayBuffer, + tool: string, + flag: string, +): Promise { const { stdout } = await runCentralizedProgram({ programPath: bin, + kernelWasmBytes, argv: [tool, flag], env: ["PATH=/usr/bin:/bin", "POSIXLY_CORRECT=1"], }); return stdout; } +function loadKernelWasm(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + async function main() { - const [bin, outDir] = process.argv.slice(2); - if (!bin || !outDir) { - console.error("usage: generate-coreutils-man.ts "); + const [bin, outDir, kernelWasm] = process.argv.slice(2); + if (!bin || !outDir || !kernelWasm) { + console.error("usage: generate-coreutils-man.ts "); process.exit(2); } + const kernelWasmBytes = loadKernelWasm(kernelWasm); mkdirSync(outDir, { recursive: true }); const uniq = Array.from(new Set(TOOLS)).sort(); const skipped: string[] = []; for (const tool of uniq) { - const help = await capture(bin, tool, "--help"); - const version = await capture(bin, tool, "--version"); + const help = await capture(bin, kernelWasmBytes, tool, "--help"); + const version = await capture(bin, kernelWasmBytes, tool, "--version"); if (!help.trim()) { console.error(`skip ${tool}: empty --help from Kandelo`); skipped.push(tool); diff --git a/nix/qtwaylandscanner/CMakeLists.txt b/nix/qtwaylandscanner/CMakeLists.txt new file mode 100644 index 0000000000..9396d97f4b --- /dev/null +++ b/nix/qtwaylandscanner/CMakeLists.txt @@ -0,0 +1,72 @@ +# Stand in for qtbase's own src/tools/qtwaylandscanner build on a host where +# qtbase declined to run it, and reproduce the Qt6WaylandScannerTools package +# a Qt cross-build then looks for under QT_HOST_PATH +# (QtToolHelpers.cmake:686). flake.nix states which hosts those are. +# +# The tool is one 1332-line source over QtCore alone, so this compiles it +# directly rather than configuring a second, native qtbase. + +cmake_minimum_required(VERSION 3.16) + +project(qtwaylandscanner LANGUAGES CXX) + +find_package(Qt6 REQUIRED COMPONENTS Core) + +set(scanner_dir "${QTBASE_SRC}/src/tools/qtwaylandscanner") +set(cmake_dir "lib/cmake/Qt6WaylandScannerTools") + +add_executable(qtwaylandscanner "${scanner_dir}/qtwaylandscanner.cpp") +target_link_libraries(qtwaylandscanner PRIVATE Qt6::Core) + +install(TARGETS qtwaylandscanner + EXPORT Qt6WaylandScannerToolsTargets + RUNTIME DESTINATION libexec) +install(EXPORT Qt6WaylandScannerToolsTargets + NAMESPACE Qt6:: + DESTINATION "${cmake_dir}") + +# The scanner alone is not enough: qt6_generate_wayland_protocol_*_sources +# lives beside it and is what qtwayland's CMakeLists actually calls. qtbase +# ships both through the tool's EXTRA_CMAKE_FILES. +install(FILES + "${scanner_dir}/Qt6WaylandClientMacros.cmake" + "${scanner_dir}/Qt6WaylandCompositorMacros.cmake" + DESTINATION "${cmake_dir}") + +include(CMakePackageConfigHelpers) + +# A host tool is consumed by a cross-build of a different pointer width, and +# write_basic_package_version_file bakes CMAKE_SIZEOF_VOID_P into a bitness +# check that then rejects the package — "version: 6.10.2 (64bit)" against a +# wasm32 consumer. Clearing the variable is CMake's documented way to omit +# that check. Qt's own tools packages carry none either. +# +# Exact, because the generated glue matches one Qt and the cross-build asks +# for its own version by number. +set(CMAKE_SIZEOF_VOID_P "") +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/Qt6WaylandScannerToolsConfigVersion.cmake" + VERSION "${Qt6_VERSION}" + COMPATIBILITY ExactVersion) + +file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/Qt6WaylandScannerToolsConfig.cmake" [[ +if(NOT DEFINED Qt6WaylandScannerTools_FOUND) + set(Qt6WaylandScannerTools_FOUND TRUE) +endif() + +if(NOT QT_NO_CREATE_TARGETS AND Qt6WaylandScannerTools_FOUND) + include("${CMAKE_CURRENT_LIST_DIR}/Qt6WaylandScannerToolsTargets.cmake") +endif() + +include("${CMAKE_CURRENT_LIST_DIR}/Qt6WaylandClientMacros.cmake") +include("${CMAKE_CURRENT_LIST_DIR}/Qt6WaylandCompositorMacros.cmake") + +if(TARGET Qt6::qtwaylandscanner AND COMMAND __qt_internal_promote_target_to_global) + __qt_internal_promote_target_to_global(Qt6::qtwaylandscanner) +endif() +]]) + +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/Qt6WaylandScannerToolsConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/Qt6WaylandScannerToolsConfigVersion.cmake" + DESTINATION "${cmake_dir}") diff --git a/nix/qtwaylandscanner/test/CMakeLists.txt b/nix/qtwaylandscanner/test/CMakeLists.txt new file mode 100644 index 0000000000..5f38199de2 --- /dev/null +++ b/nix/qtwaylandscanner/test/CMakeLists.txt @@ -0,0 +1,47 @@ +# Reproduce the lookup a Qt Wayland cross-build makes for its host scanner: +# QtToolHelpers.cmake:740 searches Qt6WaylandScannerTools with QT_HOST_PATH's +# lib/cmake as the only prefix and every other search path switched off. +# +# Both the tool and the two macro files it ships have to arrive, because +# Qt6WaylandClientMacros.cmake:22 is what fails the build when they do not. + +cmake_minimum_required(VERSION 3.16) + +project(qtwaylandscanner_probe LANGUAGES NONE) + +set(CMAKE_PREFIX_PATH "$ENV{QT_HOST_PATH}/lib/cmake") + +# The consumer is a wasm32 cross-build, and a package version file carrying a +# bitness check rejects one. State the consumer's pointer width so this probe +# fails the same way that build does. +set(CMAKE_SIZEOF_VOID_P 4) + +find_package(Qt6WaylandScannerTools "${QT_HOST_VERSION}" REQUIRED + NO_PACKAGE_ROOT_PATH + NO_CMAKE_ENVIRONMENT_PATH + NO_SYSTEM_ENVIRONMENT_PATH + NO_CMAKE_PACKAGE_REGISTRY + NO_CMAKE_SYSTEM_PATH + NO_CMAKE_SYSTEM_PACKAGE_REGISTRY) + +if(NOT TARGET Qt6::qtwaylandscanner) + message(FATAL_ERROR "Qt6WaylandScannerTools declares no Qt6::qtwaylandscanner") +endif() + +foreach(macro_name + qt6_generate_wayland_protocol_client_sources + qt6_generate_wayland_protocol_server_sources) + if(NOT COMMAND "${macro_name}") + message(FATAL_ERROR "Qt6WaylandScannerTools declares no ${macro_name}") + endif() +endforeach() + +# The macros drive Qt6::qtwaylandscanner and Wayland::Scanner side by side, +# and FindWaylandScanner.cmake:85 resolves the second off PATH. +find_program(wayland_scanner NAMES wayland-scanner) +if(NOT wayland_scanner) + message(FATAL_ERROR "wayland-scanner is absent from PATH") +endif() + +get_property(scanner TARGET Qt6::qtwaylandscanner PROPERTY LOCATION) +message(STATUS "QTWAYLANDSCANNER_OK ${scanner}") diff --git a/packages/registry/cli11/build-cli11.sh b/packages/registry/cli11/build-cli11.sh new file mode 100755 index 0000000000..bb8a74e869 --- /dev/null +++ b/packages/registry/cli11/build-cli11.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# +# Install CLI11 2.5.0 (header-only) with its CMake config package for +# wasm32-posix-kernel. +# +# No library is compiled — the point of the cmake run is the installed +# share/cmake/CLI11 config, which Quickshell's launcher consumes through +# find_package(CLI11 CONFIG REQUIRED). The cross setup mirrors +# build-fmt.sh; CMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY keeps +# cmake's compiler probe from linking an executable. +# +# Honors the dep-resolver build-script contract (see +# docs/package-management.md). When invoked via +# `cargo xtask build-deps resolve cli11`, env vars are set by the +# resolver: +# +# WASM_POSIX_DEP_OUT_DIR # install prefix +# WASM_POSIX_DEP_VERSION # upstream version +# WASM_POSIX_DEP_SOURCE_URL # tarball URL +# WASM_POSIX_DEP_SOURCE_SHA256 # expected sha256 of the tarball + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +SRC_DIR="$SCRIPT_DIR/cli11-src" + +CLI11_VERSION="${WASM_POSIX_DEP_VERSION:-2.5.0}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/cli11-install}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/CLIUtils/CLI11/archive/refs/tags/v${CLI11_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" + +BUILD_DIR="$SCRIPT_DIR/cli11-build" + +if ! command -v wasm32posix-c++ &>/dev/null; then + echo "ERROR: wasm32posix-c++ not found. Enter scripts/dev-shell.sh." >&2 + exit 1 +fi + +# --- Fetch + verify source --- +if [ ! -d "$SRC_DIR" ]; then + echo "==> Downloading CLI11 $CLI11_VERSION..." + TARBALL="/tmp/cli11-${CLI11_VERSION}.tar.gz" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" + if [ -n "$SOURCE_SHA256" ]; then + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - + else + echo "==> (no SOURCE_SHA256 declared; skipping verification)" + fi + mkdir -p "$SRC_DIR" + tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + rm "$TARBALL" +fi + +rm -rf "$BUILD_DIR" +# The resolver-created output directory is itself publication authority, so +# a recipe must populate that inode rather than delete and recreate it. +if [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + if [ -n "$(find "$INSTALL_DIR" -mindepth 1 -print -quit)" ]; then + echo "ERROR: cli11 resolver output directory must start empty" >&2 + exit 1 + fi +else + rm -rf "$INSTALL_DIR" +fi + +echo "==> Configuring CLI11 for wasm32..." +cmake -S "$SRC_DIR" -B "$BUILD_DIR" \ + -DCMAKE_SYSTEM_NAME=Linux \ + -DCMAKE_SYSTEM_PROCESSOR=wasm32 \ + -DCMAKE_C_COMPILER=wasm32posix-cc \ + -DCMAKE_CXX_COMPILER=wasm32posix-c++ \ + -DCMAKE_CXX_FLAGS="-fwasm-exceptions" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \ + -DCMAKE_INSTALL_PREFIX="$INSTALL_DIR" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DCLI11_BUILD_TESTS=OFF \ + -DCLI11_BUILD_EXAMPLES=OFF \ + -DCLI11_BUILD_DOCS=OFF \ + -DCLI11_SINGLE_FILE=OFF + +echo "==> Installing to $INSTALL_DIR..." +cmake --install "$BUILD_DIR" + +for artifact in include/CLI/CLI.hpp share/cmake/CLI11/CLI11Config.cmake; do + if [ ! -f "$INSTALL_DIR/$artifact" ]; then + echo "ERROR: Install incomplete — missing $INSTALL_DIR/$artifact" >&2 + exit 1 + fi +done + +echo "==> CLI11 $CLI11_VERSION install complete!" diff --git a/packages/registry/cli11/build.toml b/packages/registry/cli11/build.toml new file mode 100644 index 0000000000..35f1530b32 --- /dev/null +++ b/packages/registry/cli11/build.toml @@ -0,0 +1,12 @@ +script_path = "packages/registry/cli11/build-cli11.sh" +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +# The fetched `cli11-src/` tree is gitignored; the resolver hashes these +# tracked inputs on a clean checkout. script_path is not implicit — only +# what this list names reaches the cache key. +inputs = [ + "packages/registry/cli11/build-cli11.sh", + "scripts/package-build-roots.sh", +] diff --git a/packages/registry/cli11/package.toml b/packages/registry/cli11/package.toml new file mode 100644 index 0000000000..20f0615946 --- /dev/null +++ b/packages/registry/cli11/package.toml @@ -0,0 +1,30 @@ +# Per-library manifest consumed by `cargo xtask build-deps`. +# Schema: see docs/package-management.md. + +kind = "library" + +# CLI11 command-line parser, header-only with a CMake config package. +# Quickshell's launcher does find_package(CLI11 CONFIG REQUIRED). +name = "cli11" +version = "2.5.0" +kernel_abi = 44 +depends_on = [] + +[source] +url = "https://github.com/CLIUtils/CLI11/archive/refs/tags/v2.5.0.tar.gz" +sha256 = "17e02b4cddc2fa348e5dbdbb582c59a3486fa2b2433e70a0c3bacb871334fd55" +provider = "archive" + +[license] +spdx = "BSD-3-Clause" +url = "https://github.com/CLIUtils/CLI11/blob/v2.5.0/LICENSE" + +[build] +script_path = "packages/registry/cli11/build-cli11.sh" + +[outputs] +headers = ["include/CLI"] +files = [ + "share/cmake/CLI11/CLI11Config.cmake", + "share/cmake/CLI11/CLI11ConfigVersion.cmake", +] diff --git a/packages/registry/coreutils-docs/build-coreutils-docs.sh b/packages/registry/coreutils-docs/build-coreutils-docs.sh index 0b916c285d..fc445f15da 100755 --- a/packages/registry/coreutils-docs/build-coreutils-docs.sh +++ b/packages/registry/coreutils-docs/build-coreutils-docs.sh @@ -23,6 +23,9 @@ WORK="$KANDELO_PACKAGE_WORK_DIR" COREUTILS_DIR="${WASM_POSIX_DEP_COREUTILS_DIR:?coreutils dependency dir required}" COREUTILS_WASM="$COREUTILS_DIR/coreutils.wasm" [ -f "$COREUTILS_WASM" ] || { echo "coreutils.wasm not found under $COREUTILS_DIR" >&2; exit 2; } +KERNEL_DIR="${WASM_POSIX_DEP_KERNEL_DIR:?kernel dependency dir required}" +KERNEL_WASM="$KERNEL_DIR/kandelo-kernel.wasm" +[ -f "$KERNEL_WASM" ] || { echo "kandelo-kernel.wasm not found under $KERNEL_DIR" >&2; exit 2; } command -v help2man >/dev/null || { echo "help2man not on PATH (add pkgs.help2man to flake.nix)" >&2; exit 2; } CAP="$WORK/help-capture"; rm -rf "$CAP" @@ -33,7 +36,7 @@ TSX_TMP="$(mktemp -d /tmp/kandelo-coreutils-docs.XXXXXX)" trap 'rm -rf -- "$TSX_TMP"' EXIT # Capture --help/--version from the real wasm binary running inside Kandelo. TMPDIR="$TSX_TMP" node "$REPO_ROOT/node_modules/tsx/dist/cli.mjs" \ - "$REPO_ROOT/images/vfs/scripts/generate-coreutils-man.ts" "$COREUTILS_WASM" "$CAP" + "$REPO_ROOT/images/vfs/scripts/generate-coreutils-man.ts" "$COREUTILS_WASM" "$CAP" "$KERNEL_WASM" STAGE="$WORK/stage"; rm -rf "$STAGE"; mkdir -p "$STAGE/share/man/man1" WRAP="$WORK/wrap"; rm -rf "$WRAP"; mkdir -p "$WRAP" diff --git a/packages/registry/coreutils-docs/package.toml b/packages/registry/coreutils-docs/package.toml index 7140191626..2dfc137187 100644 --- a/packages/registry/coreutils-docs/package.toml +++ b/packages/registry/coreutils-docs/package.toml @@ -2,7 +2,7 @@ kind = "program" name = "coreutils-docs" version = "9.6" kernel_abi = 7 -depends_on = ["coreutils@9.6"] +depends_on = ["coreutils@9.6", "kernel@0.1.0"] # coreutils-docs has no upstream man pages to vendor: GNU coreutils # generates its own man pages from `--help`/`--version` output via diff --git a/packages/registry/libdrm/build-libdrm.sh b/packages/registry/libdrm/build-libdrm.sh index 8299241bf4..9a30841299 100755 --- a/packages/registry/libdrm/build-libdrm.sh +++ b/packages/registry/libdrm/build-libdrm.sh @@ -166,7 +166,30 @@ cp "$SRC_DIR/include/drm/drm_fourcc.h" "$INSTALL_DIR/include/drm/" cp "$SRC_DIR/xf86drm.h" "$INSTALL_DIR/include/libdrm/" cp "$SRC_DIR/xf86drmMode.h" "$INSTALL_DIR/include/libdrm/" +# A consumer that defines __linux__ (Qt clients must) sends drm.h down +# its Linux branch, which includes and — +# UAPI headers musl does not ship. Install the same forwarding shims +# this script compiles against, so the installed headers are +# self-contained; the .pc's -I${includedir} puts them on the path. +mkdir -p "$INSTALL_DIR/include/linux" "$INSTALL_DIR/include/asm" +cp "$BUILD_DIR/linux/types.h" "$INSTALL_DIR/include/linux/" +cp "$BUILD_DIR/asm/ioctl.h" "$INSTALL_DIR/include/asm/" + +mkdir -p "$INSTALL_DIR/lib/pkgconfig" +cat > "$INSTALL_DIR/lib/pkgconfig/libdrm.pc" < libdrm $LIBDRM_VERSION KMS-subset package complete" diff --git a/packages/registry/libdrm/build.toml b/packages/registry/libdrm/build.toml index addd085c98..9f0dcf4926 100644 --- a/packages/registry/libdrm/build.toml +++ b/packages/registry/libdrm/build.toml @@ -5,4 +5,4 @@ inputs = [ ] repo_url = "https://github.com/Automattic/kandelo.git" commit = "UNPUBLISHED" -revision = 1 +revision = 2 diff --git a/packages/registry/libdrm/package.toml b/packages/registry/libdrm/package.toml index 1f5d1c8b78..0989128269 100644 --- a/packages/registry/libdrm/package.toml +++ b/packages/registry/libdrm/package.toml @@ -66,9 +66,10 @@ install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run thro # KMS-only subset; the per-vendor libdrm_* archives (libdrm_amdgpu.a # etc.) are deliberately not built. libs = ["lib/libdrm.a"] +pkgconfig = ["lib/pkgconfig/libdrm.pc"] # UAPI headers live under include/drm/ (drm.h, drm_mode.h, # drm_fourcc.h); libdrm public headers live under include/libdrm/ # (xf86drm.h, xf86drmMode.h). Consumers link with # `-I$prefix/include -I$prefix/include/libdrm` and # `#include ` matches upstream's pkg-config flags. -headers = ["include/drm", "include/libdrm"] +headers = ["include/drm", "include/libdrm", "include/linux", "include/asm"] diff --git a/packages/registry/qtbase/build-qtbase.sh b/packages/registry/qtbase/build-qtbase.sh new file mode 100644 index 0000000000..e1301ac1ef --- /dev/null +++ b/packages/registry/qtbase/build-qtbase.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# +# Build Qt 6.10.2 QtCore, QtGui, QtWidgets, QtWaylandClient, QtConcurrent +# and QtXml (static) for wasm32-posix-kernel. +# +# Qt 6.10 moved the Wayland client platform plugin out of qtwayland and +# into qtbase (src/plugins/platforms/wayland), so this one package +# delivers the whole client side: QtWaylandClient, the `wayland` QPA +# plugin and the xdg-shell integration. That subdirectory returns early +# unless QT_FEATURE_wayland and QT_FEATURE_waylandscanner both hold — +# the first needs the registry's libwayland, the second the host +# wayland-scanner. It then drives Qt6::qtwaylandscanner, which nixpkgs' +# darwin qtbase does not ship and flake.nix builds instead +# (nix/qtwaylandscanner/). +# +# Qt is the first dependency here that cannot use the meson-bypass +# technique (docs/porting-guide.md): moc, rcc and uic codegen is woven +# through qtbase's sources, so it drives its own CMake. The +# generators run on the build machine and come from the flake's pinned +# Qt through QT_HOST_PATH; CMake rejects a host/target version mismatch, +# so the two versions move together. +# +# Three target adaptations, each for a stated reason: +# +# -D__linux__=1 qsystemdetection.h:134 has no wasm branch and errors +# out. CMAKE_SYSTEM_NAME only settles the build +# system; the headers read preprocessor macros, and +# this toolchain defines __unix__ but not __linux__. +# The sysroot is the musl userland Qt expects. Same +# lever basu, erlang and spidermonkey already use; the +# SDK withholds it globally on purpose (sdk/config.site). +# +# -DQT_LINUXBASE Qt's own switch for a Linux userland without the +# futex syscall (qfutex_p.h:41), which this kernel is. +# It also steers Qt off glibc-only paths — backtrace, +# inotify syscall stubs, sched_getaffinity — toward +# the portable ones. Two upstream sites forgot to +# honour it; src/qmutex-honour-qt-linuxbase.patch +# fixes those. +# +# src/include supplies the empty QtCore includes. +# +# OpenGL is off, so the Wayland client runs on wl_shm alone. Qt refuses +# that through INPUT_opengl rather than FEATURE_opengl: gui/configure.cmake +# raises a hard error unless the input reads exactly 'no'. QtQuick will +# need a graphics API, so the GL stack is the next stage's problem. +# +# On this kernel a wl_shm pool fd must be a DRI prime fd — the +# compositor imports it with gbm_bo_import(GBM_BO_IMPORT_FD), and a +# memfd carries no pixels across the socket. +# src/wayland-shm-gbm-pool.patch backs Qt's shm backing store with a +# renderD128 gbm bo, the same contract foot and GTK are patched to. +# Programs linking libQt6WaylandClient.a link libgbm.a and libdrm.a +# from the sysroot. +# +# Vulkan and LinuxFB are the two backends QtGui switches on by itself +# here — Vulkan against Qt's bundled headers, LinuxFB against a +# framebuffer device this target does not have. Both are named off. The +# rest need a library the resolved prefixes do not carry, so they settle +# off on their own and are not listed. +# +# The markdown reader uses Qt's bundled md4c. Left to auto-detection, +# configure finds the build machine's md4c — the resolved prefixes +# carry none — and Qt6Gui's dependency interface then links that host +# .dylib into every CMake consumer's target executables. +# +# FEATURE_process is on, carried by src/forkfd-generic-on-wasm.patch. +# Qt defines __linux__ here (see above), which routes its bundled forkfd +# to forkfd_linux.c — raw clone(2) does not exist on this kernel, and +# musl's clone() references __post_Fork, pulling musl's _Fork.o and +# duplicating the glue's _Fork at link. The patch keeps wasm on forkfd's +# generic fork() fallback, which routes through the glue's kernel_fork; +# the kernel's waitid(WNOWAIT) and SIGCHLD delivery carry the rest. +# +# Honors the dep-resolver build-script contract (see +# docs/package-management.md). When invoked via +# `cargo xtask build-deps resolve qtbase`, env vars are set by the +# resolver: WASM_POSIX_DEP_OUT_DIR / _VERSION / _SOURCE_URL / +# _SOURCE_SHA256, plus WASM_POSIX_DEP__DIR per depends_on entry: +# WASM_POSIX_DEP_LIBCXX_DIR WASM_POSIX_DEP_HARFBUZZ_DIR +# WASM_POSIX_DEP_ZLIB_DIR WASM_POSIX_DEP_LIBPNG_DIR +# WASM_POSIX_DEP_FREETYPE_DIR WASM_POSIX_DEP_LIBXKBCOMMON_DIR +# WASM_POSIX_DEP_FONTCONFIG_DIR WASM_POSIX_DEP_LIBWAYLAND_DIR + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +SRC_DIR="$SCRIPT_DIR/qtbase-src" + +QTBASE_VERSION="${WASM_POSIX_DEP_VERSION:-6.10.2}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/qtbase-install}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://download.qt.io/archive/qt/6.10/${QTBASE_VERSION}/submodules/qtbase-everywhere-src-${QTBASE_VERSION}.tar.xz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" + +BUILD_DIR="$SCRIPT_DIR/qtbase-build" + +for tool in wasm32posix-c++ wasm32posix-cc cmake ninja qmake strings; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Enter scripts/dev-shell.sh." >&2 + exit 1 + fi +done + +if [ -z "${QT_HOST_PATH:-}" ]; then + echo "ERROR: QT_HOST_PATH not set. Enter scripts/dev-shell.sh." >&2 + exit 1 +fi + +HOST_QT_VERSION="$(qmake -query QT_VERSION)" +if [ "$HOST_QT_VERSION" != "$QTBASE_VERSION" ]; then + echo "ERROR: host Qt $HOST_QT_VERSION cannot cross-build Qt $QTBASE_VERSION." >&2 + echo " Qt requires an exact match. Realign flake.nix's pinned Qt" >&2 + echo " and this recipe's version together." >&2 + exit 1 +fi + +ZLIB_PREFIX="${WASM_POSIX_DEP_ZLIB_DIR:?WASM_POSIX_DEP_ZLIB_DIR not set (must be invoked via cargo xtask build-deps resolve qtbase)}" +FREETYPE_PREFIX="${WASM_POSIX_DEP_FREETYPE_DIR:?WASM_POSIX_DEP_FREETYPE_DIR not set (must be invoked via cargo xtask build-deps resolve qtbase)}" +FONTCONFIG_PREFIX="${WASM_POSIX_DEP_FONTCONFIG_DIR:?WASM_POSIX_DEP_FONTCONFIG_DIR not set (must be invoked via cargo xtask build-deps resolve qtbase)}" +HARFBUZZ_PREFIX="${WASM_POSIX_DEP_HARFBUZZ_DIR:?WASM_POSIX_DEP_HARFBUZZ_DIR not set (must be invoked via cargo xtask build-deps resolve qtbase)}" +LIBPNG_PREFIX="${WASM_POSIX_DEP_LIBPNG_DIR:?WASM_POSIX_DEP_LIBPNG_DIR not set (must be invoked via cargo xtask build-deps resolve qtbase)}" +LIBXKBCOMMON_PREFIX="${WASM_POSIX_DEP_LIBXKBCOMMON_DIR:?WASM_POSIX_DEP_LIBXKBCOMMON_DIR not set (must be invoked via cargo xtask build-deps resolve qtbase)}" +LIBWAYLAND_PREFIX="${WASM_POSIX_DEP_LIBWAYLAND_DIR:?WASM_POSIX_DEP_LIBWAYLAND_DIR not set (must be invoked via cargo xtask build-deps resolve qtbase)}" + +# wasm32posix-c++ resolves libc++ headers through the sysroot. Project a +# private sysroot with the resolved libcxx overlaid: the worktree SDK seed +# is an input tree for every package build and must hold no symlink +# (mariadb pattern — see scripts/package-build-roots.sh). +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/package-build-roots.sh" +SDK_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +SYSROOT="$( + kandelo_package_prepare_private_sysroot qtbase "$SDK_SYSROOT" libcxx +)" +export WASM_POSIX_SYSROOT="$SYSROOT" + +# --- Fetch + verify source --- +if [ ! -d "$SRC_DIR" ]; then + echo "==> Downloading qtbase $QTBASE_VERSION..." + TARBALL="/tmp/qtbase-${QTBASE_VERSION}.tar.xz" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" + if [ -n "$SOURCE_SHA256" ]; then + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - + else + echo "==> (no SOURCE_SHA256 declared; skipping verification)" + fi + mkdir -p "$SRC_DIR" + tar xf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + rm "$TARBALL" + patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/src/qmutex-honour-qt-linuxbase.patch" + patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/src/wayland-shm-gbm-pool.patch" + patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/src/forkfd-generic-on-wasm.patch" + patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/src/wayland-fd-notifier-on-wasm.patch" +fi + +rm -rf "$BUILD_DIR" +# The resolver-created output directory is itself publication authority, so +# a recipe must populate that inode rather than delete and recreate it. +if [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + if [ -n "$(find "$INSTALL_DIR" -mindepth 1 -print -quit)" ]; then + echo "ERROR: qtbase resolver output directory must start empty" >&2 + exit 1 + fi +else + rm -rf "$INSTALL_DIR" +fi + +DEP_PREFIXES=( + "$ZLIB_PREFIX" + "$FREETYPE_PREFIX" + "$FONTCONFIG_PREFIX" + "$HARFBUZZ_PREFIX" + "$LIBPNG_PREFIX" + "$LIBXKBCOMMON_PREFIX" + "$LIBWAYLAND_PREFIX" +) + +# pkg-config must see only the resolved dependency prefixes. PKG_CONFIG_PATH +# prepends and leaves the host search path live, which lets Qt's configure +# find the build machine's glib and libb2; PKG_CONFIG_LIBDIR replaces it. +PKG_CONFIG_LIBDIR="$(IFS=:; printf '%s' "${DEP_PREFIXES[*]/%//lib/pkgconfig}")" +export PKG_CONFIG_LIBDIR + +CMAKE_PREFIXES="$(IFS=';'; printf '%s' "${DEP_PREFIXES[*]}")" + +TARGET_DEFINES="-D__linux__=1 -DQT_LINUXBASE -I$SCRIPT_DIR/src/include" + +# Qt bakes its configure-time install prefix into QtCore as qt_prfxpath, +# which QLibraryInfo reads. Pointing that at the resolver's staging +# directory would write the builder's PID into libQt6Core.a and into +# every program linked against it, so two builds of the same source +# would differ. Configure against the guest path Qt will actually run +# under and relocate at install time; the baked string then describes +# the guest, not this machine. +GUEST_PREFIX="/usr/local/qt6" + +echo "==> Configuring qtbase for wasm32..." +cmake -S "$SRC_DIR" -B "$BUILD_DIR" -G Ninja \ + -DCMAKE_SYSTEM_NAME=Linux \ + -DCMAKE_SYSTEM_PROCESSOR=wasm32 \ + -DCMAKE_C_COMPILER=wasm32posix-cc \ + -DCMAKE_CXX_COMPILER=wasm32posix-c++ \ + -DCMAKE_AR="$(command -v wasm32posix-ar)" \ + -DCMAKE_RANLIB="$(command -v wasm32posix-ranlib)" \ + -DCMAKE_C_FLAGS="$TARGET_DEFINES" \ + -DCMAKE_CXX_FLAGS="$TARGET_DEFINES -fwasm-exceptions" \ + `# MinSizeRel, not Release: the browser compiles the linked module once per worker thread, so code size multiplies across workers (docs/browser-support.md#quickshell-qml-limits).` \ + -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \ + -DCMAKE_PREFIX_PATH="$CMAKE_PREFIXES" \ + -DCMAKE_INSTALL_PREFIX="$GUEST_PREFIX" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DBUILD_SHARED_LIBS=OFF \ + -DQT_HOST_PATH="$QT_HOST_PATH" \ + -DQT_BUILD_EXAMPLES=OFF \ + -DQT_BUILD_TESTS=OFF \ + -DQT_QPA_DEFAULT_PLATFORM=wayland \ + -DFEATURE_gui=ON \ + -DFEATURE_freetype=ON \ + -DFEATURE_system_freetype=ON \ + -DFEATURE_fontconfig=ON \ + -DFEATURE_harfbuzz=ON \ + -DFEATURE_system_harfbuzz=ON \ + -DFEATURE_png=ON \ + -DFEATURE_system_png=ON \ + -DFEATURE_xkbcommon=ON \ + -DINPUT_opengl=no \ + -DFEATURE_vulkan=OFF \ + -DFEATURE_linuxfb=OFF \ + -DFEATURE_widgets=ON \ + -DFEATURE_network=ON \ + -DFEATURE_ssl=OFF \ + -DFEATURE_brotli=OFF \ + -DFEATURE_sql=OFF \ + -DFEATURE_testlib=OFF \ + -DFEATURE_dbus=OFF \ + -DFEATURE_process=ON \ + -DFEATURE_processenvironment=ON \ + -DFEATURE_qtwaylandscanner=ON \ + -DFEATURE_glib=OFF \ + -DFEATURE_icu=OFF \ + -DFEATURE_zstd=OFF \ + -DFEATURE_system_libb2=OFF \ + -DFEATURE_system_zlib=ON \ + -DFEATURE_system_pcre2=OFF \ + -DFEATURE_system_textmarkdownreader=OFF + +echo "==> Building qtbase..." +cmake --build "$BUILD_DIR" -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + +echo "==> Installing to $INSTALL_DIR..." +cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR" + +for lib in libQt6Core.a libQt6Gui.a libQt6Widgets.a libQt6Network.a libQt6WaylandClient.a libQt6Concurrent.a libQt6Xml.a libQt6BundledPcre2.a; do + if [ ! -f "$INSTALL_DIR/lib/$lib" ]; then + echo "ERROR: Build failed — library not found at $INSTALL_DIR/lib/$lib" >&2 + exit 1 + fi +done + +# The staging directory name carries the builder's PID, so a leak here +# makes the package non-reproducible. +if strings "$INSTALL_DIR/lib/libQt6Core.a" | grep -q "build-stage"; then + echo "ERROR: libQt6Core.a embeds the resolver staging path" >&2 + strings "$INSTALL_DIR/lib/libQt6Core.a" | grep "build-stage" | head -3 >&2 + exit 1 +fi + +echo "==> qtbase $QTBASE_VERSION built for wasm32" diff --git a/packages/registry/qtbase/build.toml b/packages/registry/qtbase/build.toml new file mode 100644 index 0000000000..0cace7ae14 --- /dev/null +++ b/packages/registry/qtbase/build.toml @@ -0,0 +1,17 @@ +script_path = "packages/registry/qtbase/build-qtbase.sh" +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "9072b5384" +revision = 8 + +# The fetched `qtbase-src/` tree is gitignored; the resolver hashes these +# tracked inputs on a clean checkout. script_path is not implicit — only +# what this list names reaches the cache key. +inputs = [ + "packages/registry/qtbase/build-qtbase.sh", + "packages/registry/qtbase/src/qmutex-honour-qt-linuxbase.patch", + "packages/registry/qtbase/src/wayland-shm-gbm-pool.patch", + "packages/registry/qtbase/src/forkfd-generic-on-wasm.patch", + "packages/registry/qtbase/src/wayland-fd-notifier-on-wasm.patch", + "packages/registry/qtbase/src/include/linux/fs.h", + "scripts/package-build-roots.sh", +] diff --git a/packages/registry/qtbase/package.toml b/packages/registry/qtbase/package.toml new file mode 100644 index 0000000000..8da8d37bdf --- /dev/null +++ b/packages/registry/qtbase/package.toml @@ -0,0 +1,85 @@ +# Per-library manifest consumed by `cargo xtask build-deps`. +# Schema: see docs/package-management.md. +# +# Qt 6 QtCore, QtGui, QtWidgets, QtWaylandClient, QtConcurrent and QtXml, static, +# for wasm32. The base of the Qt stack Omarchy v4 needs: v4 folds Waybar, +# Walker, Mako, SwayOSD, hyprlock, hypridle, swaybg and polkit-gnome into +# a single Quickshell QML process, and Quickshell is a QtQuick client. +# +# Qt 6.10 moved the Wayland client platform plugin into qtbase, so the +# `wayland` QPA plugin and the xdg-shell integration ship from here +# rather than from a separate qtwayland package. +# +# QtWidgets is on — Quickshell's QuickControls2 style impls need it. +# QtNetwork is on without ssl — Quickshell's IPC rides QLocalSocket. +# QtSql, QtTest and QtDBus are off, and so is +# OpenGL — the Wayland client runs on wl_shm; QtQuick renders through +# its software scenegraph, so no GL surface is required. QProcess is on: +# forkfd-generic-on-wasm.patch keeps Qt's bundled forkfd on its generic +# fork() fallback, which routes through the glue's kernel_fork. See +# build-qtbase.sh. +# +# PCRE2 stays bundled. QRegularExpression needs the 16-bit build +# (PCRE2_CODE_UNIT_WIDTH=16) and the registry's pcre2 package ships only +# libpcre2-8.a. zlib is taken from the registry. +kind = "library" + +name = "qtbase" +version = "6.10.2" +kernel_abi = 44 +depends_on = [ + "libcxx@21.1.7", + "zlib@1.3.1", + "freetype@2.13.3", + "fontconfig@2.15.0", + "harfbuzz@10.1.0", + "libpng@1.6.43", + "libxkbcommon@1.7.0", + "libwayland@1.24.0", +] + +[source] +url = "https://download.qt.io/archive/qt/6.10/6.10.2/submodules/qtbase-everywhere-src-6.10.2.tar.xz" +sha256 = "aeb78d29291a2b5fd53cb55950f8f5065b4978c25fb1d77f627d695ab9adf21e" +provider = "archive" + +[license] +spdx = "LGPL-3.0-only" +url = "https://code.qt.io/cgit/qt/qtbase.git/tree/LICENSES/LGPL-3.0-only.txt?h=v6.10.2" + +[build] +script_path = "packages/registry/qtbase/build-qtbase.sh" + +# Qt's cross-build runs moc, rcc and uic on the build machine and reads +# them from QT_HOST_PATH/libexec, so they are not on PATH. qmake is, and +# its version identifies the host Qt those generators belong to. +# +# CMake needs an exact host/target version match, but the resolver only +# understands `>=` (deps-management-v2 decision 11). The floor is +# declared here and build-qtbase.sh rejects any inequality outright. +[[host_tools]] +name = "qmake" +version_constraint = ">=6.10.2" +probe = { args = ["-query", "QT_VERSION"], version_regex = "(\\d+\\.\\d+\\.\\d+)" } +install_hints = { darwin = "provided by flake.nix (pkgs.qt6.qtbase); enter scripts/dev-shell.sh", linux = "provided by flake.nix (pkgs.qt6.qtbase); enter scripts/dev-shell.sh" } + +[outputs] +libs = [ + "lib/libQt6Core.a", + "lib/libQt6Gui.a", + "lib/libQt6Widgets.a", + "lib/libQt6Network.a", + "lib/libQt6WaylandClient.a", + "lib/libQt6Concurrent.a", + "lib/libQt6Xml.a", + "lib/libQt6BundledPcre2.a", +] +headers = [ + "include/QtCore", + "include/QtGui", + "include/QtWidgets", + "include/QtNetwork", + "include/QtWaylandClient", + "include/QtConcurrent", + "include/QtXml", +] diff --git a/packages/registry/qtbase/src/forkfd-generic-on-wasm.patch b/packages/registry/qtbase/src/forkfd-generic-on-wasm.patch new file mode 100644 index 0000000000..9c3072c817 --- /dev/null +++ b/packages/registry/qtbase/src/forkfd-generic-on-wasm.patch @@ -0,0 +1,11 @@ +--- a/src/3rdparty/forkfd/forkfd.c 2026-08-31 11:28:05 ++++ b/src/3rdparty/forkfd/forkfd.c 2026-08-31 11:28:13 +@@ -928,7 +928,7 @@ + + #if defined(__FreeBSD__) && __FreeBSD__ >= 9 + # include "forkfd_freebsd.c" +-#elif defined(__linux__) ++#elif defined(__linux__) && !defined(__wasm__) + # include "forkfd_linux.c" + #else + int system_has_forkfd() diff --git a/packages/registry/qtbase/src/include/linux/fs.h b/packages/registry/qtbase/src/include/linux/fs.h new file mode 100644 index 0000000000..41524dd5cb --- /dev/null +++ b/packages/registry/qtbase/src/include/linux/fs.h @@ -0,0 +1,16 @@ +/* + * linux/fs.h — build shim for the Qt port. + * + * Kandelo has no Linux kernel and vendors no . QtCore + * includes it from qfilesystemengine_unix.cpp under Q_OS_LINUX for one + * symbol, FICLONE, and already defines FICLONE itself when the header + * does not carry it (qfilesystemengine_unix.cpp:68). _IOW comes from + * , which that file includes two lines earlier. + * + * The reflink copy is attempted first and falls back to a read/write + * copy when the ioctl fails, which is what this kernel returns. An empty + * header is the accurate description: the interface is absent. + */ +#ifndef KANDELO_SHIM_LINUX_FS_H +#define KANDELO_SHIM_LINUX_FS_H +#endif diff --git a/packages/registry/qtbase/src/qmutex-honour-qt-linuxbase.patch b/packages/registry/qtbase/src/qmutex-honour-qt-linuxbase.patch new file mode 100644 index 0000000000..79f2e591b1 --- /dev/null +++ b/packages/registry/qtbase/src/qmutex-honour-qt-linuxbase.patch @@ -0,0 +1,39 @@ +Make QBasicMutex::FutexAlwaysAvailable honour QT_LINUXBASE. + +qfutex_p.h:41 selects the raw-syscall Linux futex on +`defined(Q_OS_LINUX) && !defined(QT_LINUXBASE)`, so QT_LINUXBASE is Qt's +own lever for a Linux userland without the futex syscall. Two places +forgot it and test Q_OS_LINUX alone: + + qmutex.h:27 FutexAlwaysAvailable is hardcoded true + qmutex.cpp:846 unlockInternal() is declared Q_UNREACHABLE() + +Defining QT_LINUXBASE on Linux is therefore self-contradictory upstream: +qfutex_p.h hands out QtDummyFutex while qmutex.h still claims futexes are +always present, and qmutex.cpp:858 static-asserts the two against each +other. + +Kandelo has no futex syscall, so `false` is the truthful value here. + +--- a/src/corelib/thread/qmutex.h ++++ b/src/corelib/thread/qmutex.h +@@ -25,7 +25,7 @@ + Q_DISABLE_COPY_MOVE(QBasicMutex) + protected: + static constexpr bool FutexAlwaysAvailable = +-#if defined(Q_OS_FREEBSD) || defined(Q_OS_LINUX) || defined(Q_OS_WIN) // these platforms use futex ++#if (defined(Q_OS_FREEBSD) || defined(Q_OS_LINUX) || defined(Q_OS_WIN)) && !defined(QT_LINUXBASE) // these platforms use futex + true + #else + false +--- a/src/corelib/thread/qmutex.cpp ++++ b/src/corelib/thread/qmutex.cpp +@@ -843,7 +843,7 @@ + Q_ASSERT(copy); //we must be locked + Q_ASSERT(copy != dummyLocked()); // testAndSetRelease(dummyLocked(), 0) failed + +-# if defined(Q_OS_FREEBSD) || defined(Q_OS_LINUX) || defined(Q_OS_WIN) ++# if (defined(Q_OS_FREEBSD) || defined(Q_OS_LINUX) || defined(Q_OS_WIN)) && !defined(QT_LINUXBASE) + // these platforms always have futex and have never called this function + // from inline code + Q_UNREACHABLE(); diff --git a/packages/registry/qtbase/src/wayland-fd-notifier-on-wasm.patch b/packages/registry/qtbase/src/wayland-fd-notifier-on-wasm.patch new file mode 100644 index 0000000000..ea01ee3365 --- /dev/null +++ b/packages/registry/qtbase/src/wayland-fd-notifier-on-wasm.patch @@ -0,0 +1,131 @@ +Pump the wayland display fd from the main loop, not reader threads. + +On this wasm port every guest thread is a Web Worker, and Chromium +compiles the whole program module again for each worker — ~600 MB of +machine code per thread for a Qt client (see +docs/browser-support.md#quickshell-qml-limits). Both QPA pump threads go: +the main thread becomes the display's only reader, woken by a +QSocketNotifier on the display fd, and it dispatches the default and +frame event queues itself in flushRequests() — which the dispatcher's +aboutToBlock/awake hooks already call on every loop turn. The threads +cannot be removed one at a time: with any second reader thread parked in +poll() after wl_display_prepare_read(), the main thread's +wl_display_read_events() blocks on libwayland's reader barrier until +that thread reads, freezing input. waitForFrameSync() would spin its +timeout without a dispatcher thread, but only the wayland-egl and Vulkan +integrations call it and this port renders through wl_shm. + +diff -ru a/src/plugins/platforms/wayland/qwaylanddisplay.cpp b/src/plugins/platforms/wayland/qwaylanddisplay.cpp +--- a/src/plugins/platforms/wayland/qwaylanddisplay.cpp 2026-09-02 14:40:42 ++++ b/src/plugins/platforms/wayland/qwaylanddisplay.cpp 2026-09-02 15:02:20 +@@ -59,6 +59,7 @@ + #include + + #include ++#include + + #include + #include +@@ -349,12 +350,6 @@ + + QWaylandDisplay::~QWaylandDisplay(void) + { +- if (m_eventThread) +- m_eventThread->stop(); +- +- if (m_frameEventQueueThread) +- m_frameEventQueueThread->stop(); +- + if (mSyncCallback) + wl_callback_destroy(mSyncCallback); + +@@ -418,10 +413,8 @@ + void QWaylandDisplay::reconnect() + { + qCWarning(lcQpaWayland) << "Attempting wayland reconnect"; +- m_eventThread->stop(); +- m_frameEventQueueThread->stop(); +- m_eventThread->wait(); +- m_frameEventQueueThread->wait(); ++ delete m_readNotifier; ++ m_readNotifier = nullptr; + + qDeleteAll(mWaitingScreens); + mWaitingScreens.clear(); +@@ -522,27 +515,48 @@ + + void QWaylandDisplay::flushRequests() + { +- m_eventThread->readAndDispatchEvents(); ++ while (wl_display_prepare_read(mDisplay) != 0) { ++ if (wl_display_dispatch_pending(mDisplay) < 0) { ++ checkWaylandError(); ++ return; ++ } ++ } ++ wl_display_read_events(mDisplay); ++ ++ if (wl_display_dispatch_pending(mDisplay) < 0) { ++ checkWaylandError(); ++ return; ++ } ++ ++ if (m_frameEventQueue ++ && wl_display_dispatch_queue_pending(mDisplay, m_frameEventQueue) < 0) { ++ checkWaylandError(); ++ return; ++ } ++ ++ wl_display_flush(mDisplay); + } + +-// We have to wait until we have an eventDispatcher before creating the eventThread, +-// otherwise forceRoundTrip() may block inside _events_read() because eventThread is +-// polling. ++// On this wasm port every guest thread is a Web Worker holding its own ++// compiled copy of the whole program module, so both event pump threads are ++// gone: the main thread is the display's only reader, woken by a ++// QSocketNotifier on the display fd, and it dispatches the default and the ++// frame event queues itself in flushRequests() — which the dispatcher's ++// aboutToBlock/awake hooks already call on every loop turn. A second reader ++// thread would also park the main thread's wl_display_read_events() on ++// libwayland's reader barrier until that thread reads. waitForFrameSync() ++// would spin its timeout without a dispatcher thread, but only the ++// wayland-egl and Vulkan integrations call it and this port renders through ++// wl_shm. + void QWaylandDisplay::initEventThread() + { +- m_eventThread.reset( +- new EventThread(mDisplay, /* default queue */ nullptr, EventThread::EmitToDispatch)); +- connect(m_eventThread.get(), &EventThread::needReadAndDispatch, this, +- &QWaylandDisplay::flushRequests, Qt::QueuedConnection); +- connect(m_eventThread.get(), &EventThread::waylandError, this, +- &QWaylandDisplay::checkWaylandError, Qt::QueuedConnection); +- m_eventThread->start(); ++ m_readNotifier = new QSocketNotifier( ++ wl_display_get_fd(mDisplay), QSocketNotifier::Read, this); ++ connect(m_readNotifier, &QSocketNotifier::activated, this, ++ &QWaylandDisplay::flushRequests); + + // wl_display_disconnect() free this. + m_frameEventQueue = wl_display_create_queue(mDisplay); +- m_frameEventQueueThread.reset( +- new EventThread(mDisplay, m_frameEventQueue, EventThread::SelfDispatch)); +- m_frameEventQueueThread->start(); + } + + void QWaylandDisplay::checkWaylandError() +diff -ru a/src/plugins/platforms/wayland/qwaylanddisplay_p.h b/src/plugins/platforms/wayland/qwaylanddisplay_p.h +--- a/src/plugins/platforms/wayland/qwaylanddisplay_p.h 2026-09-02 14:40:42 ++++ b/src/plugins/platforms/wayland/qwaylanddisplay_p.h 2026-09-02 15:02:20 +@@ -304,9 +304,8 @@ + void *data = nullptr; + }; + struct wl_display *mDisplay = nullptr; +- std::unique_ptr m_eventThread; ++ QSocketNotifier *m_readNotifier = nullptr; + wl_event_queue *m_frameEventQueue = nullptr; +- QScopedPointer m_frameEventQueueThread; + QList mWaitingScreens; + QList mScreens; + QPlatformPlaceholderScreen *mPlaceholderScreen = nullptr; diff --git a/packages/registry/qtbase/src/wayland-shm-gbm-pool.patch b/packages/registry/qtbase/src/wayland-shm-gbm-pool.patch new file mode 100644 index 0000000000..77b90e7cf9 --- /dev/null +++ b/packages/registry/qtbase/src/wayland-shm-gbm-pool.patch @@ -0,0 +1,147 @@ +diff -ur a/src/plugins/platforms/wayland/qwaylandshmbackingstore.cpp b/src/plugins/platforms/wayland/qwaylandshmbackingstore.cpp +--- a/src/plugins/platforms/wayland/qwaylandshmbackingstore.cpp 2026-08-31 10:04:19 ++++ b/src/plugins/platforms/wayland/qwaylandshmbackingstore.cpp 2026-08-31 10:04:46 +@@ -21,6 +21,7 @@ + #include + #include + #include ++#include + + #ifdef Q_OS_LINUX + # include +@@ -53,54 +54,65 @@ + const QSize &size, QImage::Format format, qreal scale, wl_event_queue *customEventQueue) + : mDirtyRegion(QRect(QPoint(0, 0), size / scale)) + { +- int stride = size.width() * 4; +- int alloc = stride * size.height(); +- int fd = -1; ++ /* On wasm-posix-kernel the compositor imports a wl_shm pool with ++ * gbm_bo_import(GBM_BO_IMPORT_FD), so the pool fd has to be a DRI ++ * prime fd: a memfd is private to the process that made it and ++ * carries no pixels across the socket. Same allocation as foot's ++ * shm.c and GTK's gdkdisplay-wayland.c. */ ++ static struct gbm_device *gbm = nullptr; ++ if (!gbm) { ++ int drmFd = open("/dev/dri/renderD128", O_RDWR | O_CLOEXEC); ++ if (drmFd < 0) { ++ qErrnoWarning("QWaylandShmBuffer: open /dev/dri/renderD128 failed"); ++ return; ++ } ++ gbm = gbm_create_device(drmFd); ++ if (!gbm) { ++ qWarning("QWaylandShmBuffer: gbm_create_device failed"); ++ close(drmFd); ++ return; ++ } ++ } + +-#ifdef SYS_memfd_create +- fd = syscall(SYS_memfd_create, "wayland-shm", MFD_CLOEXEC | MFD_ALLOW_SEALING); +- if (fd >= 0) +- fcntl(fd, F_ADD_SEALS, F_SEAL_SHRINK | F_SEAL_SEAL); +-#endif +- +- std::unique_ptr filePointer; +- bool opened; +- +- if (fd == -1) { +- auto tmpFile = +- std::make_unique(QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + +- QLatin1String("/wayland-shm-XXXXXX")); +- opened = tmpFile->open(); +- filePointer = std::move(tmpFile); +- } else { +- auto file = std::make_unique(); +- opened = file->open(fd, QIODevice::ReadWrite | QIODevice::Unbuffered, QFile::AutoCloseHandle); +- filePointer = std::move(file); ++ const bool hasAlpha = format != QImage::Format_RGB32 ++ && format != QImage::Format_RGBX8888; ++ mBo = gbm_bo_create(gbm, size.width(), size.height(), ++ hasAlpha ? GBM_FORMAT_ARGB8888 : GBM_FORMAT_XRGB8888, ++ GBM_BO_USE_LINEAR | GBM_BO_USE_SCANOUT); ++ if (!mBo) { ++ qErrnoWarning("QWaylandShmBuffer: gbm_bo_create failed"); ++ return; + } +- // NOTE beginPaint assumes a new buffer be all zeroes, which QFile::resize does. +- if (!opened || !filePointer->resize(alloc)) { +- qWarning("QWaylandShmBuffer: failed: %s", qUtf8Printable(filePointer->errorString())); ++ ++ uint32_t stride = 0; ++ uchar *data = (uchar *) gbm_bo_map(mBo, 0, 0, size.width(), size.height(), ++ 0, &stride, &mBoMapData); ++ if (!data) { ++ qErrnoWarning("QWaylandShmBuffer: gbm_bo_map failed"); ++ gbm_bo_destroy(mBo); ++ mBo = nullptr; + return; + } +- fd = filePointer->handle(); ++ int alloc = int(stride) * size.height(); + +- // map ourselves: QFile::map() will unmap when the object is destroyed, +- // but we want this mapping to persist (unmapping in destructor) +- uchar *data = (uchar *) +- mmap(nullptr, alloc, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); +- if (data == (uchar *) MAP_FAILED) { +- qErrnoWarning("QWaylandShmBuffer: mmap failed"); ++ int fd = gbm_bo_get_fd(mBo); ++ if (fd < 0) { ++ qErrnoWarning("QWaylandShmBuffer: gbm_bo_get_fd failed"); ++ gbm_bo_unmap(mBo, mBoMapData); ++ gbm_bo_destroy(mBo); ++ mBo = nullptr; + return; + } + + QWaylandShm* shm = display->shm(); + wl_shm_format wl_format = shm->formatFrom(format); +- mImage = QImage(data, size.width(), size.height(), stride, format); ++ mImage = QImage(data, size.width(), size.height(), int(stride), format); + mImage.setDevicePixelRatio(scale); + + mShmPool = wl_shm_create_pool(shm->object(), fd, alloc); ++ close(fd); /* wl_shm dup'd it into the pool */ + init(wl_shm_pool_create_buffer(mShmPool,0, size.width(), size.height(), +- stride, wl_format)); ++ int(stride), wl_format)); + if (customEventQueue) + wl_proxy_set_queue(reinterpret_cast(buffer()), customEventQueue); + } +@@ -108,10 +120,12 @@ + QWaylandShmBuffer::~QWaylandShmBuffer(void) + { + delete mMarginsImage; +- if (mImage.constBits()) +- munmap((void *) mImage.constBits(), mImage.sizeInBytes()); + if (mShmPool) + wl_shm_pool_destroy(mShmPool); ++ if (mBo) { ++ gbm_bo_unmap(mBo, mBoMapData); ++ gbm_bo_destroy(mBo); ++ } + } + + QImage *QWaylandShmBuffer::imageInsideMargins(const QMargins &marginsIn) +diff -ur a/src/plugins/platforms/wayland/qwaylandshmbackingstore_p.h b/src/plugins/platforms/wayland/qwaylandshmbackingstore_p.h +--- a/src/plugins/platforms/wayland/qwaylandshmbackingstore_p.h 2026-08-31 10:04:19 ++++ b/src/plugins/platforms/wayland/qwaylandshmbackingstore_p.h 2026-08-31 10:11:58 +@@ -22,6 +22,8 @@ + #include + #include + ++struct gbm_bo; ++ + QT_BEGIN_NAMESPACE + + namespace QtWaylandClient { +@@ -49,6 +51,8 @@ + private: + QImage mImage; + struct wl_shm_pool *mShmPool = nullptr; ++ struct gbm_bo *mBo = nullptr; ++ void *mBoMapData = nullptr; + QMargins mMargins; + QImage *mMarginsImage = nullptr; + QRegion mDirtyRegion; diff --git a/packages/registry/qtbase/test/build-gui-smoke.sh b/packages/registry/qtbase/test/build-gui-smoke.sh new file mode 100755 index 0000000000..9ac69672fa --- /dev/null +++ b/packages/registry/qtbase/test/build-gui-smoke.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Link qt_gui_smoke.cpp against the resolved qtbase and write an +# instrumented wasm32 program to $1. +# +# The link line is written out rather than derived from Qt's CMake +# package because the consumer is a plain SDK compile, not a CMake +# project. Two properties it has to preserve: +# +# -D__linux__=1 -DQT_LINUXBASE The same two defines build-qtbase.sh +# configures with. Qt's public headers +# read them: without the first, +# qsystemdetection.h:134 errors out; +# without the second the headers and +# the archives disagree about the futex. +# +# -lc++ -lc++abi last The SDK driver places its own -lc++ +# before the archives, so libunwind's +# __wasm_lpad_context stays undefined +# unless these come after them. +# +# Plugins precede the modules they extend, and every library follows its +# users: a static link resolves in one pass. + +set -euo pipefail + +OUT="${1:?usage: build-gui-smoke.sh }" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +for tool in wasm32posix-c++ cargo wasm-objdump; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Enter scripts/dev-shell.sh." >&2 + exit 1 + fi +done + +HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" + +# `build-deps path` answers a different cache root than `resolve` writes +# under WASM_POSIX_RESOLUTION_POLICY=source-only-v1, so take the prefix +# from resolve, which prints it and is a no-op once cached. +prefix() { + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet \ + -- build-deps resolve "$1") | tail -1 +} + +QTBASE="$(prefix qtbase)" +FREETYPE="$(prefix freetype)" +FONTCONFIG="$(prefix fontconfig)" +HARFBUZZ="$(prefix harfbuzz)" +LIBPNG="$(prefix libpng)" +LIBXKBCOMMON="$(prefix libxkbcommon)" +LIBWAYLAND="$(prefix libwayland)" +LIBXML2="$(prefix libxml2)" +LIBFFI="$(prefix libffi)" +LIBICONV="$(prefix libiconv)" +ZLIB="$(prefix zlib)" +LIBCXX="$(prefix libcxx)" + +RAW="${OUT%.wasm}.raw.wasm" +rm -f "$OUT" "$RAW" + +wasm32posix-c++ \ + -O2 -std=c++17 -fwasm-exceptions \ + -D__linux__=1 -DQT_LINUXBASE \ + -I"$QTBASE/include" \ + -I"$QTBASE/include/QtCore" \ + -I"$QTBASE/include/QtGui" \ + "$SCRIPT_DIR/qt_gui_smoke.cpp" \ + "$QTBASE/plugins/platforms/libqwayland.a" \ + "$QTBASE/plugins/platforms/libqoffscreen.a" \ + "$QTBASE/plugins/wayland-shell-integration/libxdg-shell.a" \ + "$QTBASE/lib/libQt6WaylandClient.a" \ + "$QTBASE/lib/libQt6Gui.a" \ + "$QTBASE/lib/libQt6Core.a" \ + "$QTBASE/lib/libQt6BundledPcre2.a" \ + "$FONTCONFIG/lib/libfontconfig.a" \ + "$FREETYPE/lib/libfreetype.a" \ + "$HARFBUZZ/lib/libharfbuzz.a" \ + "$LIBPNG/lib/libpng16.a" \ + "$LIBXML2/lib/libxml2.a" \ + "$LIBFFI/lib/libffi.a" \ + "$LIBICONV/lib/libiconv.a" \ + "$LIBXKBCOMMON/lib/libxkbcommon.a" \ + "$LIBWAYLAND/lib/libwayland-client.a" \ + "$LIBWAYLAND/lib/libwayland-cursor.a" \ + "${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}/lib/libgbm.a" \ + "${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}/lib/libdrm.a" \ + "$ZLIB/lib/libz.a" \ + "$LIBCXX/lib/libc++.a" \ + "$LIBCXX/lib/libc++abi.a" \ + -o "$RAW" + +# The SDK link does not fail on an undefined symbol — it leaves one as a +# host import, and the program traps only if that path ever runs. A +# missing archive therefore reaches the kernel as +# `Unimplemented import: env.`. These three are the runtime's own +# surface; anything else here is a library the line forgot. +UNDEFINED="$( + wasm-objdump -j Import -x "$RAW" | + grep -o 'env\.[A-Za-z_0-9]*' | + sort -u | + grep -v -x -e 'env.memory' -e 'env.__channel_base' -e 'env.__cxa_thread_atexit' || + true +)" +if [ -n "$UNDEFINED" ]; then + echo "ERROR: the link left library symbols undefined:" >&2 + printf ' %s\n' $UNDEFINED >&2 + exit 1 +fi + +bash "$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" "$RAW" -o "$OUT" +rm -f "$RAW" + +echo "QT_GUI_SMOKE_BUILT $OUT" diff --git a/packages/registry/qtbase/test/qt_gui_smoke.cpp b/packages/registry/qtbase/test/qt_gui_smoke.cpp new file mode 100644 index 0000000000..9501dd9e53 --- /dev/null +++ b/packages/registry/qtbase/test/qt_gui_smoke.cpp @@ -0,0 +1,143 @@ +/* + * Stage-2 gate for the Qt port: QtGui and the Wayland platform plugin run + * on the kernel. + * + * Qt 6.10 moved the Wayland client platform plugin into qtbase + * (src/plugins/platforms/wayland), so QtWaylandClient and the `wayland` + * QPA key come out of this one package. A static Qt registers a plugin + * only when the program imports it, so Q_IMPORT_PLUGIN below is what + * proves the plugin archive linked. + * + * The paint passes run on the offscreen platform: they need no + * compositor, and they exercise the two parts of QtGui that had to be + * cross-compiled rather than merely linked — the raster engine, and the + * font stack reaching fontconfig, FreeType and HarfBuzz. + * + * The caller supplies the font through FONTCONFIG_FILE, as the gtk3, + * foot, mako and waybar smokes do. What that proves is the matching + * path: fontconfig finds the file, FreeType rasterises it, and the + * glyphs reach the image. It does not prove enumeration — QFontDatabase + * reports only Qt's three generic families here, never the staged + * family name, so this asserts ink rather than a name. + * + * The QProcess pass re-execs this program with --child through Qt's + * bundled forkfd. forkfd-generic-on-wasm.patch keeps forkfd off its + * clone(2) path, so this is the proof that its generic fork() fallback + * carries a spawn, an exec, SIGCHLD reaping and the child's pipes on + * the kernel — the surface Quickshell's QProcess use stands on. + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +Q_IMPORT_PLUGIN(QOffscreenIntegrationPlugin) +Q_IMPORT_PLUGIN(QWaylandIntegrationPlugin) +Q_IMPORT_PLUGIN(QWaylandXdgShellIntegrationPlugin) + +static bool keyIsRegistered(const QString &iid, const QString &key) +{ + for (const QStaticPlugin &plugin : QPluginLoader::staticPlugins()) { + const QJsonObject metaData = plugin.metaData(); + if (metaData.value(QStringLiteral("IID")).toString() != iid) + continue; + const QJsonArray keys = + metaData.value(QStringLiteral("MetaData")).toObject() + .value(QStringLiteral("Keys")).toArray(); + for (const QJsonValue &candidate : keys) { + if (candidate.toString().compare(key, Qt::CaseInsensitive) == 0) + return true; + } + } + return false; +} + +static int inkedPixels(const QImage &image) +{ + int inked = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (image.pixel(x, y) != 0xff000000u) + ++inked; + } + } + return inked; +} + +int main(int argc, char **argv) +{ + if (argc > 1 && std::strcmp(argv[1], "--child") == 0) { + std::printf("QT_PROCESS_CHILD_OK\n"); + return 0; + } + + qputenv("QT_QPA_PLATFORM", "offscreen"); + QGuiApplication app(argc, argv); + + std::printf("QT_VERSION=%s\n", qVersion()); + std::printf("PLATFORM=%s\n", qPrintable(app.platformName())); + const QString platformIid = QStringLiteral( + "org.qt-project.Qt.QPA.QPlatformIntegrationFactoryInterface.5.3"); + const QString shellIid = QStringLiteral( + "org.qt-project.Qt.WaylandClient.QWaylandShellIntegrationFactoryInterface.5.3"); + std::printf("WAYLAND_PLUGIN=%s\n", + keyIsRegistered(platformIid, QStringLiteral("wayland")) ? "yes" : "no"); + std::printf("XDG_SHELL_PLUGIN=%s\n", + keyIsRegistered(shellIid, QStringLiteral("xdg-shell")) ? "yes" : "no"); + + const QStringList families = QFontDatabase::families(); + std::printf("FONT_FAMILIES=%lld\n", static_cast(families.size())); + + QImage canvas(96, 48, QImage::Format_ARGB32); + canvas.fill(Qt::black); + QPainter painter(&canvas); + painter.fillRect(16, 8, 32, 16, Qt::red); + painter.end(); + + std::printf("CORNER=%08x\n", canvas.pixel(0, 0)); + std::printf("CENTRE=%08x\n", canvas.pixel(32, 16)); + + QImage typeset(96, 48, QImage::Format_ARGB32); + typeset.fill(Qt::black); + QPainter scribe(&typeset); + QFont font(QStringLiteral("Monospace")); + font.setPixelSize(24); + scribe.setFont(font); + scribe.setPen(Qt::white); + scribe.drawText(typeset.rect(), Qt::AlignCenter, QStringLiteral("Kan")); + scribe.end(); + + const int inked = inkedPixels(typeset); + std::printf("GLYPH_PIXELS=%d\n", inked); + + QProcess child; + child.start(QString::fromLocal8Bit(argv[0]), + { QStringLiteral("--child") }); + const bool finished = child.waitForFinished(30000); + const QByteArray childOut = child.readAllStandardOutput(); + std::printf("QPROCESS_EXIT=%d\n", finished ? child.exitCode() : -1); + std::fwrite(childOut.constData(), 1, size_t(childOut.size()), stdout); + + if (app.platformName() != QLatin1String("offscreen")) return 1; + if (!keyIsRegistered(platformIid, QStringLiteral("wayland"))) return 2; + if (!keyIsRegistered(shellIid, QStringLiteral("xdg-shell"))) return 7; + if (canvas.pixel(0, 0) != 0xff000000u) return 3; + if (canvas.pixel(32, 16) != 0xffff0000u) return 4; + if (families.isEmpty()) return 5; + if (inked == 0) return 6; + if (!finished || child.exitCode() != 0) return 8; + if (!childOut.contains("QT_PROCESS_CHILD_OK")) return 9; + + std::printf("QT_GUI_SMOKE_OK\n"); + return 0; +} diff --git a/packages/registry/qtdeclarative/build-qtdeclarative.sh b/packages/registry/qtdeclarative/build-qtdeclarative.sh new file mode 100644 index 0000000000..a799c54ca0 --- /dev/null +++ b/packages/registry/qtdeclarative/build-qtdeclarative.sh @@ -0,0 +1,201 @@ +#!/usr/bin/env bash +# +# Cross-compile Qt 6 QtQml, QtQmlModels and QtQuick for wasm32. +# +# The scenegraph renders through the software adaptation: the kernel's +# GL command stream replays onto WebGL2, which only the browser host +# has, and a renderer one host cannot run would break dual-host parity. +# The software adaptation rasterises on the CPU into the same wl_shm +# pools qtgallery paints through. Consumers select it with +# QT_QUICK_BACKEND=software. +# +# qml-type-loader-thread is off, taking Qt's supported synchronous +# type-loading path (qqmlthread_stub.cpp — the same shape upstream's +# single-threaded wasm port uses). On this host every guest pthread is +# a Web Worker and Chromium compiles the whole program module again per +# worker, so the loader thread costs a full compiled copy of every QML +# consumer (~600 MB for quickshell) and its per-reload thread churn +# spikes tab memory on each Quickshell config reload. +# +# qml-network and qml-debug are on — Quickshell's QsIntercept +# subclasses QQmlNetworkAccessManagerFactory and its launcher calls +# QQmlDebuggingEnabler, and QtQml only compiles either with its +# feature. Both ride the QtNetwork qtbase now builds. +# +# CMAKE_BUILD_WITH_INSTALL_RPATH is on because the module ships target +# apps (tools/qml): installing one otherwise relinks to rewrite the +# RPATH, which ninja refuses for non-ELF binaries, and a static wasm +# executable has no RPATH to rewrite. +# +# CMAKE_CXX_STANDARD_LIBRARIES repeats -lc++ and -lc++abi at the end +# of every executable link line: the SDK driver places its own -lc++ +# before the archives, so libunwind's __wasm_lpad_context stays +# undefined for the module's target apps unless they come after them +# (the constraint build-gui-smoke.sh documents for manual links). +# +# CMAKE_DISABLE_FIND_PACKAGE_harfbuzz forces Qt6Gui's recorded +# find_package(WrapSystemHarfbuzz) down its pkg-config branch, which +# PKG_CONFIG_LIBDIR restricts to the resolved prefixes. Config mode +# finds the build machine's harfbuzz — the one dependency whose host +# package ships a CMake config file — and links its .so into the +# target apps. +# +# The target defines match build-qtbase.sh exactly — Qt's public +# headers read __linux__ and QT_LINUXBASE, and a module built with a +# different answer disagrees with libQt6Core.a about the futex. +# +# Honors the dep-resolver build-script contract (see +# docs/package-management.md). When invoked via +# `cargo xtask build-deps resolve qtdeclarative`, env vars are set by +# the resolver: WASM_POSIX_DEP_OUT_DIR / _VERSION / _SOURCE_URL / +# _SOURCE_SHA256, plus WASM_POSIX_DEP__DIR per depends_on entry: +# WASM_POSIX_DEP_QTBASE_DIR WASM_POSIX_DEP_HARFBUZZ_DIR +# WASM_POSIX_DEP_QTSHADERTOOLS_DIR WASM_POSIX_DEP_LIBPNG_DIR +# WASM_POSIX_DEP_LIBCXX_DIR WASM_POSIX_DEP_LIBXKBCOMMON_DIR +# WASM_POSIX_DEP_ZLIB_DIR WASM_POSIX_DEP_LIBWAYLAND_DIR +# WASM_POSIX_DEP_FREETYPE_DIR WASM_POSIX_DEP_FONTCONFIG_DIR + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +SRC_DIR="$SCRIPT_DIR/qtdeclarative-src" + +QTDECLARATIVE_VERSION="${WASM_POSIX_DEP_VERSION:-6.10.2}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/qtdeclarative-install}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://download.qt.io/archive/qt/6.10/${QTDECLARATIVE_VERSION}/submodules/qtdeclarative-everywhere-src-${QTDECLARATIVE_VERSION}.tar.xz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" + +BUILD_DIR="$SCRIPT_DIR/qtdeclarative-build" + +for tool in wasm32posix-c++ wasm32posix-cc cmake ninja qmake; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Enter scripts/dev-shell.sh." >&2 + exit 1 + fi +done + +if [ -z "${QT_HOST_PATH:-}" ]; then + echo "ERROR: QT_HOST_PATH not set. Enter scripts/dev-shell.sh." >&2 + exit 1 +fi + +HOST_QT_VERSION="$(qmake -query QT_VERSION)" +if [ "$HOST_QT_VERSION" != "$QTDECLARATIVE_VERSION" ]; then + echo "ERROR: host Qt $HOST_QT_VERSION cannot cross-build Qt $QTDECLARATIVE_VERSION." >&2 + echo " Qt requires an exact match. Realign flake.nix's pinned Qt" >&2 + echo " and this recipe's version together." >&2 + exit 1 +fi + +QTBASE_PREFIX="${WASM_POSIX_DEP_QTBASE_DIR:?WASM_POSIX_DEP_QTBASE_DIR not set (must be invoked via cargo xtask build-deps resolve qtdeclarative)}" +QTSHADERTOOLS_PREFIX="${WASM_POSIX_DEP_QTSHADERTOOLS_DIR:?WASM_POSIX_DEP_QTSHADERTOOLS_DIR not set (must be invoked via cargo xtask build-deps resolve qtdeclarative)}" +ZLIB_PREFIX="${WASM_POSIX_DEP_ZLIB_DIR:?WASM_POSIX_DEP_ZLIB_DIR not set (must be invoked via cargo xtask build-deps resolve qtdeclarative)}" +FREETYPE_PREFIX="${WASM_POSIX_DEP_FREETYPE_DIR:?WASM_POSIX_DEP_FREETYPE_DIR not set (must be invoked via cargo xtask build-deps resolve qtdeclarative)}" +FONTCONFIG_PREFIX="${WASM_POSIX_DEP_FONTCONFIG_DIR:?WASM_POSIX_DEP_FONTCONFIG_DIR not set (must be invoked via cargo xtask build-deps resolve qtdeclarative)}" +HARFBUZZ_PREFIX="${WASM_POSIX_DEP_HARFBUZZ_DIR:?WASM_POSIX_DEP_HARFBUZZ_DIR not set (must be invoked via cargo xtask build-deps resolve qtdeclarative)}" +LIBPNG_PREFIX="${WASM_POSIX_DEP_LIBPNG_DIR:?WASM_POSIX_DEP_LIBPNG_DIR not set (must be invoked via cargo xtask build-deps resolve qtdeclarative)}" +LIBXKBCOMMON_PREFIX="${WASM_POSIX_DEP_LIBXKBCOMMON_DIR:?WASM_POSIX_DEP_LIBXKBCOMMON_DIR not set (must be invoked via cargo xtask build-deps resolve qtdeclarative)}" +LIBWAYLAND_PREFIX="${WASM_POSIX_DEP_LIBWAYLAND_DIR:?WASM_POSIX_DEP_LIBWAYLAND_DIR not set (must be invoked via cargo xtask build-deps resolve qtdeclarative)}" + +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/package-build-roots.sh" +SDK_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +SYSROOT="$( + kandelo_package_prepare_private_sysroot qtdeclarative "$SDK_SYSROOT" libcxx +)" +export WASM_POSIX_SYSROOT="$SYSROOT" + +# --- Fetch + verify source --- +if [ ! -d "$SRC_DIR" ]; then + echo "==> Downloading qtdeclarative $QTDECLARATIVE_VERSION..." + TARBALL="/tmp/qtdeclarative-${QTDECLARATIVE_VERSION}.tar.xz" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" + if [ -n "$SOURCE_SHA256" ]; then + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - + else + echo "==> (no SOURCE_SHA256 declared; skipping verification)" + fi + mkdir -p "$SRC_DIR" + tar xf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + rm "$TARBALL" + patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/src/qv4-stack-bounds-on-wasm.patch" +fi + +rm -rf "$BUILD_DIR" +# The resolver-created output directory is itself publication authority, so +# a recipe must populate that inode rather than delete and recreate it. +if [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + if [ -n "$(find "$INSTALL_DIR" -mindepth 1 -print -quit)" ]; then + echo "ERROR: qtdeclarative resolver output directory must start empty" >&2 + exit 1 + fi +else + rm -rf "$INSTALL_DIR" +fi + +DEP_PREFIXES=( + "$QTBASE_PREFIX" + "$QTSHADERTOOLS_PREFIX" + "$ZLIB_PREFIX" + "$FREETYPE_PREFIX" + "$FONTCONFIG_PREFIX" + "$HARFBUZZ_PREFIX" + "$LIBPNG_PREFIX" + "$LIBXKBCOMMON_PREFIX" + "$LIBWAYLAND_PREFIX" +) + +# pkg-config must see only the resolved dependency prefixes (the qtbase +# pattern: PKG_CONFIG_PATH leaves the host search path live). +PKG_CONFIG_LIBDIR="$(IFS=:; printf '%s' "${DEP_PREFIXES[*]/%//lib/pkgconfig}")" +export PKG_CONFIG_LIBDIR + +CMAKE_PREFIXES="$(IFS=';'; printf '%s' "${DEP_PREFIXES[*]}")" + +TARGET_DEFINES="-D__linux__=1 -DQT_LINUXBASE" + +GUEST_PREFIX="/usr/local/qt6" + +echo "==> Configuring qtdeclarative for wasm32..." +cmake -S "$SRC_DIR" -B "$BUILD_DIR" -G Ninja \ + -DCMAKE_SYSTEM_NAME=Linux \ + -DCMAKE_SYSTEM_PROCESSOR=wasm32 \ + -DCMAKE_C_COMPILER=wasm32posix-cc \ + -DCMAKE_CXX_COMPILER=wasm32posix-c++ \ + -DCMAKE_AR="$(command -v wasm32posix-ar)" \ + -DCMAKE_RANLIB="$(command -v wasm32posix-ranlib)" \ + -DCMAKE_C_FLAGS="$TARGET_DEFINES" \ + -DCMAKE_CXX_FLAGS="$TARGET_DEFINES -fwasm-exceptions" \ + `# MinSizeRel, not Release: the browser compiles the linked module once per worker thread, so code size multiplies across workers (docs/browser-support.md#quickshell-qml-limits).` \ + -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ + -DCMAKE_CXX_STANDARD_LIBRARIES="-lc++ -lc++abi" \ + -DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \ + -DCMAKE_PREFIX_PATH="$CMAKE_PREFIXES" \ + -DCMAKE_INSTALL_PREFIX="$GUEST_PREFIX" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DBUILD_SHARED_LIBS=OFF \ + -DQT_HOST_PATH="$QT_HOST_PATH" \ + -DQT_BUILD_EXAMPLES=OFF \ + -DQT_BUILD_TESTS=OFF \ + -DCMAKE_DISABLE_FIND_PACKAGE_harfbuzz=ON \ + -DFEATURE_qml_network=ON \ + -DFEATURE_qml_debug=ON \ + -DFEATURE_qml_type_loader_thread=OFF + +echo "==> Building qtdeclarative..." +cmake --build "$BUILD_DIR" -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + +echo "==> Installing to $INSTALL_DIR..." +cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR" + +for lib in libQt6Qml.a libQt6QmlModels.a libQt6Quick.a; do + if [ ! -f "$INSTALL_DIR/lib/$lib" ]; then + echo "ERROR: Build failed — library not found at $INSTALL_DIR/lib/$lib" >&2 + exit 1 + fi +done + +echo "==> qtdeclarative $QTDECLARATIVE_VERSION built for wasm32" diff --git a/packages/registry/qtdeclarative/build.toml b/packages/registry/qtdeclarative/build.toml new file mode 100644 index 0000000000..06a3aff753 --- /dev/null +++ b/packages/registry/qtdeclarative/build.toml @@ -0,0 +1,13 @@ +script_path = "packages/registry/qtdeclarative/build-qtdeclarative.sh" +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "9072b5384" +revision = 3 + +# The fetched `qtdeclarative-src/` tree is gitignored; the resolver +# hashes these tracked inputs on a clean checkout. script_path is not +# implicit — only what this list names reaches the cache key. +inputs = [ + "packages/registry/qtdeclarative/build-qtdeclarative.sh", + "packages/registry/qtdeclarative/src/qv4-stack-bounds-on-wasm.patch", + "scripts/package-build-roots.sh", +] diff --git a/packages/registry/qtdeclarative/package.toml b/packages/registry/qtdeclarative/package.toml new file mode 100644 index 0000000000..59e3339c81 --- /dev/null +++ b/packages/registry/qtdeclarative/package.toml @@ -0,0 +1,64 @@ +# Per-library manifest consumed by `cargo xtask build-deps`. +# Schema: see docs/package-management.md. +# +# Qt 6 QtQml, QtQmlModels and QtQuick, static, for wasm32. Stage 3 of +# the Qt stack Omarchy v4 needs: Quickshell is a QtQuick client, and +# this is the QML engine and scenegraph it runs on. +# +# QtQuick renders through its software scenegraph adaptation here — the +# kernel's GL command stream replays onto WebGL2, which only the browser +# host has, and a renderer only one host can run would break dual-host +# parity. The software adaptation rasterises on the CPU into the same +# wl_shm pools qtgallery paints through, on both hosts. qml-network is off +# because qtbase carries no QtNetwork. +kind = "library" + +name = "qtdeclarative" +version = "6.10.2" +kernel_abi = 44 +depends_on = [ + "qtbase@6.10.2", + "qtshadertools@6.10.2", + "libcxx@21.1.7", + "zlib@1.3.1", + "freetype@2.13.3", + "fontconfig@2.15.0", + "harfbuzz@10.1.0", + "libpng@1.6.43", + "libxkbcommon@1.7.0", + "libwayland@1.24.0", +] + +[source] +url = "https://download.qt.io/archive/qt/6.10/6.10.2/submodules/qtdeclarative-everywhere-src-6.10.2.tar.xz" +sha256 = "a249914ff66cdcdbf0df8b5ffad997a2ee6dce01cc17d43c6cc56fdc1d0f4b0f" +provider = "archive" + +[license] +spdx = "LGPL-3.0-only" +url = "https://code.qt.io/cgit/qt/qtdeclarative.git/tree/LICENSES/LGPL-3.0-only.txt?h=v6.10.2" + +[build] +script_path = "packages/registry/qtdeclarative/build-qtdeclarative.sh" + +# The cross-build runs moc, qmltyperegistrar and qmlcachegen from +# QT_HOST_PATH (flake.nix joins nixpkgs' qtbase and qtdeclarative into +# one host prefix). qmake identifies the host Qt; the version must +# match the target exactly, and the build script rejects any mismatch. +[[host_tools]] +name = "qmake" +version_constraint = ">=6.10.2" +probe = { args = ["-query", "QT_VERSION"], version_regex = "(\\d+\\.\\d+\\.\\d+)" } +install_hints = { darwin = "provided by flake.nix (pkgs.qt6.qtbase); enter scripts/dev-shell.sh", linux = "provided by flake.nix (pkgs.qt6.qtbase); enter scripts/dev-shell.sh" } + +[outputs] +libs = [ + "lib/libQt6Qml.a", + "lib/libQt6QmlModels.a", + "lib/libQt6Quick.a", +] +headers = [ + "include/QtQml", + "include/QtQmlModels", + "include/QtQuick", +] diff --git a/packages/registry/qtdeclarative/src/qv4-stack-bounds-on-wasm.patch b/packages/registry/qtdeclarative/src/qv4-stack-bounds-on-wasm.patch new file mode 100644 index 0000000000..05b45a95a3 --- /dev/null +++ b/packages/registry/qtdeclarative/src/qv4-stack-bounds-on-wasm.patch @@ -0,0 +1,37 @@ +Report the linear-memory stack bounds to QV4 on wasm32posix. + +QV4 refuses to recurse past the stack limits this file computes. The +build defines __linux__, so the Linux paths run, and both misreport +this target: there is no /proc/self/maps, and musl's +pthread_getattr_np sizes the main thread's stack by probing with +mremap — which this kernel does not implement — so it reports one +page at the address of libc.auxv. QV4 then throws +"Maximum call stack size exceeded" on the first QML document. + +wasm-ld brackets the data stack with __stack_low and __stack_high +whenever they are referenced. The stack grows down, so __stack_high +is the logical base, as in the Q_OS_WASM branch this mirrors. + +--- a/src/qml/memory/qv4stacklimits.cpp ++++ b/src/qml/memory/qv4stacklimits.cpp +@@ -96,7 +96,19 @@ static qsizetype getMainStackSizeFromRlimit() + } + #endif + +-#if defined(Q_OS_INTEGRITY) ++#if defined(__wasm__) && !defined(Q_OS_WASM) ++ ++// The linker brackets the linear-memory data stack with these when ++// they are referenced. The Linux paths below misreport this target. ++extern "C" char __stack_high[]; ++extern "C" char __stack_low[]; ++ ++StackProperties stackProperties() ++{ ++ return createStackProperties(__stack_high, __stack_high - __stack_low); ++} ++ ++#elif defined(Q_OS_INTEGRITY) + + StackProperties stackProperties() + { diff --git a/packages/registry/qtdeclarative/test/build-qml-smoke.sh b/packages/registry/qtdeclarative/test/build-qml-smoke.sh new file mode 100644 index 0000000000..007725c84b --- /dev/null +++ b/packages/registry/qtdeclarative/test/build-qml-smoke.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# Link qt_qml_smoke.cpp against the resolved qtdeclarative and write an +# instrumented wasm32 program to $1. +# +# The link line extends build-gui-smoke.sh's (see that script for the +# two properties every consumer link preserves: the qtbase target +# defines, and -lc++/-lc++abi last). What this one adds is the static +# QML layout: a QML module ships its plugin archive under qml/ in the +# prefix and its runtime under lib/, the program imports the plugin +# class with Q_IMPORT_QML_PLUGIN, and every plugin archive precedes the +# module libraries it registers into. + +set -euo pipefail + +OUT="${1:?usage: build-qml-smoke.sh }" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" + +for tool in wasm32posix-c++ cargo wasm-objdump; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Enter scripts/dev-shell.sh." >&2 + exit 1 + fi +done + +HOST_TRIPLE="$(rustc -vV | awk '/^host/ {print $2}')" + +# `build-deps path` answers a different cache root than `resolve` writes +# under WASM_POSIX_RESOLUTION_POLICY=source-only-v1, so take the prefix +# from resolve, which prints it and is a no-op once cached. +prefix() { + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TRIPLE" --quiet \ + -- build-deps resolve "$1") | tail -1 +} + +QTDECLARATIVE="$(prefix qtdeclarative)" +QTBASE="$(prefix qtbase)" +FREETYPE="$(prefix freetype)" +FONTCONFIG="$(prefix fontconfig)" +HARFBUZZ="$(prefix harfbuzz)" +LIBPNG="$(prefix libpng)" +LIBXML2="$(prefix libxml2)" +LIBFFI="$(prefix libffi)" +LIBICONV="$(prefix libiconv)" +ZLIB="$(prefix zlib)" +LIBCXX="$(prefix libcxx)" + +RAW="${OUT%.wasm}.raw.wasm" +rm -f "$OUT" "$RAW" + +wasm32posix-c++ \ + -O2 -std=c++17 -fwasm-exceptions \ + -D__linux__=1 -DQT_LINUXBASE \ + -I"$QTBASE/include" \ + -I"$QTBASE/include/QtCore" \ + -I"$QTBASE/include/QtGui" \ + -I"$QTDECLARATIVE/include" \ + -I"$QTDECLARATIVE/include/QtQml" \ + -I"$QTDECLARATIVE/include/QtQmlIntegration" \ + -I"$QTDECLARATIVE/include/QtQuick" \ + "$SCRIPT_DIR/qt_qml_smoke.cpp" \ + "$QTDECLARATIVE/qml/QtQuick/Window/libquickwindowplugin.a" \ + "$QTDECLARATIVE/qml/QtQuick/libqtquick2plugin.a" \ + "$QTDECLARATIVE/qml/QtQml/WorkerScript/libworkerscriptplugin.a" \ + "$QTDECLARATIVE/qml/QtQml/Models/libmodelsplugin.a" \ + "$QTDECLARATIVE/qml/QtQml/libqmlplugin.a" \ + "$QTDECLARATIVE/lib/libQt6Quick.a" \ + "$QTDECLARATIVE/lib/libQt6QmlMeta.a" \ + "$QTDECLARATIVE/lib/libQt6QmlWorkerScript.a" \ + "$QTDECLARATIVE/lib/libQt6QmlModels.a" \ + "$QTDECLARATIVE/lib/libQt6Qml.a" \ + "$QTBASE/lib/libQt6Network.a" \ + "$QTBASE/plugins/platforms/libqoffscreen.a" \ + "$QTBASE/lib/libQt6Gui.a" \ + "$QTBASE/lib/libQt6Core.a" \ + "$QTBASE/lib/libQt6BundledPcre2.a" \ + "$FONTCONFIG/lib/libfontconfig.a" \ + "$FREETYPE/lib/libfreetype.a" \ + "$HARFBUZZ/lib/libharfbuzz.a" \ + "$LIBPNG/lib/libpng16.a" \ + "$LIBXML2/lib/libxml2.a" \ + "$LIBFFI/lib/libffi.a" \ + "$LIBICONV/lib/libiconv.a" \ + "$ZLIB/lib/libz.a" \ + "$LIBCXX/lib/libc++.a" \ + "$LIBCXX/lib/libc++abi.a" \ + -o "$RAW" + +# The SDK link does not fail on an undefined symbol — it leaves one as a +# host import, and the program traps only if that path ever runs. A +# missing archive therefore reaches the kernel as +# `Unimplemented import: env.`. These three are the runtime's own +# surface; anything else here is a library the line forgot. +UNDEFINED="$( + wasm-objdump -j Import -x "$RAW" | + grep -o 'env\.[A-Za-z_0-9]*' | + sort -u | + grep -v -x -e 'env.memory' -e 'env.__channel_base' -e 'env.__cxa_thread_atexit' || + true +)" +if [ -n "$UNDEFINED" ]; then + echo "ERROR: the link left library symbols undefined:" >&2 + printf ' %s\n' $UNDEFINED >&2 + exit 1 +fi + +bash "$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" "$RAW" -o "$OUT" +rm -f "$RAW" + +echo "QT_QML_SMOKE_BUILT $OUT" diff --git a/packages/registry/qtdeclarative/test/qt_qml_smoke.cpp b/packages/registry/qtdeclarative/test/qt_qml_smoke.cpp new file mode 100644 index 0000000000..bd3f7faf88 --- /dev/null +++ b/packages/registry/qtdeclarative/test/qt_qml_smoke.cpp @@ -0,0 +1,113 @@ +/* + * Stage-3 gate for the Qt port: the QML engine and the QtQuick software + * scenegraph run on the kernel. + * + * The QML comes from a byte array, not a file — what this gate proves + * is the engine, not the VFS. QQmlApplicationEngine::loadData compiles + * it with the linked-in QML runtime; a static Qt resolves the QtQuick + * import through the imported plugins below, so a missing plugin + * archive fails here as an unresolved import, not a silent fallback. + * + * The scenegraph must settle on the software adaptation: + * QT_QUICK_BACKEND=software is exported before the application exists, + * and the renderer interface is asserted afterwards. grabWindow() on + * the offscreen platform renders the scene synchronously on the CPU — + * the same raster path the compositor's wl_shm pools receive. + * + * The caller supplies the font through FONTCONFIG_FILE, as the qtbase + * gui smoke does: the Text item asserts ink, not a family name. + */ +#include +#include +#include +#include +#include +#include + +#include + +Q_IMPORT_PLUGIN(QOffscreenIntegrationPlugin) +Q_IMPORT_QML_PLUGIN(QtQmlPlugin) +Q_IMPORT_QML_PLUGIN(QtQmlModelsPlugin) +Q_IMPORT_QML_PLUGIN(QtQmlWorkerScriptPlugin) +Q_IMPORT_QML_PLUGIN(QtQuick2Plugin) +Q_IMPORT_QML_PLUGIN(QtQuick_WindowPlugin) + +static const char qml[] = R"( +import QtQuick + +Window { + visible: true + width: 96 + height: 48 + color: "black" + + Rectangle { x: 16; y: 8; width: 32; height: 16; color: "red" } + + Text { + anchors.centerIn: parent + text: "Kan" + color: "white" + font.pixelSize: 24 + } +} +)"; + +static int inkedPixels(const QImage &image) +{ + int inked = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (image.pixel(x, y) != 0xff000000u) + ++inked; + } + } + return inked; +} + +int main(int argc, char **argv) +{ + std::setvbuf(stdout, nullptr, _IONBF, 0); + qputenv("QT_QPA_PLATFORM", "offscreen"); + qputenv("QT_QUICK_BACKEND", "software"); + QGuiApplication app(argc, argv); + + std::printf("QT_VERSION=%s\n", qVersion()); + std::printf("PLATFORM=%s\n", qPrintable(app.platformName())); + + QQmlApplicationEngine engine; + engine.loadData(QByteArray(qml)); + const QList roots = engine.rootObjects(); + std::printf("QML_ROOTS=%lld\n", static_cast(roots.size())); + if (roots.isEmpty()) return 2; + + QQuickWindow *window = qobject_cast(roots.first()); + std::printf("QML_WINDOW=%s\n", window ? "yes" : "no"); + if (!window) return 3; + + const QSGRendererInterface::GraphicsApi api = + window->rendererInterface()->graphicsApi(); + std::printf("SCENEGRAPH_SOFTWARE=%s\n", + api == QSGRendererInterface::Software ? "yes" : "no"); + + const QImage grab = window->grabWindow(); + std::printf("GRAB_SIZE=%dx%d\n", grab.width(), grab.height()); + if (grab.width() < 96 || grab.height() < 48) return 4; + + const QImage frame = + grab.convertToFormat(QImage::Format_ARGB32).scaled(96, 48); + std::printf("CORNER=%08x\n", frame.pixel(2, 2)); + std::printf("CENTRE=%08x\n", frame.pixel(32, 16)); + + const int inked = inkedPixels(frame); + std::printf("GLYPH_PIXELS=%d\n", inked); + + if (app.platformName() != QLatin1String("offscreen")) return 1; + if (api != QSGRendererInterface::Software) return 5; + if (frame.pixel(2, 2) != 0xff000000u) return 6; + if (frame.pixel(32, 16) != 0xffff0000u) return 7; + if (inked == 0) return 8; + + std::printf("QT_QML_SMOKE_OK\n"); + return 0; +} diff --git a/packages/registry/qtgallery/build-qtgallery.sh b/packages/registry/qtgallery/build-qtgallery.sh new file mode 100755 index 0000000000..efd9aaadba --- /dev/null +++ b/packages/registry/qtgallery/build-qtgallery.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# Link programs/qtgallery/qtgallery.cpp against the resolved qtbase into qtgallery.wasm. +# +# The link line mirrors packages/registry/qtbase/test/build-gui-smoke.sh, +# which documents the two properties it has to preserve: +# +# -D__linux__=1 -DQT_LINUXBASE The same two defines build-qtbase.sh +# configures with. Qt's public headers +# read them. +# +# -lc++ -lc++abi last The SDK driver places its own -lc++ +# before the archives, so libunwind's +# __wasm_lpad_context stays undefined +# unless these come after them. +# +# Plugins precede the modules they extend, and every library follows its +# users: a static link resolves in one pass. + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../../.." && pwd)" +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/package-build-roots.sh" +kandelo_package_prepare_build_roots "$HERE" wasm32 +kandelo_package_select_source_root "$REPO_ROOT" +SOURCE_ROOT="$KANDELO_PACKAGE_SOURCE_ROOT" +WORK_DIR="$KANDELO_PACKAGE_WORK_DIR" + +SOURCE="$SOURCE_ROOT/programs/qtgallery/qtgallery.cpp" +if [ ! -f "$SOURCE" ] || [ -L "$SOURCE" ]; then + echo "ERROR: qtgallery source must be a regular file: $SOURCE" >&2 + exit 1 +fi + +if [ -n "${WASM_POSIX_DEP_WORK_DIR:-}" ] && [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + export WASM_POSIX_INSTALL_LOCAL_MIRROR=0 + export WASM_POSIX_INSTALL_FORK_INSTRUMENTATION=auto +fi + +QTBASE="${WASM_POSIX_DEP_QTBASE_DIR:?WASM_POSIX_DEP_QTBASE_DIR not set (must be invoked via cargo xtask build-deps resolve qtgallery)}" +FONTCONFIG="${WASM_POSIX_DEP_FONTCONFIG_DIR:?WASM_POSIX_DEP_FONTCONFIG_DIR not set}" +FREETYPE="${WASM_POSIX_DEP_FREETYPE_DIR:?WASM_POSIX_DEP_FREETYPE_DIR not set}" +HARFBUZZ="${WASM_POSIX_DEP_HARFBUZZ_DIR:?WASM_POSIX_DEP_HARFBUZZ_DIR not set}" +LIBPNG="${WASM_POSIX_DEP_LIBPNG_DIR:?WASM_POSIX_DEP_LIBPNG_DIR not set}" +LIBXML2="${WASM_POSIX_DEP_LIBXML2_DIR:?WASM_POSIX_DEP_LIBXML2_DIR not set}" +LIBFFI="${WASM_POSIX_DEP_LIBFFI_DIR:?WASM_POSIX_DEP_LIBFFI_DIR not set}" +LIBICONV="${WASM_POSIX_DEP_LIBICONV_DIR:?WASM_POSIX_DEP_LIBICONV_DIR not set}" +LIBXKBCOMMON="${WASM_POSIX_DEP_LIBXKBCOMMON_DIR:?WASM_POSIX_DEP_LIBXKBCOMMON_DIR not set}" +LIBWAYLAND="${WASM_POSIX_DEP_LIBWAYLAND_DIR:?WASM_POSIX_DEP_LIBWAYLAND_DIR not set}" +ZLIB="${WASM_POSIX_DEP_ZLIB_DIR:?WASM_POSIX_DEP_ZLIB_DIR not set}" +LIBCXX="${WASM_POSIX_DEP_LIBCXX_DIR:?WASM_POSIX_DEP_LIBCXX_DIR not set}" + +source "$REPO_ROOT/sdk/activate.sh" +export WASM_POSIX_SYSROOT="$REPO_ROOT/sysroot" + +for tool in wasm32posix-c++ wasm-objdump; do + command -v "$tool" >/dev/null || { + echo "ERROR: $tool not found — run through scripts/dev-shell.sh" >&2 + exit 1 + } +done + +echo "==> Building qtgallery (the Omarchy theme gallery)..." +RAW="$WORK_DIR/qtgallery.raw.wasm" +wasm32posix-c++ \ + -O2 -std=c++17 -fwasm-exceptions \ + -D__linux__=1 -DQT_LINUXBASE \ + -I"$QTBASE/include" \ + -I"$QTBASE/include/QtCore" \ + -I"$QTBASE/include/QtGui" \ + "$SOURCE" \ + "$QTBASE/plugins/platforms/libqwayland.a" \ + "$QTBASE/plugins/wayland-shell-integration/libxdg-shell.a" \ + "$QTBASE/lib/libQt6WaylandClient.a" \ + "$QTBASE/lib/libQt6Gui.a" \ + "$QTBASE/lib/libQt6Core.a" \ + "$QTBASE/lib/libQt6BundledPcre2.a" \ + "$FONTCONFIG/lib/libfontconfig.a" \ + "$FREETYPE/lib/libfreetype.a" \ + "$HARFBUZZ/lib/libharfbuzz.a" \ + "$LIBPNG/lib/libpng16.a" \ + "$LIBXML2/lib/libxml2.a" \ + "$LIBFFI/lib/libffi.a" \ + "$LIBICONV/lib/libiconv.a" \ + "$LIBXKBCOMMON/lib/libxkbcommon.a" \ + "$LIBWAYLAND/lib/libwayland-client.a" \ + "$LIBWAYLAND/lib/libwayland-cursor.a" \ + "$WASM_POSIX_SYSROOT/lib/libgbm.a" \ + "$WASM_POSIX_SYSROOT/lib/libdrm.a" \ + "$ZLIB/lib/libz.a" \ + "$LIBCXX/lib/libc++.a" \ + "$LIBCXX/lib/libc++abi.a" \ + -o "$RAW" + +# The SDK link does not fail on an undefined symbol — it leaves one as a +# host import, and the program traps only if that path ever runs. These +# three are the runtime's own surface; anything else here is a library +# the line forgot. +UNDEFINED="$( + wasm-objdump -j Import -x "$RAW" | + grep -o 'env\.[A-Za-z_0-9]*' | + sort -u | + grep -v -x -e 'env.memory' -e 'env.__channel_base' -e 'env.__cxa_thread_atexit' || + true +)" +if [ -n "$UNDEFINED" ]; then + echo "ERROR: the link left library symbols undefined:" >&2 + printf ' %s\n' $UNDEFINED >&2 + exit 1 +fi +mv "$RAW" "$WORK_DIR/qtgallery.wasm" + +cd "$REPO_ROOT" +source "$REPO_ROOT/scripts/install-local-binary.sh" +install_local_binary qtgallery "$WORK_DIR/qtgallery.wasm" "qtgallery.wasm" diff --git a/packages/registry/qtgallery/build.toml b/packages/registry/qtgallery/build.toml new file mode 100644 index 0000000000..70a23ede98 --- /dev/null +++ b/packages/registry/qtgallery/build.toml @@ -0,0 +1,9 @@ +script_path = "packages/registry/qtgallery/build-qtgallery.sh" +inputs = [ + "packages/registry/qtgallery/build-qtgallery.sh", + "programs/qtgallery/qtgallery.cpp", + "scripts/package-build-roots.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 diff --git a/packages/registry/qtgallery/package.toml b/packages/registry/qtgallery/package.toml new file mode 100644 index 0000000000..8e95f37727 --- /dev/null +++ b/packages/registry/qtgallery/package.toml @@ -0,0 +1,44 @@ +# Per-package manifest consumed by `cargo xtask build-deps`. +# Schema: see docs/package-management.md. + +kind = "program" + +# The Omarchy theme gallery: a QRasterWindow listing the installed themes +# and staged Quickshell shells as clickable cards, applying them through +# the compositor's kwlctl socket. The source stays in programs/ like the +# wldesktop clients, and the recipe reuses the link line proven by +# packages/registry/qtbase/test/build-gui-smoke.sh. +name = "qtgallery" +version = "0.1.0" +kernel_abi = 44 +depends_on = [ + "qtbase@6.10.2", + "fontconfig@2.15.0", + "freetype@2.13.3", + "harfbuzz@10.1.0", + "libpng@1.6.43", + "libxml2@2.13.8", + "libffi@0.1.0", + "libiconv@1.17", + "libxkbcommon@1.7.0", + "libwayland@1.24.0", + "zlib@1.3.1", + "libcxx@21.1.7", +] +arches = ["wasm32"] + +[source] +url = "https://github.com/Automattic/kandelo" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" +provider = "repository" + +[license] +spdx = "GPL-2.0-or-later" +url = "https://github.com/Automattic/kandelo/blob/main/COPYING" + +[build] +script_path = "packages/registry/qtgallery/build-qtgallery.sh" + +[[outputs]] +name = "qtgallery" +wasm = "qtgallery.wasm" diff --git a/packages/registry/qtshadertools/build-qtshadertools.sh b/packages/registry/qtshadertools/build-qtshadertools.sh new file mode 100644 index 0000000000..c8beabb51c --- /dev/null +++ b/packages/registry/qtshadertools/build-qtshadertools.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# +# Cross-compile Qt 6 QtShaderTools for wasm32. +# +# qtdeclarative refuses to configure without this module: QtQuick's +# scenegraph materials are compiled to .qsb at build time by the host +# qsb (QT_HOST_PATH), and this library carries the runtime that loads +# them. The bundled glslang and SPIRV-Cross compile as plain C++. +# +# The target defines match build-qtbase.sh exactly — Qt's public +# headers read __linux__ and QT_LINUXBASE, and a module built with a +# different answer disagrees with libQt6Core.a about the futex. +# +# Honors the dep-resolver build-script contract (see +# docs/package-management.md). When invoked via +# `cargo xtask build-deps resolve qtshadertools`, env vars are set by +# the resolver: WASM_POSIX_DEP_OUT_DIR / _VERSION / _SOURCE_URL / +# _SOURCE_SHA256, plus WASM_POSIX_DEP__DIR per depends_on entry: +# WASM_POSIX_DEP_QTBASE_DIR WASM_POSIX_DEP_HARFBUZZ_DIR +# WASM_POSIX_DEP_LIBCXX_DIR WASM_POSIX_DEP_LIBPNG_DIR +# WASM_POSIX_DEP_ZLIB_DIR WASM_POSIX_DEP_LIBXKBCOMMON_DIR +# WASM_POSIX_DEP_FREETYPE_DIR WASM_POSIX_DEP_LIBWAYLAND_DIR +# WASM_POSIX_DEP_FONTCONFIG_DIR +# +# libQt6ShaderTools links Qt::Gui, and the Qt6Gui CMake package loads +# only when every third-party library qtbase was configured against +# resolves too — so this recipe carries qtbase's full dependency set +# even though ShaderTools itself touches none of them. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +SRC_DIR="$SCRIPT_DIR/qtshadertools-src" + +QTSHADERTOOLS_VERSION="${WASM_POSIX_DEP_VERSION:-6.10.2}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/qtshadertools-install}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://download.qt.io/archive/qt/6.10/${QTSHADERTOOLS_VERSION}/submodules/qtshadertools-everywhere-src-${QTSHADERTOOLS_VERSION}.tar.xz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" + +BUILD_DIR="$SCRIPT_DIR/qtshadertools-build" + +for tool in wasm32posix-c++ wasm32posix-cc cmake ninja qmake; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Enter scripts/dev-shell.sh." >&2 + exit 1 + fi +done + +if [ -z "${QT_HOST_PATH:-}" ]; then + echo "ERROR: QT_HOST_PATH not set. Enter scripts/dev-shell.sh." >&2 + exit 1 +fi + +HOST_QT_VERSION="$(qmake -query QT_VERSION)" +if [ "$HOST_QT_VERSION" != "$QTSHADERTOOLS_VERSION" ]; then + echo "ERROR: host Qt $HOST_QT_VERSION cannot cross-build Qt $QTSHADERTOOLS_VERSION." >&2 + echo " Qt requires an exact match. Realign flake.nix's pinned Qt" >&2 + echo " and this recipe's version together." >&2 + exit 1 +fi + +QTBASE_PREFIX="${WASM_POSIX_DEP_QTBASE_DIR:?WASM_POSIX_DEP_QTBASE_DIR not set (must be invoked via cargo xtask build-deps resolve qtshadertools)}" +ZLIB_PREFIX="${WASM_POSIX_DEP_ZLIB_DIR:?WASM_POSIX_DEP_ZLIB_DIR not set (must be invoked via cargo xtask build-deps resolve qtshadertools)}" +FREETYPE_PREFIX="${WASM_POSIX_DEP_FREETYPE_DIR:?WASM_POSIX_DEP_FREETYPE_DIR not set (must be invoked via cargo xtask build-deps resolve qtshadertools)}" +FONTCONFIG_PREFIX="${WASM_POSIX_DEP_FONTCONFIG_DIR:?WASM_POSIX_DEP_FONTCONFIG_DIR not set (must be invoked via cargo xtask build-deps resolve qtshadertools)}" +HARFBUZZ_PREFIX="${WASM_POSIX_DEP_HARFBUZZ_DIR:?WASM_POSIX_DEP_HARFBUZZ_DIR not set (must be invoked via cargo xtask build-deps resolve qtshadertools)}" +LIBPNG_PREFIX="${WASM_POSIX_DEP_LIBPNG_DIR:?WASM_POSIX_DEP_LIBPNG_DIR not set (must be invoked via cargo xtask build-deps resolve qtshadertools)}" +LIBXKBCOMMON_PREFIX="${WASM_POSIX_DEP_LIBXKBCOMMON_DIR:?WASM_POSIX_DEP_LIBXKBCOMMON_DIR not set (must be invoked via cargo xtask build-deps resolve qtshadertools)}" +LIBWAYLAND_PREFIX="${WASM_POSIX_DEP_LIBWAYLAND_DIR:?WASM_POSIX_DEP_LIBWAYLAND_DIR not set (must be invoked via cargo xtask build-deps resolve qtshadertools)}" + +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/package-build-roots.sh" +SDK_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +SYSROOT="$( + kandelo_package_prepare_private_sysroot qtshadertools "$SDK_SYSROOT" libcxx +)" +export WASM_POSIX_SYSROOT="$SYSROOT" + +# --- Fetch + verify source --- +if [ ! -d "$SRC_DIR" ]; then + echo "==> Downloading qtshadertools $QTSHADERTOOLS_VERSION..." + TARBALL="/tmp/qtshadertools-${QTSHADERTOOLS_VERSION}.tar.xz" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" + if [ -n "$SOURCE_SHA256" ]; then + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - + else + echo "==> (no SOURCE_SHA256 declared; skipping verification)" + fi + mkdir -p "$SRC_DIR" + tar xf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + rm "$TARBALL" +fi + +rm -rf "$BUILD_DIR" +# The resolver-created output directory is itself publication authority, so +# a recipe must populate that inode rather than delete and recreate it. +if [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + if [ -n "$(find "$INSTALL_DIR" -mindepth 1 -print -quit)" ]; then + echo "ERROR: qtshadertools resolver output directory must start empty" >&2 + exit 1 + fi +else + rm -rf "$INSTALL_DIR" +fi + +DEP_PREFIXES=( + "$QTBASE_PREFIX" + "$ZLIB_PREFIX" + "$FREETYPE_PREFIX" + "$FONTCONFIG_PREFIX" + "$HARFBUZZ_PREFIX" + "$LIBPNG_PREFIX" + "$LIBXKBCOMMON_PREFIX" + "$LIBWAYLAND_PREFIX" +) + +# pkg-config must see only the resolved dependency prefixes (the qtbase +# pattern: PKG_CONFIG_PATH leaves the host search path live). +PKG_CONFIG_LIBDIR="$(IFS=:; printf '%s' "${DEP_PREFIXES[*]/%//lib/pkgconfig}")" +export PKG_CONFIG_LIBDIR + +CMAKE_PREFIXES="$(IFS=';'; printf '%s' "${DEP_PREFIXES[*]}")" + +TARGET_DEFINES="-D__linux__=1 -DQT_LINUXBASE" + +GUEST_PREFIX="/usr/local/qt6" + +echo "==> Configuring qtshadertools for wasm32..." +cmake -S "$SRC_DIR" -B "$BUILD_DIR" -G Ninja \ + -DCMAKE_SYSTEM_NAME=Linux \ + -DCMAKE_SYSTEM_PROCESSOR=wasm32 \ + -DCMAKE_C_COMPILER=wasm32posix-cc \ + -DCMAKE_CXX_COMPILER=wasm32posix-c++ \ + -DCMAKE_AR="$(command -v wasm32posix-ar)" \ + -DCMAKE_RANLIB="$(command -v wasm32posix-ranlib)" \ + -DCMAKE_C_FLAGS="$TARGET_DEFINES" \ + -DCMAKE_CXX_FLAGS="$TARGET_DEFINES -fwasm-exceptions" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \ + -DCMAKE_PREFIX_PATH="$CMAKE_PREFIXES" \ + -DCMAKE_INSTALL_PREFIX="$GUEST_PREFIX" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DBUILD_SHARED_LIBS=OFF \ + -DQT_HOST_PATH="$QT_HOST_PATH" \ + -DQT_BUILD_EXAMPLES=OFF \ + -DQT_BUILD_TESTS=OFF + +echo "==> Building qtshadertools..." +cmake --build "$BUILD_DIR" -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + +echo "==> Installing to $INSTALL_DIR..." +cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR" + +if [ ! -f "$INSTALL_DIR/lib/libQt6ShaderTools.a" ]; then + echo "ERROR: Build failed — library not found at $INSTALL_DIR/lib/libQt6ShaderTools.a" >&2 + exit 1 +fi + +echo "==> qtshadertools $QTSHADERTOOLS_VERSION built for wasm32" diff --git a/packages/registry/qtshadertools/build.toml b/packages/registry/qtshadertools/build.toml new file mode 100644 index 0000000000..003f6e11a5 --- /dev/null +++ b/packages/registry/qtshadertools/build.toml @@ -0,0 +1,12 @@ +script_path = "packages/registry/qtshadertools/build-qtshadertools.sh" +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "9072b5384" +revision = 1 + +# The fetched `qtshadertools-src/` tree is gitignored; the resolver +# hashes these tracked inputs on a clean checkout. script_path is not +# implicit — only what this list names reaches the cache key. +inputs = [ + "packages/registry/qtshadertools/build-qtshadertools.sh", + "scripts/package-build-roots.sh", +] diff --git a/packages/registry/qtshadertools/package.toml b/packages/registry/qtshadertools/package.toml new file mode 100644 index 0000000000..58825eb078 --- /dev/null +++ b/packages/registry/qtshadertools/package.toml @@ -0,0 +1,50 @@ +# Per-library manifest consumed by `cargo xtask build-deps`. +# Schema: see docs/package-management.md. +# +# Qt 6 QtShaderTools, static, for wasm32. qtdeclarative refuses to +# configure without it: QtQuick's scenegraph materials are compiled to +# .qsb at build time, and the module carries the runtime that loads +# them. The compiler itself (qsb) runs on the build machine from +# QT_HOST_PATH; this package is the target-side library. +kind = "library" + +name = "qtshadertools" +version = "6.10.2" +kernel_abi = 44 +depends_on = [ + "qtbase@6.10.2", + "libcxx@21.1.7", + "zlib@1.3.1", + "freetype@2.13.3", + "fontconfig@2.15.0", + "harfbuzz@10.1.0", + "libpng@1.6.43", + "libxkbcommon@1.7.0", + "libwayland@1.24.0", +] + +[source] +url = "https://download.qt.io/archive/qt/6.10/6.10.2/submodules/qtshadertools-everywhere-src-6.10.2.tar.xz" +sha256 = "18d9dbbc4f7e6e96e6ed89a9965dc032e2b58158b65156c035537826216716c9" +provider = "archive" + +[license] +spdx = "LGPL-3.0-only" +url = "https://code.qt.io/cgit/qt/qtshadertools.git/tree/LICENSES/LGPL-3.0-only.txt?h=v6.10.2" + +[build] +script_path = "packages/registry/qtshadertools/build-qtshadertools.sh" + +[[host_tools]] +name = "qmake" +version_constraint = ">=6.10.2" +probe = { args = ["-query", "QT_VERSION"], version_regex = "(\\d+\\.\\d+\\.\\d+)" } +install_hints = { darwin = "provided by flake.nix (pkgs.qt6.qtbase); enter scripts/dev-shell.sh", linux = "provided by flake.nix (pkgs.qt6.qtbase); enter scripts/dev-shell.sh" } + +[outputs] +libs = [ + "lib/libQt6ShaderTools.a", +] +headers = [ + "include/QtShaderTools", +] diff --git a/packages/registry/quickshell/build-quickshell.sh b/packages/registry/quickshell/build-quickshell.sh new file mode 100755 index 0000000000..5061612832 --- /dev/null +++ b/packages/registry/quickshell/build-quickshell.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# +# Cross-compile Quickshell 0.3.1 (quickshell.wasm) for wasm32. +# +# The CMake cross setup mirrors build-qtdeclarative.sh — the target +# defines match qtbase, CMAKE_CXX_STANDARD_LIBRARIES closes the static +# link at the end of the executable line (libffi for libwayland's +# closure marshalling, libxml2+libiconv+libcharset for fontconfig, +# the sysroot gbm for the dmabuf buffer module, -lc++/-lc++abi last — +# the waybar link closure is the model), and the harfbuzz config-mode +# leak is cut with CMAKE_DISABLE_FIND_PACKAGE. +# +# Feature selection: WAYLAND stays on with WLR_LAYERSHELL (Omarchy's +# bar is a layer-shell surface); everything else is off because its +# dependency does not exist here — see package.toml. The wayland glue +# needs the host wayland-scanner and Qt6::qtwaylandscanner, both from +# the dev shell (nix/qtwaylandscanner/ shims the latter when nixpkgs +# does not expose it). +# +# Honors the dep-resolver build-script contract (see +# docs/package-management.md). When invoked via +# `cargo xtask build-deps resolve quickshell`, env vars are set by the +# resolver: WASM_POSIX_DEP_OUT_DIR / _VERSION / _SOURCE_URL / +# _SOURCE_SHA256, plus WASM_POSIX_DEP__DIR per depends_on entry. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +SRC_DIR="$SCRIPT_DIR/quickshell-src" + +QUICKSHELL_VERSION="${WASM_POSIX_DEP_VERSION:-0.3.1}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/quickshell-install}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/quickshell-mirror/quickshell/archive/refs/tags/v${QUICKSHELL_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" + +BUILD_DIR="$SCRIPT_DIR/quickshell-build" + +for tool in wasm32posix-c++ wasm32posix-cc cmake ninja qmake wayland-scanner; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Enter scripts/dev-shell.sh." >&2 + exit 1 + fi +done + +if [ -z "${QT_HOST_PATH:-}" ]; then + echo "ERROR: QT_HOST_PATH not set. Enter scripts/dev-shell.sh." >&2 + exit 1 +fi + +QTBASE_PREFIX="${WASM_POSIX_DEP_QTBASE_DIR:?WASM_POSIX_DEP_QTBASE_DIR not set (must be invoked via cargo xtask build-deps resolve quickshell)}" +QTDECLARATIVE_PREFIX="${WASM_POSIX_DEP_QTDECLARATIVE_DIR:?WASM_POSIX_DEP_QTDECLARATIVE_DIR not set}" +QTSHADERTOOLS_PREFIX="${WASM_POSIX_DEP_QTSHADERTOOLS_DIR:?WASM_POSIX_DEP_QTSHADERTOOLS_DIR not set}" +CLI11_PREFIX="${WASM_POSIX_DEP_CLI11_DIR:?WASM_POSIX_DEP_CLI11_DIR not set}" +LIBDRM_PREFIX="${WASM_POSIX_DEP_LIBDRM_DIR:?WASM_POSIX_DEP_LIBDRM_DIR not set}" +WAYLAND_PROTOCOLS_PREFIX="${WASM_POSIX_DEP_WAYLAND_PROTOCOLS_DIR:?WASM_POSIX_DEP_WAYLAND_PROTOCOLS_DIR not set}" +LIBWAYLAND_PREFIX="${WASM_POSIX_DEP_LIBWAYLAND_DIR:?WASM_POSIX_DEP_LIBWAYLAND_DIR not set}" +LIBXKBCOMMON_PREFIX="${WASM_POSIX_DEP_LIBXKBCOMMON_DIR:?WASM_POSIX_DEP_LIBXKBCOMMON_DIR not set}" +FONTCONFIG_PREFIX="${WASM_POSIX_DEP_FONTCONFIG_DIR:?WASM_POSIX_DEP_FONTCONFIG_DIR not set}" +FREETYPE_PREFIX="${WASM_POSIX_DEP_FREETYPE_DIR:?WASM_POSIX_DEP_FREETYPE_DIR not set}" +HARFBUZZ_PREFIX="${WASM_POSIX_DEP_HARFBUZZ_DIR:?WASM_POSIX_DEP_HARFBUZZ_DIR not set}" +LIBPNG_PREFIX="${WASM_POSIX_DEP_LIBPNG_DIR:?WASM_POSIX_DEP_LIBPNG_DIR not set}" +LIBXML2_PREFIX="${WASM_POSIX_DEP_LIBXML2_DIR:?WASM_POSIX_DEP_LIBXML2_DIR not set}" +LIBFFI_PREFIX="${WASM_POSIX_DEP_LIBFFI_DIR:?WASM_POSIX_DEP_LIBFFI_DIR not set}" +LIBICONV_PREFIX="${WASM_POSIX_DEP_LIBICONV_DIR:?WASM_POSIX_DEP_LIBICONV_DIR not set}" +ZLIB_PREFIX="${WASM_POSIX_DEP_ZLIB_DIR:?WASM_POSIX_DEP_ZLIB_DIR not set}" +LIBCXX_PREFIX="${WASM_POSIX_DEP_LIBCXX_DIR:?WASM_POSIX_DEP_LIBCXX_DIR not set}" + +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/package-build-roots.sh" +SDK_SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +SYSROOT="$( + kandelo_package_prepare_private_sysroot quickshell "$SDK_SYSROOT" libcxx +)" +export WASM_POSIX_SYSROOT="$SYSROOT" + +# --- Fetch + verify source --- +if [ ! -d "$SRC_DIR" ]; then + echo "==> Downloading quickshell $QUICKSHELL_VERSION..." + TARBALL="/tmp/quickshell-${QUICKSHELL_VERSION}.tar.gz" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" + if [ -n "$SOURCE_SHA256" ]; then + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - + else + echo "==> (no SOURCE_SHA256 declared; skipping verification)" + fi + mkdir -p "$SRC_DIR" + tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + rm "$TARBALL" + patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/src/no-wl-proxy-interpose-on-wasm.patch" + patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/src/on-thread-logger-on-wasm.patch" + patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/src/one-generation-reload-on-wasm.patch" +fi + +rm -rf "$BUILD_DIR" +# The resolver-created output directory is itself publication authority, so +# a recipe must populate that inode rather than delete and recreate it. +if [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + if [ -n "$(find "$INSTALL_DIR" -mindepth 1 -print -quit)" ]; then + echo "ERROR: quickshell resolver output directory must start empty" >&2 + exit 1 + fi +else + rm -rf "$INSTALL_DIR" +fi + +DEP_PREFIXES=( + "$QTDECLARATIVE_PREFIX" + "$QTSHADERTOOLS_PREFIX" + "$QTBASE_PREFIX" + "$CLI11_PREFIX" + "$LIBDRM_PREFIX" + "$WAYLAND_PROTOCOLS_PREFIX" + "$LIBWAYLAND_PREFIX" + "$LIBXKBCOMMON_PREFIX" + "$FONTCONFIG_PREFIX" + "$FREETYPE_PREFIX" + "$HARFBUZZ_PREFIX" + "$LIBPNG_PREFIX" + "$LIBXML2_PREFIX" + "$LIBFFI_PREFIX" + "$LIBICONV_PREFIX" + "$ZLIB_PREFIX" + "$LIBCXX_PREFIX" +) + +# pkg-config must see only the resolved dependency prefixes (the qtbase +# pattern: PKG_CONFIG_PATH leaves the host search path live). Quickshell +# pkg-configs libdrm and wayland-protocols (pkg_get_variable pkgdatadir). +PKG_CONFIG_LIBDIR="$(IFS=:; printf '%s' "${DEP_PREFIXES[*]/%//lib/pkgconfig}")" +PKG_CONFIG_LIBDIR="$PKG_CONFIG_LIBDIR:$WAYLAND_PROTOCOLS_PREFIX/share/pkgconfig" +export PKG_CONFIG_LIBDIR + +CMAKE_PREFIXES="$(IFS=';'; printf '%s' "${DEP_PREFIXES[*]}")" + +TARGET_DEFINES="-D__linux__=1 -DQT_LINUXBASE" + +echo "==> Configuring quickshell for wasm32..." +cmake -S "$SRC_DIR" -B "$BUILD_DIR" -G Ninja \ + -DCMAKE_SYSTEM_NAME=Linux \ + -DCMAKE_SYSTEM_PROCESSOR=wasm32 \ + -DCMAKE_C_COMPILER=wasm32posix-cc \ + -DCMAKE_CXX_COMPILER=wasm32posix-c++ \ + -DCMAKE_AR="$(command -v wasm32posix-ar)" \ + -DCMAKE_RANLIB="$(command -v wasm32posix-ranlib)" \ + -DCMAKE_C_FLAGS="$TARGET_DEFINES" \ + -DCMAKE_CXX_FLAGS="$TARGET_DEFINES -fwasm-exceptions" \ + `# MinSizeRel, not Release: the browser compiles the linked module once per worker thread, so code size multiplies across workers (docs/browser-support.md#quickshell-qml-limits).` \ + -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \ + -DCMAKE_CXX_STANDARD_LIBRARIES="$LIBFFI_PREFIX/lib/libffi.a $LIBXML2_PREFIX/lib/libxml2.a $LIBICONV_PREFIX/lib/libiconv.a $LIBICONV_PREFIX/lib/libcharset.a $SYSROOT/lib/libgbm.a -lc++ -lc++abi" \ + -DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \ + -DCMAKE_PREFIX_PATH="$CMAKE_PREFIXES" \ + -DCMAKE_INSTALL_PREFIX=/usr/local \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DBUILD_SHARED_LIBS=OFF \ + -DQT_HOST_PATH="$QT_HOST_PATH" \ + -DQT_ADDITIONAL_PACKAGES_PREFIX_PATH="$QTDECLARATIVE_PREFIX;$QTSHADERTOOLS_PREFIX" \ + -DCMAKE_DISABLE_FIND_PACKAGE_harfbuzz=ON \ + -DWAYLAND=ON \ + -DWAYLAND_WLR_LAYERSHELL=ON \ + -DSOCKETS=OFF \ + -DCRASH_HANDLER=OFF \ + -DUSE_JEMALLOC=OFF \ + -DX11=OFF \ + -DI3=OFF \ + -DHYPRLAND=OFF \ + -DSCREENCOPY=OFF \ + -DWAYLAND_SESSION_LOCK=OFF \ + -DWAYLAND_TOPLEVEL_MANAGEMENT=OFF \ + -DSERVICE_STATUS_NOTIFIER=OFF \ + -DSERVICE_PIPEWIRE=OFF \ + -DSERVICE_MPRIS=OFF \ + -DSERVICE_PAM=OFF \ + -DSERVICE_POLKIT=OFF \ + -DSERVICE_GREETD=OFF \ + -DSERVICE_UPOWER=OFF \ + -DSERVICE_NOTIFICATIONS=OFF \ + -DBLUETOOTH=OFF \ + -DNETWORK=OFF + +echo "==> Building quickshell..." +cmake --build "$BUILD_DIR" -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + +QS_BIN="$BUILD_DIR/src/launch/quickshell" +if [ ! -f "$QS_BIN" ]; then + QS_BIN="$(find "$BUILD_DIR" -type f -name quickshell -perm -u+x | head -1)" +fi +if [ -z "$QS_BIN" ] || [ ! -f "$QS_BIN" ]; then + echo "ERROR: Build failed — quickshell executable not found under $BUILD_DIR" >&2 + exit 1 +fi + +cd "$REPO_ROOT" +source "$REPO_ROOT/scripts/install-local-binary.sh" +install_local_binary quickshell "$QS_BIN" "quickshell.wasm" + +echo "==> quickshell $QUICKSHELL_VERSION built for wasm32" diff --git a/packages/registry/quickshell/build.toml b/packages/registry/quickshell/build.toml new file mode 100644 index 0000000000..5b4795a5ab --- /dev/null +++ b/packages/registry/quickshell/build.toml @@ -0,0 +1,15 @@ +script_path = "packages/registry/quickshell/build-quickshell.sh" +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +# The fetched `quickshell-src/` tree is gitignored; the resolver hashes +# these tracked inputs on a clean checkout. script_path is not implicit — +# only what this list names reaches the cache key. +inputs = [ + "packages/registry/quickshell/build-quickshell.sh", + "packages/registry/quickshell/src/no-wl-proxy-interpose-on-wasm.patch", + "packages/registry/quickshell/src/on-thread-logger-on-wasm.patch", + "packages/registry/quickshell/src/one-generation-reload-on-wasm.patch", + "scripts/package-build-roots.sh", +] diff --git a/packages/registry/quickshell/package.toml b/packages/registry/quickshell/package.toml new file mode 100644 index 0000000000..336372c47e --- /dev/null +++ b/packages/registry/quickshell/package.toml @@ -0,0 +1,68 @@ +# Per-program manifest for Quickshell. See docs/package-management.md. +# +# Ports Quickshell 0.3.1, the QtQuick-based Wayland shell toolkit +# Omarchy's bar and launcher are written in. The build keeps the +# Wayland window layer (wlr-layer-shell for bars/overlays) and turns +# off every feature whose dependency this kernel does not carry: +# NETWORK pulls QtDBus (qtbase does not build it), SOCKETS' QML socket +# API has no consumer in the Omarchy configs, +# the SERVICE_* modules speak DBus/PipeWire/PAM/greetd, and X11, +# Hyprland, i3, screencopy, session-lock and toplevel-management +# target compositor surfaces wlcompositor does not expose. +kind = "program" +name = "quickshell" +version = "0.3.1" +kernel_abi = 44 +depends_on = [ + "qtdeclarative@6.10.2", + "qtshadertools@6.10.2", + "qtbase@6.10.2", + "cli11@2.5.0", + "libdrm@2.4.120", + "wayland-protocols@1.45", + "libwayland@1.24.0", + "libxkbcommon@1.7.0", + "fontconfig@2.15.0", + "freetype@2.13.3", + "harfbuzz@10.1.0", + "libpng@1.6.43", + "libxml2@2.13.8", + "libffi@0.1.0", + "libiconv@1.17", + "zlib@1.3.1", + "libcxx@21.1.7", +] + +[[host_tools]] +name = "qmake" +version_constraint = ">=6.10.2" +probe = { args = ["-query", "QT_VERSION"], version_regex = "(\\d+\\.\\d+\\.\\d+)" } +install_hints = { darwin = "provided by flake.nix (pkgs.qt6.qtbase); enter scripts/dev-shell.sh", linux = "provided by flake.nix (pkgs.qt6.qtbase); enter scripts/dev-shell.sh" } + +[[host_tools]] +name = "spirv-opt" +version_constraint = ">=2024.1" +probe = { args = ["--version"], version_regex = "v(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "provided by flake.nix (pkgs.spirv-tools); enter scripts/dev-shell.sh", linux = "provided by flake.nix (pkgs.spirv-tools); enter scripts/dev-shell.sh" } + +[[host_tools]] +name = "wayland-scanner" +version_constraint = ">=1.20" +probe = { args = ["--version"], version_regex = "wayland-scanner (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "provided by flake.nix (pkgs.wayland-scanner); enter scripts/dev-shell.sh", linux = "provided by flake.nix (pkgs.wayland-scanner); or apt install wayland-scanner" } + +[source] +url = "https://github.com/quickshell-mirror/quickshell/archive/refs/tags/v0.3.1.tar.gz" +sha256 = "218f6327293928bcb1f9b25728b336c4ab125f67fe1babd7d47313f890a16c99" +provider = "archive" + +[license] +spdx = "LGPL-3.0-only" +url = "https://github.com/quickshell-mirror/quickshell/blob/v0.3.1/LICENSE" + +[build] +script_path = "packages/registry/quickshell/build-quickshell.sh" + +[[outputs]] +name = "quickshell" +wasm = "quickshell.wasm" diff --git a/packages/registry/quickshell/src/no-wl-proxy-interpose-on-wasm.patch b/packages/registry/quickshell/src/no-wl-proxy-interpose-on-wasm.patch new file mode 100644 index 0000000000..c0c2892a89 --- /dev/null +++ b/packages/registry/quickshell/src/no-wl-proxy-interpose-on-wasm.patch @@ -0,0 +1,70 @@ +Drop the wl_proxy_get_listener interposition on the static wasm link. + +Upstream shadows libwayland's wl_proxy_get_listener with a null-guarding +wrapper and recovers the original through dlsym(RTLD_NEXT, ...) — a +dynamic-linking mechanism. In a fully static wasm link the wrapper and +libwayland-client.a's definition collide, wasm-ld has no +--export-dynamic-symbol, and RTLD_NEXT has nothing to search. Without +the hook the behavior is upstream libwayland's (QTBUG-145022's null +deref stays possible, exactly as in any non-interposed build). + +diff -ru a/src/wayland/CMakeLists.txt b/src/wayland/CMakeLists.txt +--- a/src/wayland/CMakeLists.txt 2026-08-31 19:35:06 ++++ b/src/wayland/CMakeLists.txt 2026-08-31 19:35:06 +@@ -82,10 +82,6 @@ + output_tracking.cpp + ) + +-# required for wl_proxy_safe_deref +-target_link_libraries(quickshell-wayland PRIVATE ${CMAKE_DL_LIBS}) +-target_link_options(quickshell PRIVATE "LINKER:--export-dynamic-symbol=wl_proxy_get_listener") +- + # required to make sure the constructor is linked + add_library(quickshell-wayland-init OBJECT init.cpp) + +diff -ru a/src/wayland/wl_proxy_safe_deref.cpp b/src/wayland/wl_proxy_safe_deref.cpp +--- a/src/wayland/wl_proxy_safe_deref.cpp 2026-08-31 19:35:06 ++++ b/src/wayland/wl_proxy_safe_deref.cpp 2026-08-31 19:35:06 +@@ -1,41 +1,12 @@ +-#include + #include + #include +-#include +-#include + + #include "../core/logcat.hpp" + + namespace { + QS_LOGGING_CATEGORY(logDeref, "quickshell.wayland.safederef", QtWarningMsg); +-using wl_proxy_get_listener_t = const void* (*) (wl_proxy*); +-wl_proxy_get_listener_t original_wl_proxy_get_listener = nullptr; // NOLINT + } // namespace + +-extern "C" { +-WL_EXPORT const void* wl_proxy_get_listener(struct wl_proxy* proxy) { +- // Avoid null derefs of protocol objects in qtbase. +- // https://qt-project.atlassian.net/browse/QTBUG-145022 +- if (!proxy) [[unlikely]] { +- qCCritical(logDeref) << "wl_proxy_get_listener called with a null proxy!"; +- return nullptr; +- } +- +- return original_wl_proxy_get_listener(proxy); +-} +-} +- +-// NOLINTBEGIN (concurrency-mt-unsafe) + void installWlProxySafeDeref() { +- dlerror(); // clear old errors +- +- original_wl_proxy_get_listener = +- reinterpret_cast(dlsym(RTLD_NEXT, "wl_proxy_get_listener")); +- +- if (auto* error = dlerror()) { +- qCCritical(logDeref) << "Failed to find wl_proxy_get_listener for hooking:" << error; +- } else { +- qCInfo(logDeref) << "Installed wl_proxy_get_listener hook."; +- } ++ qCInfo(logDeref) << "Static wasm link: wl_proxy_get_listener interposition disabled."; + } +-// NOLINTEND diff --git a/packages/registry/quickshell/src/on-thread-logger-on-wasm.patch b/packages/registry/quickshell/src/on-thread-logger-on-wasm.patch new file mode 100644 index 0000000000..d192302f07 --- /dev/null +++ b/packages/registry/quickshell/src/on-thread-logger-on-wasm.patch @@ -0,0 +1,47 @@ +Run the logger on the main thread instead of a dedicated QThread. + +On this host every guest pthread is a Web Worker, and Chromium compiles +the whole 93 MB quickshell module again for each worker (~600 MB of +machine code per thread — see +docs/browser-support.md#quickshell-qml-limits). The logger thread exists +upstream to keep log I/O off the UI thread; here it buys no parallelism +(one-CPU kernel) and costs a full compiled copy of the module. The +logMessage -> ThreadLogging::onMessage connection is already direct, so +only the proxy's home thread and the two blocking init calls change. + +diff -ru a/src/core/logging.cpp b/src/core/logging.cpp +--- a/src/core/logging.cpp 2026-09-02 14:11:30 ++++ b/src/core/logging.cpp 2026-09-02 14:11:30 +@@ -335,17 +335,9 @@ + + qInstallMessageHandler(&LogManager::messageHandler); + +- qCDebug(logLogging) << "Creating offthread logger..."; +- auto* thread = new QThread(); +- instance->threadProxy.moveToThread(thread); +- thread->start(); ++ qCDebug(logLogging) << "Creating on-thread logger..."; ++ instance->threadProxy.initInThread(); + +- QMetaObject::invokeMethod( +- &instance->threadProxy, +- &LoggingThreadProxy::initInThread, +- Qt::BlockingQueuedConnection +- ); +- + qCDebug(logLogging) << "Logger initialized."; + } + +@@ -354,11 +346,7 @@ + } + + void LogManager::initFs() { +- QMetaObject::invokeMethod( +- &LogManager::instance()->threadProxy, +- "initFs", +- Qt::BlockingQueuedConnection +- ); ++ LogManager::instance()->threadProxy.initFs(); + } + + QString LogManager::rulesString() const { return this->mRulesString; } diff --git a/packages/registry/quickshell/src/one-generation-reload-on-wasm.patch b/packages/registry/quickshell/src/one-generation-reload-on-wasm.patch new file mode 100644 index 0000000000..3de0d8dc9f --- /dev/null +++ b/packages/registry/quickshell/src/one-generation-reload-on-wasm.patch @@ -0,0 +1,55 @@ +Destroy the old engine generation before building its replacement. + +Upstream builds the new generation first and only destroys the old one +after a successful load, so a config reload briefly holds two complete +generations — two QML engines, two window sets, two sets of worker +threads. On this host every guest pthread is a Web Worker and Chromium +compiles the whole quickshell module again for each worker, so the +reload transient roughly doubles the tab's compiled-code footprint +(measured 5 GB -> 10 GB on a shell swap) and crosses the ~10 GB tab +ceiling (see docs/browser-support.md#quickshell-qml-limits). Tear the +old generation down completely, then build the new one from a clean +event-loop turn. The trade: a config file that fails to load leaves no +running shell instead of falling back to the old one. + +diff -ru a/src/core/rootwrapper.cpp b/src/core/rootwrapper.cpp +--- a/src/core/rootwrapper.cpp 2026-09-02 17:26:26 ++++ b/src/core/rootwrapper.cpp 2026-09-02 17:26:26 +@@ -10,6 +10,7 @@ + #include + #include + #include ++#include + #include + #include + +@@ -206,7 +207,28 @@ + } + } + +-void RootWrapper::onWatchedFilesChanged() { this->reloadGraph(false); } ++void RootWrapper::onWatchedFilesChanged() { ++ auto* old = this->generation; ++ ++ if (old == nullptr) { ++ this->reloadGraph(false); ++ return; ++ } ++ ++ // One generation at a time: this host cannot hold the memory of two ++ // live engine generations, so tear the old one down completely before ++ // building its replacement instead of building first and swapping. ++ qInfo() << "Reloading configuration..."; ++ QObject::disconnect(old, nullptr, this, nullptr); ++ this->generation = nullptr; ++ QuickshellSettings::reset(); ++ ++ QObject::connect(old, &QObject::destroyed, this, [this]() { ++ QTimer::singleShot(0, this, [this]() { this->reloadGraph(false); }); ++ }); ++ ++ old->destroy(); ++} + + void RootWrapper::updateTooling() { + if (!this->generation) return; diff --git a/packages/registry/wayland-protocols/build-wayland-protocols.sh b/packages/registry/wayland-protocols/build-wayland-protocols.sh index a72b08c1f6..4e9eb83ed7 100755 --- a/packages/registry/wayland-protocols/build-wayland-protocols.sh +++ b/packages/registry/wayland-protocols/build-wayland-protocols.sh @@ -43,6 +43,51 @@ fi mkdir -p "$INSTALL_DIR/xml" cp "$SRC_XML"/*.xml "$INSTALL_DIR/xml/" +# Upstream wayland-protocols installs its XML under +# share/wayland-protocols/// and announces that root +# through pkg-config's pkgdatadir. CMake consumers (Quickshell's +# pkg_get_variable) resolve protocol paths against that layout, so stage +# the vendored files a second time at their upstream 1.45 paths. +# wayland.xml (core wayland) and wlr-layer-shell-unstable-v1.xml +# (wlroots) are not wayland-protocols upstream — they stay flat-only. +DATA_DIR="$INSTALL_DIR/share/wayland-protocols" +while read -r file category protocol; do + mkdir -p "$DATA_DIR/$category/$protocol" + cp "$SRC_XML/$file" "$DATA_DIR/$category/$protocol/" +done <<'MAPEOF' +linux-dmabuf-v1.xml stable linux-dmabuf +presentation-time.xml stable presentation-time +viewporter.xml stable viewporter +xdg-shell.xml stable xdg-shell +cursor-shape-v1.xml staging cursor-shape +ext-background-effect-v1.xml staging ext-background-effect +ext-idle-notify-v1.xml staging ext-idle-notify +ext-workspace-v1.xml staging ext-workspace +fractional-scale-v1.xml staging fractional-scale +xdg-activation-v1.xml staging xdg-activation +idle-inhibit-unstable-v1.xml unstable idle-inhibit +keyboard-shortcuts-inhibit-unstable-v1.xml unstable keyboard-shortcuts-inhibit +pointer-gestures-unstable-v1.xml unstable pointer-gestures +primary-selection-unstable-v1.xml unstable primary-selection +tablet-unstable-v2.xml unstable tablet +text-input-unstable-v3.xml unstable text-input +xdg-decoration-unstable-v1.xml unstable xdg-decoration +xdg-foreign-unstable-v1.xml unstable xdg-foreign +xdg-output-unstable-v1.xml unstable xdg-output +xdg-shell-unstable-v6.xml unstable xdg-shell +MAPEOF + +mkdir -p "$INSTALL_DIR/share/pkgconfig" +cat > "$INSTALL_DIR/share/pkgconfig/wayland-protocols.pc" <<'PCEOF' +prefix=${pcfiledir}/../.. +datarootdir=${prefix}/share +pkgdatadir=${datarootdir}/wayland-protocols + +Name: Wayland Protocols +Description: Wayland protocol files +Version: 1.45 +PCEOF + echo "==> wayland-protocols staged at $INSTALL_DIR" for f in "$INSTALL_DIR"/xml/*.xml; do echo " xml/$(basename "$f") ($(wc -c < "$f") bytes)" diff --git a/packages/registry/wayland-protocols/build.toml b/packages/registry/wayland-protocols/build.toml index 9a7f24cc2d..175e3618b0 100644 --- a/packages/registry/wayland-protocols/build.toml +++ b/packages/registry/wayland-protocols/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/wayland-protocols/build-wayland-protocols.sh" repo_url = "https://github.com/Automattic/kandelo.git" commit = "1dc10fcc1761475964c0108628a800c3c5e468e5" -revision = 1 +revision = 2 # Vendored protocol XML is part of the cache key: editing either file # (e.g. bumping the pinned protocol version) must invalidate the staged @@ -29,6 +29,9 @@ inputs = [ "packages/registry/wayland-protocols/xml/xdg-foreign-unstable-v1.xml", "packages/registry/wayland-protocols/xml/keyboard-shortcuts-inhibit-unstable-v1.xml", "packages/registry/wayland-protocols/xml/idle-inhibit-unstable-v1.xml", + "packages/registry/wayland-protocols/xml/ext-background-effect-v1.xml", + "packages/registry/wayland-protocols/xml/ext-idle-notify-v1.xml", + "packages/registry/wayland-protocols/xml/ext-workspace-v1.xml", ] # Required by the build.toml schema even for a source package (which is diff --git a/packages/registry/wayland-protocols/package.toml b/packages/registry/wayland-protocols/package.toml index 644826b7b4..1fba6cddb0 100644 --- a/packages/registry/wayland-protocols/package.toml +++ b/packages/registry/wayland-protocols/package.toml @@ -61,8 +61,12 @@ url = "https://gitlab.freedesktop.org/wayland/wayland-protocols/-/blob/1.45/COPY script_path = "packages/registry/wayland-protocols/build-wayland-protocols.sh" [outputs] +pkgconfig = ["share/pkgconfig/wayland-protocols.pc"] files = [ "xml/cursor-shape-v1.xml", + "xml/ext-background-effect-v1.xml", + "xml/ext-idle-notify-v1.xml", + "xml/ext-workspace-v1.xml", "xml/fractional-scale-v1.xml", "xml/idle-inhibit-unstable-v1.xml", "xml/keyboard-shortcuts-inhibit-unstable-v1.xml", @@ -81,4 +85,24 @@ files = [ "xml/xdg-output-unstable-v1.xml", "xml/xdg-shell-unstable-v6.xml", "xml/xdg-shell.xml", + "share/wayland-protocols/stable/linux-dmabuf/linux-dmabuf-v1.xml", + "share/wayland-protocols/stable/presentation-time/presentation-time.xml", + "share/wayland-protocols/stable/viewporter/viewporter.xml", + "share/wayland-protocols/stable/xdg-shell/xdg-shell.xml", + "share/wayland-protocols/staging/cursor-shape/cursor-shape-v1.xml", + "share/wayland-protocols/staging/ext-background-effect/ext-background-effect-v1.xml", + "share/wayland-protocols/staging/ext-idle-notify/ext-idle-notify-v1.xml", + "share/wayland-protocols/staging/ext-workspace/ext-workspace-v1.xml", + "share/wayland-protocols/staging/fractional-scale/fractional-scale-v1.xml", + "share/wayland-protocols/staging/xdg-activation/xdg-activation-v1.xml", + "share/wayland-protocols/unstable/idle-inhibit/idle-inhibit-unstable-v1.xml", + "share/wayland-protocols/unstable/keyboard-shortcuts-inhibit/keyboard-shortcuts-inhibit-unstable-v1.xml", + "share/wayland-protocols/unstable/pointer-gestures/pointer-gestures-unstable-v1.xml", + "share/wayland-protocols/unstable/primary-selection/primary-selection-unstable-v1.xml", + "share/wayland-protocols/unstable/tablet/tablet-unstable-v2.xml", + "share/wayland-protocols/unstable/text-input/text-input-unstable-v3.xml", + "share/wayland-protocols/unstable/xdg-decoration/xdg-decoration-unstable-v1.xml", + "share/wayland-protocols/unstable/xdg-foreign/xdg-foreign-unstable-v1.xml", + "share/wayland-protocols/unstable/xdg-output/xdg-output-unstable-v1.xml", + "share/wayland-protocols/unstable/xdg-shell/xdg-shell-unstable-v6.xml", ] diff --git a/packages/registry/wayland-protocols/xml/ext-background-effect-v1.xml b/packages/registry/wayland-protocols/xml/ext-background-effect-v1.xml new file mode 100644 index 0000000000..33cc2a2605 --- /dev/null +++ b/packages/registry/wayland-protocols/xml/ext-background-effect-v1.xml @@ -0,0 +1,129 @@ + + + + Copyright (C) 2015 Martin Gräßlin + Copyright (C) 2015 Marco Martin + Copyright (C) 2020 Vlad Zahorodnii + Copyright (C) 2024 Xaver Hugl + + 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 protocol provides a way to improve visuals of translucent surfaces + by applying effects like blur to the background behind them. + + The capabilities are send when the global is bound, and every time they + change. Note that when the capability goes away, the corresponding effect + is no longer applied by the compositor, even if it was set before. + + Warning! The protocol described in this file is currently in the testing + phase. Backward compatible changes may be added together with the + corresponding interface version bump. Backward incompatible changes can + only be done by creating a new major version of the extension. + + + + + Informs the server that the client will no longer be using this + protocol object. Existing objects created by this object are not + affected. + + + + + + + + + + + + + + + + + + + Instantiate an interface extension for the given wl_surface to add + effects like blur for the background behind it. + + If the given wl_surface already has a ext_background_effect_surface_v1 + object associated, the background_effect_exists protocol error will be + raised. + + + + + + + + + The background effect object provides a way to specify a region behind + a surface that should have background effects like blur applied. + + If the wl_surface associated with the ext_background_effect_surface_v1 + object has been destroyed, this object becomes inert. + + + + + Informs the server that the client will no longer be using this protocol + object. The effect regions will be removed on the next commit. + + + + + + + + + + This request sets the region of the surface that will have its + background blurred. + + The blur region is specified in the surface-local coordinates, and + clipped by the compositor to the surface size. + + The initial value for the blur region is empty. Setting the pending + blur region has copy semantics, and the wl_region object can be + destroyed immediately. A NULL wl_region removes the effect. + + The blur region is double-buffered state, and will be applied on + the next wl_surface.commit. + + The blur algorithm is subject to compositor policies. + + If the associated surface has been destroyed, the surface_destroyed + error will be raised. + + + + + + diff --git a/packages/registry/wayland-protocols/xml/ext-idle-notify-v1.xml b/packages/registry/wayland-protocols/xml/ext-idle-notify-v1.xml new file mode 100644 index 0000000000..db7d9c1650 --- /dev/null +++ b/packages/registry/wayland-protocols/xml/ext-idle-notify-v1.xml @@ -0,0 +1,131 @@ + + + + Copyright © 2015 Martin Gräßlin + Copyright © 2022 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 clients to monitor user idle status. + + After binding to this global, clients can create ext_idle_notification_v1 + objects to get notified when the user is idle for a given amount of time. + + + + + Destroy the manager object. All objects created via this interface + remain valid. + + + + + + Create a new idle notification object. + + The notification object has a minimum timeout duration and is tied to a + seat. The client will be notified if the seat is inactive for at least + the provided timeout. See ext_idle_notification_v1 for more details. + + A zero timeout is valid and means the client wants to be notified as + soon as possible when the seat is inactive. + + + + + + + + + + + Create a new idle notification object to track input from the + user, such as keyboard and mouse movement. Because this object is + meant to track user input alone, it ignores idle inhibitors. + + The notification object has a minimum timeout duration and is tied to a + seat. The client will be notified if the seat is inactive for at least + the provided timeout. See ext_idle_notification_v1 for more details. + + A zero timeout is valid and means the client wants to be notified as + soon as possible when the seat is inactive. + + + + + + + + + + + This interface is used by the compositor to send idle notification events + to clients. + + Initially the notification object is not idle. The notification object + becomes idle when no user activity has happened for at least the timeout + duration, starting from the creation of the notification object. User + activity may include input events or a presence sensor, but is + compositor-specific. + + How this notification responds to idle inhibitors depends on how + it was constructed. If constructed from the + get_idle_notification request, then if an idle inhibitor is + active (e.g. another client has created a zwp_idle_inhibitor_v1 + on a visible surface), the compositor must not make the + notification object idle. However, if constructed from the + get_input_idle_notification request, then idle inhibitors are + ignored, and only input from the user, e.g. from a keyboard or + mouse, counts as activity. + + When the notification object becomes idle, an idled event is sent. When + user activity starts again, the notification object stops being idle, + a resumed event is sent and the timeout is restarted. + + + + + Destroy the notification object. + + + + + + This event is sent when the notification object becomes idle. + + It's a compositor protocol error to send this event twice without a + resumed event in-between. + + + + + + This event is sent when the notification object stops being idle. + + It's a compositor protocol error to send this event twice without an + idled event in-between. It's a compositor protocol error to send this + event prior to any idled event. + + + + diff --git a/packages/registry/wayland-protocols/xml/ext-workspace-v1.xml b/packages/registry/wayland-protocols/xml/ext-workspace-v1.xml new file mode 100644 index 0000000000..74074b3288 --- /dev/null +++ b/packages/registry/wayland-protocols/xml/ext-workspace-v1.xml @@ -0,0 +1,422 @@ + + + + Copyright © 2019 Christopher Billington + Copyright © 2020 Ilia Bozhinov + Copyright © 2022 Victoria Brekenfeld + + Permission to use, copy, modify, distribute, and sell this + software and its documentation for any purpose is hereby granted + without fee, provided that the above copyright notice appear in + all copies and that both that copyright notice and this permission + notice appear in supporting documentation, and that the name of + the copyright holders not be used in advertising or publicity + pertaining to distribution of the software without specific, + written prior permission. The copyright holders make no + representations about the suitability of this software for any + purpose. It is provided "as is" without express or implied + warranty. + + THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS + SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY + SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN + AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, + ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + + + + Workspaces, also called virtual desktops, are groups of surfaces. A + compositor with a concept of workspaces may only show some such groups of + surfaces (those of 'active' workspaces) at a time. 'Activating' a + workspace is a request for the compositor to display that workspace's + surfaces as normal, whereas the compositor may hide or otherwise + de-emphasise surfaces that are associated only with 'inactive' workspaces. + Workspaces are grouped by which sets of outputs they correspond to, and + may contain surfaces only from those outputs. In this way, it is possible + for each output to have its own set of workspaces, or for all outputs (or + any other arbitrary grouping) to share workspaces. Compositors may + optionally conceptually arrange each group of workspaces in an + N-dimensional grid. + + The purpose of this protocol is to enable the creation of taskbars and + docks by providing them with a list of workspaces and their properties, + and allowing them to activate and deactivate workspaces. + + After a client binds the ext_workspace_manager_v1, each workspace will be + sent via the workspace event. + + + + + This event is emitted whenever a new workspace group has been created. + + All initial details of the workspace group (outputs) will be + sent immediately after this event via the corresponding events in + ext_workspace_group_handle_v1 and ext_workspace_handle_v1. + + + + + + + This event is emitted whenever a new workspace has been created. + + All initial details of the workspace (name, coordinates, state) will + be sent immediately after this event via the corresponding events in + ext_workspace_handle_v1. + + Workspaces start off unassigned to any workspace group. + + + + + + + The client must send this request after it has finished sending other + requests. The compositor must process a series of requests preceding a + commit request atomically. + + This allows changes to the workspace properties to be seen as atomic, + even if they happen via multiple events, and even if they involve + multiple ext_workspace_handle_v1 objects, for example, deactivating one + workspace and activating another. + + + + + + This event is sent after all changes in all workspaces and workspace groups have been + sent. + + This allows changes to one or more ext_workspace_group_handle_v1 + properties and ext_workspace_handle_v1 properties + to be seen as atomic, even if they happen via multiple events. + In particular, an output moving from one workspace group to + another sends an output_enter event and an output_leave event to the two + ext_workspace_group_handle_v1 objects in question. The compositor sends + the done event only after updating the output information in both + workspace groups. + + + + + + This event indicates that the compositor is done sending events to the + ext_workspace_manager_v1. The server will destroy the object + immediately after sending this request. + + + + + + Indicates the client no longer wishes to receive events for new + workspace groups. However the compositor may emit further workspace + events, until the finished event is emitted. The compositor is expected + to send the finished event eventually once the stop request has been processed. + + The client must not send any requests after this one, doing so will raise a wl_display + invalid_object error. + + + + + + + + A ext_workspace_group_handle_v1 object represents a workspace group + that is assigned a set of outputs and contains a number of workspaces. + + The set of outputs assigned to the workspace group is conveyed to the client via + output_enter and output_leave events, and its workspaces are conveyed with + workspace events. + + For example, a compositor which has a set of workspaces for each output may + advertise a workspace group (and its workspaces) per output, whereas a compositor + where a workspace spans all outputs may advertise a single workspace group for all + outputs. + + + + + + + + + This event advertises the capabilities supported by the compositor. If + a capability isn't supported, clients should hide or disable the UI + elements that expose this functionality. For instance, if the + compositor doesn't advertise support for creating workspaces, a button + triggering the create_workspace request should not be displayed. + + The compositor will ignore requests it doesn't support. For instance, + a compositor which doesn't advertise support for creating workspaces will ignore + create_workspace requests. + + Compositors must send this event once after creation of an + ext_workspace_group_handle_v1. When the capabilities change, compositors + must send this event again. + + + + + + + This event is emitted whenever an output is assigned to the workspace + group or a new `wl_output` object is bound by the client, which was already + assigned to this workspace_group. + + + + + + + This event is emitted whenever an output is removed from the workspace + group. + + + + + + + This event is emitted whenever a workspace is assigned to this group. + A workspace may only ever be assigned to a single group at a single point + in time, but can be re-assigned during it's lifetime. + + + + + + + This event is emitted whenever a workspace is removed from this group. + + + + + + + This event is send when the group associated with the ext_workspace_group_handle_v1 + has been removed. After sending this request the compositor will immediately consider + the object inert. Any requests will be ignored except the destroy request. + It is guaranteed there won't be any more events referencing this + ext_workspace_group_handle_v1. + + The compositor must remove all workspaces belonging to a workspace group + via a workspace_leave event before removing the workspace group. + + + + + + Request that the compositor create a new workspace with the given name + and assign it to this group. + + There is no guarantee that the compositor will create a new workspace, + or that the created workspace will have the provided name. + + + + + + + Destroys the ext_workspace_group_handle_v1 object. + + This request should be send either when the client does not want to + use the workspace group object any more or after the removed event to finalize + the destruction of the object. + + + + + + + A ext_workspace_handle_v1 object represents a workspace that handles a + group of surfaces. + + Each workspace has: + - a name, conveyed to the client with the name event + - potentially an id conveyed with the id event + - a list of states, conveyed to the client with the state event + - and optionally a set of coordinates, conveyed to the client with the + coordinates event + + The client may request that the compositor activate or deactivate the workspace. + + Each workspace can belong to only a single workspace group. + Depending on the compositor policy, there might be workspaces with + the same name in different workspace groups, but these workspaces are still + separate (e.g. one of them might be active while the other is not). + + + + + If this event is emitted, it will be send immediately after the + ext_workspace_handle_v1 is created or when an id is assigned to + a workspace (at most once during it's lifetime). + + An id will never change during the lifetime of the `ext_workspace_handle_v1` + and is guaranteed to be unique during it's lifetime. + + Ids are not human-readable and shouldn't be displayed, use `name` for that purpose. + + Compositors are expected to only send ids for workspaces likely stable across multiple + sessions and can be used by clients to store preferences for workspaces. Workspaces without + ids should be considered temporary and any data associated with them should be deleted once + the respective object is lost. + + + + + + + This event is emitted immediately after the ext_workspace_handle_v1 is + created and whenever the name of the workspace changes. + + A name is meant to be human-readable and can be displayed to a user. + Unlike the id it is neither stable nor unique. + + + + + + + This event is used to organize workspaces into an N-dimensional grid + within a workspace group, and if supported, is emitted immediately after + the ext_workspace_handle_v1 is created and whenever the coordinates of + the workspace change. Compositors may not send this event if they do not + conceptually arrange workspaces in this way. If compositors simply + number workspaces, without any geometric interpretation, they may send + 1D coordinates, which clients should not interpret as implying any + geometry. Sending an empty array means that the compositor no longer + orders the workspace geometrically. + + Coordinates have an arbitrary number of dimensions N with an uint32 + position along each dimension. By convention if N > 1, the first + dimension is X, the second Y, the third Z, and so on. The compositor may + chose to utilize these events for a more novel workspace layout + convention, however. No guarantee is made about the grid being filled or + bounded; there may be a workspace at coordinate 1 and another at + coordinate 1000 and none in between. Within a workspace group, however, + workspaces must have unique coordinates of equal dimensionality. + + + + + + + The different states that a workspace can have. + + + + + + + The workspace is not visible in its workspace group, and clients + attempting to visualize the compositor workspace state should not + display such workspaces. + + + + + + + This event is emitted immediately after the ext_workspace_handle_v1 is + created and each time the workspace state changes, either because of a + compositor action or because of a request in this protocol. + + Missing states convey the opposite meaning, e.g. an unset active bit + means the workspace is currently inactive. + + + + + + + + + + + + + + This event advertises the capabilities supported by the compositor. If + a capability isn't supported, clients should hide or disable the UI + elements that expose this functionality. For instance, if the + compositor doesn't advertise support for removing workspaces, a button + triggering the remove request should not be displayed. + + The compositor will ignore requests it doesn't support. For instance, + a compositor which doesn't advertise support for remove will ignore + remove requests. + + Compositors must send this event once after creation of an + ext_workspace_handle_v1 . When the capabilities change, compositors + must send this event again. + + + + + + + This event is send when the workspace associated with the ext_workspace_handle_v1 + has been removed. After sending this request, the compositor will immediately consider + the object inert. Any requests will be ignored except the destroy request. + + It is guaranteed there won't be any more events referencing this + ext_workspace_handle_v1. + + The compositor must only remove a workspaces not currently belonging to any + workspace_group. + + + + + + Destroys the ext_workspace_handle_v1 object. + + This request should be made either when the client does not want to + use the workspace object any more or after the remove event to finalize + the destruction of the object. + + + + + + Request that this workspace be activated. + + There is no guarantee the workspace will be actually activated, and + behaviour may be compositor-dependent. For example, activating a + workspace may or may not deactivate all other workspaces in the same + group. + + + + + + Request that this workspace be deactivated. + + There is no guarantee the workspace will be actually deactivated. + + + + + + Requests that this workspace is assigned to the given workspace group. + + There is no guarantee the workspace will be assigned. + + + + + + + Request that this workspace be removed. + + There is no guarantee the workspace will be actually removed. + + + + diff --git a/packages/sets/local-supported.toml b/packages/sets/local-supported.toml index 6634f935ec..cdaede72b5 100644 --- a/packages/sets/local-supported.toml +++ b/packages/sets/local-supported.toml @@ -62,6 +62,10 @@ class = "platform" name = "cairomm" class = "platform" +[[packages]] +name = "cli11" +class = "platform" + [[packages]] name = "coreutils" class = "user-software" @@ -410,6 +414,26 @@ class = "platform" name = "posix-utils-lite" class = "user-software" +[[packages]] +name = "qtbase" +class = "platform" + +[[packages]] +name = "qtdeclarative" +class = "platform" + +[[packages]] +name = "qtgallery" +class = "user-software" + +[[packages]] +name = "qtshadertools" +class = "platform" + +[[packages]] +name = "quickshell" +class = "user-software" + [[packages]] name = "redis" class = "user-software" diff --git a/programs/dri-modeset.c b/programs/dri-modeset.c index 037f54c5fc..3224278036 100644 --- a/programs/dri-modeset.c +++ b/programs/dri-modeset.c @@ -33,7 +33,9 @@ * flag (see handoff-58 — v1 simplification). We still pass the flag * to match real DRM semantics and to match what SDL2 KMSDRM does. */ +#include #include +#include #include #include #include @@ -131,11 +133,24 @@ int main(int argc, char **argv) { ctx.version = 2; ctx.page_flip_handler = on_flip; + /* Upstream libdrm's drmHandleEvent() does a single read(fd, ...) + * and assumes Linux's blocking-mode read(/dev/dri/cardN) parks the + * caller until the kernel's vblank pump delivers an event. Our + * kernel returns 0 immediately on an empty event ring (centralized + * mode has no SYS_READ EAGAIN-retry plumbing), so a bare + * drmHandleEvent races the vblank tick: it returns without + * dispatching, the queued flip stays pending, and the next + * drmModePageFlip hits EBUSY. Real consumers (SDL2 KMSDRM, + * weston, mutter) already poll() first; do the same here. */ + struct pollfd pfd = { .fd = card, .events = POLLIN }; for (int i = 0; i < frames; i++) { if (drmModePageFlip(card, crtc_id, fb_id, DRM_MODE_PAGE_FLIP_EVENT, (void *)(uintptr_t) i) < 0) FAIL("drmModePageFlip"); + int pr; + do { pr = poll(&pfd, 1, -1); } while (pr < 0 && errno == EINTR); + if (pr < 0) FAIL("poll"); if (drmHandleEvent(card, &ctx) < 0) FAIL("drmHandleEvent"); } diff --git a/programs/inotify-enosys.c b/programs/inotify-enosys.c new file mode 100644 index 0000000000..235734e7a2 --- /dev/null +++ b/programs/inotify-enosys.c @@ -0,0 +1,28 @@ +#include +#include +#include + +static int expect_enosys(const char *name, int rc) { + if (rc != -1 || errno != ENOSYS) { + fprintf(stderr, "%s: rc=%d errno=%d, expected -1/ENOSYS\n", name, rc, errno); + return 1; + } + return 0; +} + +int main(void) { + int failures = 0; + + errno = 0; + failures += expect_enosys("inotify_init", inotify_init()); + errno = 0; + failures += expect_enosys("inotify_init1", inotify_init1(0)); + errno = 0; + failures += expect_enosys("inotify_add_watch", inotify_add_watch(0, "/", IN_CREATE)); + errno = 0; + failures += expect_enosys("inotify_rm_watch", inotify_rm_watch(0, 1)); + + if (failures) return 1; + printf("PASS\n"); + return 0; +} diff --git a/programs/klauncher.c b/programs/klauncher.c index 27a148820e..1847205b5b 100644 --- a/programs/klauncher.c +++ b/programs/klauncher.c @@ -83,6 +83,7 @@ static struct { int match[MAX_APPS]; /* indices of apps matching the query */ int n_match; int selected; /* index into match[] */ + int scroll; /* first visible index into match[] */ char query[64]; int menu; /* --menu: root level with submenus */ enum level level; @@ -267,6 +268,7 @@ static void enter_level(enum level lvl) { st.n_apps = 0; st.query[0] = '\0'; st.selected = 0; + st.scroll = 0; if (lvl == L_ROOT) load_root(); else if (lvl == L_APPS) load_apps(); else load_themes(); @@ -331,7 +333,16 @@ static void render(struct wpk_surface *s, struct wpk_font *font) { wpk_text(s, font, 14, baseline, prompt, palette.foreground); int y = PROMPT_H + 4; - for (int i = 0; i < st.n_match && y + ROW_H <= s->h; i++, y += ROW_H) { + int visible = (s->h - y) / ROW_H; + if (visible < 1) visible = 1; + int max_scroll = st.n_match - visible; + if (max_scroll < 0) max_scroll = 0; + if (st.scroll > max_scroll) st.scroll = max_scroll; + if (st.scroll > st.selected) st.scroll = st.selected; + if (st.scroll < st.selected - visible + 1) + st.scroll = st.selected - visible + 1; + for (int i = st.scroll; i < st.n_match && y + ROW_H <= s->h; + i++, y += ROW_H) { int selected = i == st.selected; if (selected) wpk_rect(s, 6, y, s->w - 12, ROW_H, palette.accent); const struct app *a = &st.apps[st.match[i]]; diff --git a/programs/qtgallery/qtgallery.cpp b/programs/qtgallery/qtgallery.cpp new file mode 100644 index 0000000000..d1a647f3b2 --- /dev/null +++ b/programs/qtgallery/qtgallery.cpp @@ -0,0 +1,503 @@ +/* + * qtgallery — the Omarchy theme gallery, a Qt application on the desktop. + * + * A QRasterWindow painted through QtGui's raster engine onto a wl_shm + * buffer. Each installed theme (a directory under WLC_THEME_DIR, default + * /usr/share/kandelo/themes, holding a theme.conf) becomes a card painted + * as a miniature desktop from its own palette: wallpaper gradient, bar + * strip, two tiled windows with the active border. Activating a card + * writes `dispatch theme ` to the compositor's kwlctl socket + * (KWLCTL_SOCKET, default /tmp/kwlctl-0) — the same control surface + * kwlctl and the CTRL+SHIFT+Space bind use — and the whole desktop + * repaints from the new palette. + * + * A second row lists the Quickshell shells staged under + * QTGALLERY_SHELL_DIR (default /usr/share/kandelo/quickshell): activating + * one stages that QML file as /tmp/qtgallery-active.qml and the gallery's + * single Quickshell child live-reloads it in place — the swap-the-shell + * demonstration Quickshell exists for. One child for every swap, never a + * respawn: each Quickshell process costs the browser host a fresh compiled + * copy of the whole Qt wasm module per worker, and respawn churn at that + * size crashes the tab (docs/browser-support.md#quickshell-qml-limits). + * + * Cards activate by click (hover highlights) or by arrows + Enter. + * Escape or Q closes. + * + * Markers on stdout (the smoke test and the browser spec read them): + * GALLERY_PLATFORM= after QGuiApplication construction + * GALLERY_THEMES n= after the theme scan + * GALLERY_SHELLS n= after the shell scan + * GALLERY_EXPOSED x once, on the first paint + * GALLERY_APPLY theme= reply= after a theme card activates + * GALLERY_SHELL file=

pid= after a shell card activates + */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +Q_IMPORT_PLUGIN(QWaylandIntegrationPlugin) +Q_IMPORT_PLUGIN(QWaylandXdgShellIntegrationPlugin) + +namespace { + +const QColor kWindow(0x16, 0x16, 0x1e); +const QColor kCard(0x1f, 0x23, 0x35); +const QColor kCardBorder(0x3b, 0x42, 0x61); +const QColor kText(0xc0, 0xca, 0xf5); +const QColor kDimText(0x56, 0x5f, 0x89); +const QColor kAccent(0x7a, 0xa2, 0xf7); + +struct Theme { + QString name; + QString title; + QColor wallpaperTop; + QColor wallpaperBottom; + QColor bar; + QColor border; + QColor occupied; + QColor foreground; + QColor accent; +}; + +QString themesRoot() +{ + const QByteArray env = qgetenv("WLC_THEME_DIR"); + return env.isEmpty() ? QStringLiteral("/usr/share/kandelo/themes") + : QString::fromUtf8(env); +} + +QString shellsRoot() +{ + const QByteArray env = qgetenv("QTGALLERY_SHELL_DIR"); + return env.isEmpty() ? QStringLiteral("/usr/share/kandelo/quickshell") + : QString::fromUtf8(env); +} + +const QString kActiveShellPath = QStringLiteral("/tmp/qtgallery-active.qml"); + +/* The atomic commit keeps Quickshell's content-hash reload from ever + * reading a half-written file. */ +bool stageActiveShell(const QString &file) +{ + QFile source(file); + if (!source.open(QIODevice::ReadOnly)) return false; + QSaveFile active(kActiveShellPath); + if (!active.open(QIODevice::WriteOnly)) return false; + active.write(source.readAll()); + return active.commit(); +} + +QColor parseColor(const QString &value, const QColor &fallback) +{ + QString s = value.trimmed(); + if (s.startsWith('#')) s.remove(0, 1); + if (s.startsWith(QStringLiteral("0x"))) s.remove(0, 2); + bool ok = false; + const uint rgb = s.toUInt(&ok, 16); + if (!ok) return fallback; + return QColor::fromRgb(0xff000000u | rgb); +} + +QString titleCase(const QString &slug) +{ + QStringList words = slug.split('-', Qt::SkipEmptyParts); + for (QString &w : words) + w[0] = w[0].toUpper(); + return words.join(' '); +} + +Theme loadTheme(const QString &dir, const QString &name) +{ + Theme t; + t.name = name; + t.title = titleCase(name); + t.wallpaperTop = QColor(0x1a, 0x1b, 0x26); + t.wallpaperBottom = QColor(0x24, 0x28, 0x3b); + t.bar = kWindow; + t.border = kAccent; + t.occupied = QColor(0x29, 0x2e, 0x42); + t.foreground = kText; + t.accent = kAccent; + + QFile f(dir + '/' + name + QStringLiteral("/theme.conf")); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return t; + QTextStream in(&f); + while (!in.atEnd()) { + const QString line = in.readLine().trimmed(); + if (line.isEmpty() || line.startsWith('#')) continue; + const qsizetype eq = line.indexOf('='); + if (eq < 0) continue; + const QString key = line.left(eq).trimmed(); + const QString val = line.mid(eq + 1).trimmed(); + if (key == QStringLiteral("wallpaper_top")) + t.wallpaperTop = parseColor(val, t.wallpaperTop); + else if (key == QStringLiteral("wallpaper_bottom")) + t.wallpaperBottom = parseColor(val, t.wallpaperBottom); + else if (key == QStringLiteral("bar")) + t.bar = parseColor(val, t.bar); + else if (key == QStringLiteral("border_active")) + t.border = parseColor(val, t.border); + else if (key == QStringLiteral("occupied")) + t.occupied = parseColor(val, t.occupied); + else if (key == QStringLiteral("foreground")) + t.foreground = parseColor(val, t.foreground); + else if (key == QStringLiteral("accent")) + t.accent = parseColor(val, t.accent); + } + return t; +} + +/* One request/reply line on the compositor's control socket, the exact + * conversation kwlctl.c holds. Returns the trimmed reply ("ok" on + * success), or an empty string when the socket is unreachable. */ +QString kwlctlDispatch(const QString &command) +{ + const QByteArray env = qgetenv("KWLCTL_SOCKET"); + const QByteArray path = env.isEmpty() ? QByteArrayLiteral("/tmp/kwlctl-0") + : env; + const int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return QString(); + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path.constData(), sizeof(addr.sun_path) - 1); + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + close(fd); + return QString(); + } + const QByteArray line = command.toUtf8() + '\n'; + if (write(fd, line.constData(), (size_t)line.size()) != line.size()) { + close(fd); + return QString(); + } + char buf[256]; + QByteArray reply; + ssize_t r; + while ((r = read(fd, buf, sizeof(buf))) > 0) + reply.append(buf, (qsizetype)r); + close(fd); + return QString::fromUtf8(reply).trimmed(); +} + +} // namespace + +class GalleryWindow : public QRasterWindow +{ +public: + GalleryWindow() + { + setTitle(QStringLiteral("Theme Gallery")); + + const QDir root(themesRoot()); + for (const QString &entry : + root.entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name)) { + if (QFile::exists(root.filePath( + entry + QStringLiteral("/theme.conf")))) + m_themes.append(loadTheme(root.absolutePath(), entry)); + } + std::printf("GALLERY_THEMES n=%d\n", (int)m_themes.size()); + + const QDir shells(shellsRoot()); + for (const QString &entry : + shells.entryList({ QStringLiteral("*.qml") }, QDir::Files, + QDir::Name)) + m_shells.append(shells.absoluteFilePath(entry)); + std::printf("GALLERY_SHELLS n=%d\n", (int)m_shells.size()); + std::fflush(stdout); + + const int themeRows = ((int)m_themes.size() + kColumns - 1) / kColumns; + resize(kMargin * 2 + kColumns * kCardW + (kColumns - 1) * kGap, + kHeaderH + kSectionH + themeRows * (kCardH + kGap) + kSectionH + + kShellH + kMargin); + + auto *timer = new QTimer(this); + connect(timer, &QTimer::timeout, this, + [this] { update(); }); + timer->start(1000); + } + + ~GalleryWindow() override + { + if (m_quickshell) { + m_quickshell->kill(); + m_quickshell->waitForFinished(3000); + } + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + p.fillRect(QRect(0, 0, width(), height()), kWindow); + + QFont titleFont = p.font(); + titleFont.setPixelSize(20); + titleFont.setBold(true); + p.setFont(titleFont); + p.setPen(kText); + p.drawText(QRect(kMargin, 0, width() - 2 * kMargin, kHeaderH), + Qt::AlignVCenter | Qt::AlignLeft, + QStringLiteral("Omarchy Theme Gallery")); + QFont small = p.font(); + small.setPixelSize(12); + small.setBold(false); + p.setFont(small); + p.setPen(kDimText); + p.drawText(QRect(kMargin, 0, width() - 2 * kMargin, kHeaderH), + Qt::AlignVCenter | Qt::AlignRight, + QTime::currentTime().toString(QStringLiteral("hh:mm:ss"))); + + p.setPen(kDimText); + p.drawText(QRect(kMargin, kHeaderH, width() - 2 * kMargin, kSectionH), + Qt::AlignVCenter | Qt::AlignLeft, + QStringLiteral("Themes — click to restyle the desktop")); + for (int i = 0; i < (int)m_themes.size(); i++) + paintThemeCard(p, i); + + const int shellTop = shellSectionTop(); + p.setPen(kDimText); + p.drawText(QRect(kMargin, shellTop, width() - 2 * kMargin, kSectionH), + Qt::AlignVCenter | Qt::AlignLeft, + QStringLiteral("Quickshell — click to swap the shell")); + for (int i = 0; i < (int)m_shells.size(); i++) + paintShellCard(p, i); + + if (!m_exposed) { + m_exposed = true; + std::printf("GALLERY_EXPOSED %dx%d\n", width(), height()); + std::fflush(stdout); + } + } + + void mouseMoveEvent(QMouseEvent *e) override + { + const int hit = cardAt(e->position().toPoint()); + if (hit == m_hover) return; + m_hover = hit; + update(); + } + + void mousePressEvent(QMouseEvent *e) override + { + const int hit = cardAt(e->position().toPoint()); + if (hit < 0) return; + m_selected = hit; + activate(hit); + } + + void keyPressEvent(QKeyEvent *e) override + { + const int total = (int)m_themes.size() + (int)m_shells.size(); + switch (e->key()) { + case Qt::Key_Escape: + case Qt::Key_Q: + close(); + return; + case Qt::Key_Left: + if (total == 0) return; + m_selected = (m_selected + total - 1) % total; + break; + case Qt::Key_Right: + if (total == 0) return; + m_selected = (m_selected + 1) % total; + break; + case Qt::Key_Return: + case Qt::Key_Enter: + case Qt::Key_Space: + activate(m_selected); + return; + default: + return; + } + update(); + } + +private: + static constexpr int kColumns = 3; + static constexpr int kMargin = 24; + static constexpr int kGap = 16; + static constexpr int kCardW = 216; + static constexpr int kCardH = 132; + static constexpr int kShellH = 64; + static constexpr int kHeaderH = 56; + static constexpr int kSectionH = 32; + + int shellSectionTop() const + { + const int themeRows = ((int)m_themes.size() + kColumns - 1) / kColumns; + return kHeaderH + kSectionH + themeRows * (kCardH + kGap); + } + + QRect themeCardRect(int i) const + { + return QRect(kMargin + (i % kColumns) * (kCardW + kGap), + kHeaderH + kSectionH + (i / kColumns) * (kCardH + kGap), + kCardW, kCardH); + } + + QRect shellCardRect(int i) const + { + return QRect(kMargin + i * (kCardW + kGap), + shellSectionTop() + kSectionH, kCardW, kShellH); + } + + /* Cards index a single selection space: themes first, shells after. */ + int cardAt(const QPoint &pos) const + { + for (int i = 0; i < (int)m_themes.size(); i++) + if (themeCardRect(i).contains(pos)) return i; + for (int i = 0; i < (int)m_shells.size(); i++) + if (shellCardRect(i).contains(pos)) + return (int)m_themes.size() + i; + return -1; + } + + void paintCardFrame(QPainter &p, const QRect &r, int index) + { + QPainterPath path; + path.addRoundedRect(r, 10, 10); + p.fillPath(path, kCard); + QPen pen(index == m_selected ? kAccent + : index == m_hover ? kText + : kCardBorder); + pen.setWidth(2); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawPath(path); + } + + void paintThemeCard(QPainter &p, int i) + { + const Theme &t = m_themes[i]; + const QRect r = themeCardRect(i); + paintCardFrame(p, r, i); + + const QRect preview = r.adjusted(10, 10, -10, -34); + QLinearGradient wall(preview.topLeft(), preview.bottomLeft()); + wall.setColorAt(0, t.wallpaperTop); + wall.setColorAt(1, t.wallpaperBottom); + QPainterPath clip; + clip.addRoundedRect(preview, 6, 6); + p.save(); + p.setClipPath(clip); + p.fillRect(preview, wall); + p.fillRect(QRect(preview.x(), preview.y(), preview.width(), 12), + t.bar); + p.fillRect(QRect(preview.x() + 4, preview.y() + 3, 24, 6), t.accent); + + const int winY = preview.y() + 18; + const int winH = preview.height() - 24; + const int winW = (preview.width() - 18) / 2; + QPen border(t.border); + border.setWidth(2); + p.setPen(border); + p.setBrush(t.occupied); + p.drawRect(QRect(preview.x() + 6, winY, winW, winH)); + p.setPen(QPen(t.occupied.lighter(130), 2)); + p.drawRect(QRect(preview.x() + 12 + winW, winY, winW, winH)); + p.restore(); + + QFont f = p.font(); + f.setPixelSize(13); + p.setFont(f); + p.setPen(t.foreground); + p.drawText(QRect(r.x() + 12, r.bottom() - 28, r.width() - 24, 22), + Qt::AlignVCenter | Qt::AlignLeft, t.title); + p.setPen(t.accent); + p.setBrush(t.accent); + p.drawEllipse(QPoint(r.right() - 18, r.bottom() - 17), 4, 4); + } + + void paintShellCard(QPainter &p, int i) + { + const QRect r = shellCardRect(i); + const int index = (int)m_themes.size() + i; + paintCardFrame(p, r, index); + + const QString base = QFileInfo(m_shells[i]).baseName(); + QFont f = p.font(); + f.setPixelSize(13); + p.setFont(f); + p.setPen(kText); + p.drawText(QRect(r.x() + 12, r.y() + 8, r.width() - 24, 20), + Qt::AlignVCenter | Qt::AlignLeft, titleCase(base)); + p.setPen(kDimText); + p.drawText(QRect(r.x() + 12, r.y() + 30, r.width() - 24, 20), + Qt::AlignVCenter | Qt::AlignLeft, + base + QStringLiteral(".qml")); + } + + void activate(int index) + { + if (index < (int)m_themes.size()) { + const QString &name = m_themes[index].name; + const QString reply = kwlctlDispatch( + QStringLiteral("dispatch theme ") + name); + std::printf("GALLERY_APPLY theme=%s reply=%s\n", + name.toUtf8().constData(), + reply.isEmpty() ? "unreachable" + : reply.toUtf8().constData()); + std::fflush(stdout); + } else { + const QString &file = m_shells[index - (int)m_themes.size()]; + if (!stageActiveShell(file)) return; + if (m_quickshell + && m_quickshell->state() == QProcess::NotRunning) { + delete m_quickshell; + m_quickshell = nullptr; + } + if (!m_quickshell) { + m_quickshell = new QProcess; + m_quickshell->setProgram( + QStringLiteral("/usr/local/bin/quickshell")); + m_quickshell->setArguments( + { QStringLiteral("-p"), kActiveShellPath }); + m_quickshell->start(); + m_quickshell->waitForStarted(10000); + } + std::printf("GALLERY_SHELL file=%s pid=%lld\n", + file.toUtf8().constData(), + (long long)m_quickshell->processId()); + std::fflush(stdout); + } + update(); + } + + QList m_themes; + QStringList m_shells; + QProcess *m_quickshell = nullptr; + int m_hover = -1; + int m_selected = 0; + bool m_exposed = false; +}; + +int main(int argc, char **argv) +{ + QGuiApplication app(argc, argv); + std::printf("GALLERY_PLATFORM=%s\n", + app.platformName().toUtf8().constData()); + std::fflush(stdout); + GalleryWindow w; + w.show(); + return app.exec(); +} diff --git a/programs/wlcompositor/wlcompositor.c b/programs/wlcompositor/wlcompositor.c index dc3ca36b60..991cb665bb 100644 --- a/programs/wlcompositor/wlcompositor.c +++ b/programs/wlcompositor/wlcompositor.c @@ -245,6 +245,7 @@ struct surface { /* Set once wl_surface.enter has named the output to this surface. One * output means one enter, so this keeps the two senders from doubling it. */ int entered; + int gl_drawn; /* GLDRAW marker printed */ }; /* ---- wl_shm pool / buffer (custom, gbm-backed) ------------------------- */ @@ -3092,6 +3093,16 @@ static int repaint_gl(void) { glc_px(dw + 4), glc_px(dh + 4)); glc_draw_tex_rect(texs[i], surface_alpha[i], glc_px(s->x), glc_px(s->y), glc_px(dw), glc_px(dh), uv); + /* One-shot per window: the GL renderer drew this window's own + * texture. Every protocol marker (map, focus, tile, frame callback) + * still fires for a window whose pool the GPU cannot import — the + * collection loop leaves a zero texture and the window is skipped + * silently. The browser gate polls this marker by app_id. */ + if (s->xdg_toplevel && !s->gl_drawn) { + printf("GLDRAW app_id=%s\n", s->app_id); + fflush(stdout); + s->gl_drawn = 1; + } for (int j = 0; j < g.n_all_surfaces; j++) { struct surface *sub = g.all_surfaces[j]; if (sub->parent != s || !surface_visible(sub) || !sub->buffer) diff --git a/scripts/build-rootfs.sh b/scripts/build-rootfs.sh index 45fde3d9f2..4a7515d58f 100755 --- a/scripts/build-rootfs.sh +++ b/scripts/build-rootfs.sh @@ -124,7 +124,7 @@ fi PKG_MANIFEST="${ROOTFS_PACKAGE_MANIFEST:-target/rootfs-packages.MANIFEST}" ROOTFS_SAB_SIZE="${ROOTFS_SAB_SIZE:-16777216}" -ROOTFS_MAX_SIZE="${ROOTFS_MAX_SIZE:-268435456}" +ROOTFS_MAX_SIZE="${ROOTFS_MAX_SIZE:-536870912}" ROOTFS_PACKAGES="${ROOTFS_PACKAGES_CONFIG:-images/rootfs/PACKAGES.toml}" ROOTFS_BUILD_REPO_ROOT="${ROOTFS_REPO_ROOT:-$REPO_ROOT}" mkdir -p "$(dirname "$OUT")" diff --git a/scripts/ci-vitest-evidence-classes.tsv b/scripts/ci-vitest-evidence-classes.tsv index ba9016437e..3f2e6bf712 100644 --- a/scripts/ci-vitest-evidence-classes.tsv +++ b/scripts/ci-vitest-evidence-classes.tsv @@ -250,6 +250,11 @@ host/test/pthread-trap-semantics.test.ts prepared-product host/test/pthread.test.ts prepared-product host/test/pty-ownership.test.ts prepared-product host/test/putenv.test.ts prepared-product +host/test/qt-gui-smoke.test.ts source-only +host/test/qt-qml-smoke.test.ts source-only +host/test/qtgallery-smoke.test.ts prepared-product +host/test/qtwaylandscanner-host-tool.test.ts source-only +host/test/quickshell-smoke.test.ts prepared-product host/test/readdir-atomicity.test.ts source-only host/test/readiness-deadline.test.ts source-only host/test/readiness-wakeup.test.ts source-only @@ -426,6 +431,7 @@ tests/package-system/kernel-test-fixtures.test.ts prepared-product tests/package-system/local-build-wrapper.test.ts source-only tests/package-system/playwright-server-policy.test.ts prepared-product tests/package-system/program-resolver-literals.test.ts prepared-product +tests/package-system/qtbase-package.test.ts source-only tests/package-system/resolve-binary.test.ts prepared-product tests/package-system/rootfs-package-manifest.test.ts prepared-product tests/package-system/rootfs-verified-source-contract.test.ts prepared-product diff --git a/tests/package-system/qtbase-package.test.ts b/tests/package-system/qtbase-package.test.ts new file mode 100644 index 0000000000..67c57f1c7d --- /dev/null +++ b/tests/package-system/qtbase-package.test.ts @@ -0,0 +1,167 @@ +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(import.meta.dirname, "../.."); + +function source(path: string): string { + return readFileSync(join(repoRoot, path), "utf8"); +} + +describe("qtbase package contract", () => { + it("pins the upstream archive by version and digest", () => { + const manifest = source("packages/registry/qtbase/package.toml"); + const build = source("packages/registry/qtbase/build-qtbase.sh"); + + expect(manifest).toContain('version = "6.10.2"'); + expect(manifest).toContain( + 'sha256 = "aeb78d29291a2b5fd53cb55950f8f5065b4978c25fb1d77f627d695ab9adf21e"', + ); + expect(build).toContain("shasum -a 256 -c -"); + }); + + it("declares the three target adaptations the wasm32 build needs", () => { + const build = source("packages/registry/qtbase/build-qtbase.sh"); + + // qsystemdetection.h:134 errors out without a known OS macro; this + // toolchain defines __unix__ but not __linux__. + expect(build).toContain("-D__linux__=1"); + // Qt's own lever for a Linux userland with no futex syscall. + expect(build).toContain("-DQT_LINUXBASE"); + // Supplies the empty QtCore includes. + expect(build).toContain('-I$SCRIPT_DIR/src/include'); + }); + + it("patches the two upstream sites that ignore QT_LINUXBASE", () => { + const build = source("packages/registry/qtbase/build-qtbase.sh"); + const patch = source( + "packages/registry/qtbase/src/qmutex-honour-qt-linuxbase.patch", + ); + + expect(build).toContain( + 'patch -d "$SRC_DIR" -p1 < "$SCRIPT_DIR/src/qmutex-honour-qt-linuxbase.patch"', + ); + expect(patch).toContain("a/src/corelib/thread/qmutex.h"); + expect(patch).toContain("a/src/corelib/thread/qmutex.cpp"); + // Both hunks add the same guard qfutex_p.h:41 already applies. + expect( + patch.match(/^\+.*&& !defined\(QT_LINUXBASE\)/gm)?.length, + ).toBe(2); + }); + + it("keeps the resolver staging path out of the shipped library", () => { + const build = source("packages/registry/qtbase/build-qtbase.sh"); + + // Qt bakes the configure-time prefix into QtCore as qt_prfxpath. It + // must be the guest path, with the staging directory reached only by + // relocating at install time. + expect(build).toContain('GUEST_PREFIX="/usr/local/qt6"'); + expect(build).toContain('-DCMAKE_INSTALL_PREFIX="$GUEST_PREFIX"'); + expect(build).toContain( + 'cmake --install "$BUILD_DIR" --prefix "$INSTALL_DIR"', + ); + // And the recipe proves it on every build rather than trusting it. + expect(build).toContain( + 'if strings "$INSTALL_DIR/lib/libQt6Core.a" | grep -q "build-stage"; then', + ); + // The guard is only as good as the tool it runs, so preflight it with + // the rest rather than letting a missing `strings` read as a pass. + expect(build).toContain( + "for tool in wasm32posix-c++ wasm32posix-cc cmake ninja qmake strings; do", + ); + }); + + it("keeps the shim free of definitions", () => { + const shim = source("packages/registry/qtbase/src/include/linux/fs.h"); + + // qfilesystemengine_unix.cpp:68 defines FICLONE itself when the header + // does not carry it. Defining it here instead would pin the ioctl + // number in the shim, where nothing checks it against Qt's own value. + expect(shim).not.toMatch(/^#\s*define\s+FICLONE/m); + expect(shim).not.toMatch(/^#\s*define\s+FS_/m); + // Only the include guard. + expect(shim.match(/^#\s*define\s+\w+/gm)).toEqual([ + "#define KANDELO_SHIM_LINUX_FS_H", + ]); + }); + + it("rejects a host Qt whose version differs from the target", () => { + const manifest = source("packages/registry/qtbase/package.toml"); + const build = source("packages/registry/qtbase/build-qtbase.sh"); + + // The resolver understands only `>=`, so the exact match CMake needs + // is enforced by the recipe itself. + expect(manifest).toContain('name = "qmake"'); + expect(manifest).toContain('version_constraint = ">=6.10.2"'); + expect(build).toContain( + 'if [ "$HOST_QT_VERSION" != "$QTBASE_VERSION" ]; then', + ); + expect(build).toContain('QT_HOST_PATH'); + }); + + it("declares every repository-local input that reaches the cache key", () => { + const buildToml = source("packages/registry/qtbase/build.toml"); + + // script_path is not implicit: only what `inputs` names is hashed, so + // an omission here means editing the recipe does not invalidate the + // cache. Every tracked file the build reads must be listed. + for (const input of [ + "packages/registry/qtbase/build-qtbase.sh", + "packages/registry/qtbase/src/qmutex-honour-qt-linuxbase.patch", + "packages/registry/qtbase/src/include/linux/fs.h", + "scripts/package-build-roots.sh", + ]) { + expect(buildToml).toContain(`"${input}"`); + } + }); + + it("ships the static libraries the Qt stack above it links against", () => { + const manifest = source("packages/registry/qtbase/package.toml"); + + for (const lib of [ + "lib/libQt6Core.a", + "lib/libQt6Gui.a", + "lib/libQt6WaylandClient.a", + "lib/libQt6Concurrent.a", + "lib/libQt6Xml.a", + "lib/libQt6BundledPcre2.a", + ]) { + expect(manifest).toContain(`"${lib}"`); + } + // Every library QtGui and the Wayland plugin read comes from the + // registry; PCRE2 stays bundled because QRegularExpression needs the + // 16-bit build and the registry package ships only libpcre2-8.a. + for (const dep of [ + "libcxx@21.1.7", + "zlib@1.3.1", + "freetype@2.13.3", + "fontconfig@2.15.0", + "harfbuzz@10.1.0", + "libpng@1.6.43", + "libxkbcommon@1.7.0", + "libwayland@1.24.0", + ]) { + expect(manifest).toContain(`"${dep}"`); + } + const build = source("packages/registry/qtbase/build-qtbase.sh"); + expect(build).toContain("-DFEATURE_system_zlib=ON"); + expect(build).toContain("-DFEATURE_system_pcre2=OFF"); + }); + + it("builds the Wayland platform plugin qtbase 6.10 absorbed", () => { + const build = source("packages/registry/qtbase/build-qtbase.sh"); + + // src/plugins/platforms/wayland/CMakeLists.txt:9 returns early unless + // both features hold, and the second needs the host generator + // flake.nix supplies. + expect(build).toContain("-DFEATURE_gui=ON"); + expect(build).toContain("-DFEATURE_qtwaylandscanner=ON"); + // The target has no X server and no framebuffer, so wayland is the + // only truthful default. + expect(build).toContain("-DQT_QPA_DEFAULT_PLATFORM=wayland"); + // OpenGL is refused through Qt's own input, not FEATURE_opengl: + // gui/configure.cmake:1753 raises an error unless INPUT_opengl reads + // exactly 'no'. + expect(build).toContain("-DINPUT_opengl=no"); + }); +}); diff --git a/tests/package-system/source-rootfs-shell-bridge.test.ts b/tests/package-system/source-rootfs-shell-bridge.test.ts index 06e0dc7b3c..f12619e851 100644 --- a/tests/package-system/source-rootfs-shell-bridge.test.ts +++ b/tests/package-system/source-rootfs-shell-bridge.test.ts @@ -27,6 +27,7 @@ import { } from "../../images/vfs/scripts/build-source-rootfs-shell-image"; import { SHELL_LAZY_BINARY_SPECS } from "../../images/vfs/lib/init/shell-binaries"; import { + NCURSES_TERMINFO_RUNTIME_FILE, SHELL_LAZY_ARCHIVE_SPECS, type ShellLazyArchiveResolver, } from "../../images/vfs/scripts/shell-lazy-archives"; @@ -256,6 +257,17 @@ function fixturePaths(root: string) { }), ); } + writeFileSync( + join( + dependencyRoots.get(NCURSES_TERMINFO_RUNTIME_FILE.dependency)!, + NCURSES_TERMINFO_RUNTIME_FILE.resolverPath.split("/").at(-1)!, + ), + zipSync({ + [NCURSES_TERMINFO_RUNTIME_FILE.requiredEntry]: new TextEncoder().encode( + "terminfo fixture", + ), + }), + ); const resolveArtifact: ShellLazyArchiveResolver = ( resolverPath, requestedDependency, @@ -348,7 +360,7 @@ describe("canonical source-rootfs shell", () => { 'name = "node"', ]); expect(buildToml).toMatch(/^commit\s*=\s*"UNPUBLISHED"$/m); - expect(buildToml).toMatch(/^revision\s*=\s*30$/m); + expect(buildToml).toMatch(/^revision\s*=\s*32$/m); expect(buildToml).not.toContain("[[git_inputs]]"); for (const input of [ "packages/registry/shell/source-rootfs-shell-demo.json", diff --git a/tools/xtask/src/build_deps.rs b/tools/xtask/src/build_deps.rs index affec12d18..4475928e95 100644 --- a/tools/xtask/src/build_deps.rs +++ b/tools/xtask/src/build_deps.rs @@ -8525,6 +8525,15 @@ pub struct ResolveOpts<'a> { /// disables symlink placement (test fixtures, library-only /// resolves, etc.). pub binaries_dir: Option<&'a Path>, + /// Hermetic SourceOnlyV1 output tree (the local-build engine's + /// `--output-root`). When `Some`, source-build children receive it as + /// `WASM_POSIX_SOURCE_ONLY_BINARY_ROOT`, so a recipe that boots the + /// kernel host (`host/src/binary-resolver.ts` demands that variable + /// whenever the policy env is set) works under the env-scrubbed + /// `dev-shell.sh` entry. `None` leaves the child's ambient value + /// untouched (standalone `build-deps resolve` callers export it + /// themselves). + pub source_only_binary_root: Option<&'a Path>, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -9097,6 +9106,7 @@ where force_source_build: forced.as_ref(), repo_root: Some(repo_root), binaries_dir: None, + source_only_binary_root: Some(output_root), }; validate_resolve_cache_pair(&opts)?; let canonical_repo = exact_canonical_real_directory(repo_root, "local-build repository root")?; @@ -10251,6 +10261,7 @@ fn ensure_built_uncached( &cache_key_sha_hex, opts.cache_root, opts.source_cache_root, + opts.source_only_binary_root, &canonical, &dep_dirs, &pkgconfig_path, @@ -10282,6 +10293,7 @@ fn ensure_built_uncached( &cache_key_sha_hex, opts.cache_root, opts.source_cache_root, + opts.source_only_binary_root, &canonical, &dep_dirs, &pkgconfig_path, @@ -13111,6 +13123,7 @@ fn build_into_cache( cache_key_sha: &str, cache_root: &Path, source_cache_root: Option<&Path>, + source_only_binary_root: Option<&Path>, canonical: &Path, dep_dirs: &BTreeMap, pkgconfig_path: &str, @@ -13322,6 +13335,9 @@ fn build_into_cache( })?; cmd.env(SOURCE_ONLY_POLICY_ENV, SOURCE_ONLY_POLICY_VALUE); cmd.env("WASM_POSIX_SOURCE_ONLY_CACHE_ROOT", base); + if let Some(binary_root) = source_only_binary_root { + cmd.env("WASM_POSIX_SOURCE_ONLY_BINARY_ROOT", binary_root); + } let registry_root = registry .roots .iter() @@ -16251,7 +16267,7 @@ pub fn run(args: Vec) -> Result<(), String> { if extra.is_some() { return Err("build-deps path: unexpected extra arg".into()); } - cmd_path(&manifest, ®istry, arch) + cmd_path(&manifest, ®istry, arch, resolve_policy) } "resolve" => { if extra.is_some() { @@ -16417,18 +16433,28 @@ fn cmd_sha(m: &DepsManifest, registry: &Registry, arch: TargetArch) -> Result<() Ok(()) } -fn cmd_path(m: &DepsManifest, registry: &Registry, arch: TargetArch) -> Result<(), String> { +fn cmd_path( + m: &DepsManifest, + registry: &Registry, + arch: TargetArch, + policy: ResolvePolicy, +) -> Result<(), String> { let mut memo = BTreeMap::new(); let mut chain = Vec::new(); - let sha = compute_sha( + let sha = compute_sha_for_policy( m, registry, arch, current_abi_version(), + policy, &mut memo, &mut chain, )?; - let path = canonical_path(&default_cache_root(), m, arch, &sha); + let cache_root = match policy { + ResolvePolicy::SourceOnlyV1 => source_only_cache_roots()?.compiled, + ResolvePolicy::Default => default_cache_root(), + }; + let path = canonical_path(&cache_root, m, arch, &sha); println!("{}", path.display()); Ok(()) } @@ -16598,6 +16624,7 @@ fn cmd_resolve( // package binaries via `tryResolveBinary` need the dep // symlinks too. binaries_dir, + source_only_binary_root: None, }; let path = ensure_built(m, registry, arch, current_abi_version(), &opts)?; @@ -23738,6 +23765,7 @@ spdx = "TestLicense" force_source_build: None, repo_root: None, binaries_dir: None, + source_only_binary_root: None, } } @@ -23758,6 +23786,7 @@ spdx = "TestLicense" force_source_build: None, repo_root: Some(repo_root), binaries_dir: None, + source_only_binary_root: None, } } @@ -23777,6 +23806,7 @@ spdx = "TestLicense" force_source_build: None, repo_root: None, binaries_dir: None, + source_only_binary_root: None, } } @@ -29012,6 +29042,7 @@ spdx = "BSD-3-Clause" force_source_build: None, repo_root: None, binaries_dir: None, + source_only_binary_root: None, }; let path = ensure_built(&m, ®istry, TEST_ARCH, TEST_ABI, &opts).unwrap(); assert!( @@ -29556,6 +29587,7 @@ libs = ["lib/libF1.a"] force_source_build: Some(&force), repo_root: None, binaries_dir: None, + source_only_binary_root: None, }; let p3 = ensure_built(&m, ®, TEST_ARCH, TEST_ABI, &opts).unwrap(); assert_eq!(p1, p3, "force-rebuild must land at the same canonical path"); @@ -29642,6 +29674,7 @@ libs = ["lib/libF3b.a"] force_source_build: Some(&force), repo_root: None, binaries_dir: None, + source_only_binary_root: None, }; ensure_built(&ma, ®, TEST_ARCH, TEST_ABI, &opts).unwrap(); ensure_built(&mb, ®, TEST_ARCH, TEST_ABI, &opts).unwrap(); @@ -31472,6 +31505,7 @@ revision = 1 force_source_build: None, repo_root: None, binaries_dir: Some(&binaries), + source_only_binary_root: None, }; ensure_built( &target, @@ -31983,6 +32017,7 @@ printf NESTED > "$WASM_POSIX_DEP_OUT_DIR/lib/out.a" force_source_build: None, repo_root: None, binaries_dir: None, + source_only_binary_root: None, }; let resolved = ensure_built( &manifest, @@ -31995,6 +32030,52 @@ printf NESTED > "$WASM_POSIX_DEP_OUT_DIR/lib/out.a" assert_eq!(std::fs::read(resolved.join("lib/out.a")).unwrap(), b"NESTED"); } + #[test] + fn source_only_recipe_receives_engine_binary_root() { + let root = tempdir("source-only-binary-root-registry"); + let cache_base = tempdir("source-only-binary-root-cache"); + let cache = cache_base.join("source-only-v1/compiled"); + std::fs::create_dir_all(&cache).unwrap(); + let binary_root = tempdir("source-only-binary-root-output"); + write_lib( + &root, + "libRooted", + "1.0.0", + &[], + &format!( + r#" +test "${{WASM_POSIX_SOURCE_ONLY_BINARY_ROOT:-}}" = "{}" +mkdir -p "$WASM_POSIX_DEP_OUT_DIR/lib" +printf ROOTED > "$WASM_POSIX_DEP_OUT_DIR/lib/out.a" +"#, + binary_root.display() + ), + "[outputs]\nlibs = [\"lib/out.a\"]\n", + ); + write_source_only_repository_inputs(&root, "libRooted"); + let registry = Registry { roots: vec![root] }; + let manifest = registry.load("libRooted").unwrap(); + let opts = ResolveOpts { + policy: ResolvePolicy::SourceOnlyV1, + source_cache_root: Some(&cache_base), + cache_root: &cache, + local_libs: None, + force_source_build: None, + repo_root: None, + binaries_dir: None, + source_only_binary_root: Some(&binary_root), + }; + let resolved = ensure_built( + &manifest, + ®istry, + TEST_ARCH, + current_abi_version(), + &opts, + ) + .unwrap(); + assert_eq!(std::fs::read(resolved.join("lib/out.a")).unwrap(), b"ROOTED"); + } + #[test] fn source_archive_handoff_rejects_every_noncanonical_cache_pair_before_resolution() { let (base, compiled) = source_only_test_roots("source-archive-handoff-pair"); @@ -33385,6 +33466,7 @@ printf '%s\n' "{consumer}" > "$WASM_POSIX_DEP_OUT_DIR/lib/out.a" &"1".repeat(64), &cache, None, + None, &canonical, &deps, "", @@ -33427,6 +33509,7 @@ printf '%s\n' "{consumer}" > "$WASM_POSIX_DEP_OUT_DIR/lib/out.a" &"1".repeat(64), &cache, None, + None, &canonical, &reserved, "", @@ -33552,6 +33635,7 @@ printf '%s\n' "{consumer}" > "$WASM_POSIX_DEP_OUT_DIR/lib/out.a" force_source_build: Some(&forced), repo_root: Some(&root), binaries_dir: None, + source_only_binary_root: None, }; let canonical = ensure_built(&manifest, ®istry, TEST_ARCH, TEST_ABI, &opts).unwrap(); @@ -33660,6 +33744,7 @@ printf '%s\n' "{consumer}" > "$WASM_POSIX_DEP_OUT_DIR/lib/out.a" force_source_build: None, repo_root: Some(&root), binaries_dir: None, + source_only_binary_root: None, }; let cached = ensure_built( &manifest, @@ -33931,6 +34016,7 @@ printf canonical-runtime > "$WASM_POSIX_DEP_OUT_DIR/icu.dat""#, force_source_build: None, repo_root: Some(repo), binaries_dir, + source_only_binary_root: None, }; let path = ensure_built(m, registry, arch, TEST_ABI, &opts)?; if let Some(bdir) = binaries_dir {