diff --git a/pyproject.toml b/pyproject.toml index e55a2f2..0dfa291 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ members = [ "ncurses", "qt5", "raylib", + "xvfb", "zeromq", "zstd", ] diff --git a/xvfb/build.sh b/xvfb/build.sh new file mode 100755 index 0000000..8913736 --- /dev/null +++ b/xvfb/build.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +set -e + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)" +cd "$DIR" + +INSTALL_DIR="$DIR/xvfb/install" + +# macOS: Xvfb is Linux-only. Ship an empty install dir so the wheel still +# builds; smoketest() is a no-op on Darwin. +if [[ "$OSTYPE" == "darwin"* ]]; then + rm -rf "$INSTALL_DIR" + mkdir -p "$INSTALL_DIR"/{bin,lib,share/X11/xkb} + echo "xvfb: macOS not supported, shipping empty install dir" + exit 0 +fi + +# Linux: bundle the Xvfb binary, xkbcomp, xkb keymap data, and the closure +# of shared libraries it needs (minus libc/libpthread/etc which the host +# always provides). For CI/manylinux we pull from AlmaLinux 8 so the wheel +# works on any glibc >= 2.28 distro; on a Debian/Ubuntu dev host we accept +# whatever the system has (the local wheel just won't be as portable). +if command -v dnf >/dev/null 2>&1; then + dnf install -y -q xorg-x11-server-Xvfb xorg-x11-xkb-utils xkeyboard-config >/dev/null +elif command -v apt-get >/dev/null 2>&1; then + if [[ "$(id -u)" -eq 0 ]]; then SUDO="" + elif command -v sudo >/dev/null 2>&1; then SUDO=sudo + else echo "xvfb: need sudo or root to apt-get install" >&2; exit 1; fi + export DEBIAN_FRONTEND=noninteractive + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq --no-install-recommends \ + xvfb x11-xkb-utils xkb-data patchelf +else + echo "xvfb: need dnf or apt-get to fetch upstream Xvfb" >&2 + exit 1 +fi + +if ! command -v patchelf >/dev/null 2>&1; then + echo "xvfb: patchelf is required but not found" >&2 + exit 1 +fi + +rm -rf "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR"/{bin,lib,share/X11/xkb} + +# Xvfb may live in /usr/bin (RPM/Debian) — just locate it. +XVFB_SRC="$(command -v Xvfb || true)" +XKBCOMP_SRC="$(command -v xkbcomp || true)" +if [[ -z "$XVFB_SRC" || -z "$XKBCOMP_SRC" ]]; then + echo "xvfb: Xvfb or xkbcomp not found after install" >&2 + exit 1 +fi + +cp "$XVFB_SRC" "$INSTALL_DIR/bin/Xvfb" +cp "$XKBCOMP_SRC" "$INSTALL_DIR/bin/xkbcomp" +chmod u+w "$INSTALL_DIR/bin/Xvfb" "$INSTALL_DIR/bin/xkbcomp" +cp -a /usr/share/X11/xkb/. "$INSTALL_DIR/share/X11/xkb/" + +# Two binary patches to make Xvfb relocatable: +# +# 1. "/usr/bin" -> "" (null bytes) +# Xvfb's compile-time XkbBinDirectory points at /usr/bin and is used to +# build an absolute path to xkbcomp. Blanking it makes the spawned +# command just "xkbcomp", which popen() then resolves via PATH. Our +# Python wrapper prepends the bundled bin/ to PATH. +# +# 2. "-R%s" -> "-I%s" (in the xkbcomp argv format string) +# Xvfb passes its XkbBaseDirectory to xkbcomp via -R, expecting xkbcomp +# to chdir there *and* add "." to the include path. xkbcomp 1.4.x only +# chdirs — the include-path side was added later. Switching to -I makes +# 1.4.2 actually search the path we hand it via -xkbdir. +python3 - "$INSTALL_DIR/bin/Xvfb" <<'PY' +import sys +path = sys.argv[1] +with open(path, "r+b") as f: + data = bytearray(f.read()) + +def replace_unique(needle: bytes, replacement: bytes): + assert len(needle) == len(replacement) + idx = data.find(needle) + if idx < 0: + sys.exit(f"could not find {needle!r} in Xvfb binary") + if data.find(needle, idx + 1) >= 0: + sys.exit(f"multiple {needle!r} matches; refusing to patch") + data[idx:idx + len(needle)] = replacement + +replace_unique(b"/usr/bin\x00", b"\x00" * 9) +replace_unique(b'"-R%s"\x00', b'"-I%s"\x00') + +with open(path, "wb") as f: + f.write(data) +PY + +# bundle the shared library closure. Recursively walk ldd output to catch +# libs-of-libs (e.g. libXfont2 -> libfontenc -> libbz2). Skip core glibc +# pieces; everything else gets copied alongside the binary. +declare -A SEEN +collect_libs() { + local target="$1" + while IFS= read -r line; do + local lib + lib=$(echo "$line" | awk '{print $3}') + [[ -z "$lib" || "$lib" == "not" ]] && continue + [[ ! -e "$lib" ]] && continue + local base + base=$(basename "$lib") + case "$base" in + libc.so.*|libpthread.so.*|libm.so.*|librt.so.*|libdl.so.*|libgcc_s.so.*|libresolv.so.*|libutil.so.*|ld-linux-*.so.*|linux-vdso.so.*|linux-gate.so.*) + continue ;; + esac + [[ -n "${SEEN[$base]:-}" ]] && continue + SEEN[$base]=1 + cp -L "$lib" "$INSTALL_DIR/lib/$base" + chmod u+w "$INSTALL_DIR/lib/$base" + collect_libs "$INSTALL_DIR/lib/$base" + done < <(ldd "$target" 2>/dev/null || true) +} +collect_libs "$INSTALL_DIR/bin/Xvfb" +collect_libs "$INSTALL_DIR/bin/xkbcomp" + +# point the binaries (and the bundled libs) at our private lib dir so they +# don't accidentally resolve against an incompatible host copy. +patchelf --set-rpath '$ORIGIN/../lib' "$INSTALL_DIR/bin/Xvfb" +patchelf --set-rpath '$ORIGIN/../lib' "$INSTALL_DIR/bin/xkbcomp" +for so in "$INSTALL_DIR"/lib/*.so*; do + patchelf --set-rpath '$ORIGIN' "$so" 2>/dev/null || true +done + +strip --strip-unneeded "$INSTALL_DIR/bin/Xvfb" "$INSTALL_DIR/bin/xkbcomp" 2>/dev/null || true +find "$INSTALL_DIR/lib" -name '*.so*' -exec strip --strip-unneeded {} + 2>/dev/null || true + +echo "Installed xvfb to $INSTALL_DIR" +du -sh "$INSTALL_DIR" diff --git a/xvfb/pyproject.toml b/xvfb/pyproject.toml new file mode 100644 index 0000000..e055664 --- /dev/null +++ b/xvfb/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "xvfb" +version = "1.20.11" +description = "Xvfb (X virtual framebuffer) headless X server" +requires-python = ">=3.8" + +[project.scripts] +Xvfb = "xvfb:_run_xvfb" + +[tool.setuptools.packages.find] +include = ["xvfb*"] + +[tool.setuptools.package-data] +xvfb = ["install/**/*"] diff --git a/xvfb/setup.py b/xvfb/setup.py new file mode 100644 index 0000000..b68017b --- /dev/null +++ b/xvfb/setup.py @@ -0,0 +1,58 @@ +import os +import platform +import subprocess + +from setuptools.command.build_py import build_py + +try: + from wheel.bdist_wheel import bdist_wheel +except ImportError: + bdist_wheel = None + + +class BuildXvfb(build_py): + """Run build.sh to fetch and bundle Xvfb before collecting package data.""" + + def run(self): + pkg_dir = os.path.dirname(os.path.abspath(__file__)) + build_script = os.path.join(pkg_dir, "build.sh") + subprocess.check_call(["bash", build_script], cwd=pkg_dir) + + super().run() + + +cmdclass = {"build_py": BuildXvfb} + +if bdist_wheel is not None: + + class PlatformWheel(bdist_wheel): + """Produce a platform-specific, Python-version-agnostic wheel.""" + + def finalize_options(self): + super().finalize_options() + self.root_is_pure = False + + def get_tag(self): + system = platform.system() + machine = platform.machine() + + if system == "Linux": + plat = f"linux_{machine}" + elif system == "Darwin": + plat = "macosx_11_0_arm64" + else: + plat = f"{system.lower()}_{machine}" + + return "py3", "none", plat + + cmdclass["bdist_wheel"] = PlatformWheel + + +def setup(): + from setuptools import setup as _setup + + _setup(cmdclass=cmdclass) + + +if __name__ == "__main__": + setup() diff --git a/xvfb/xvfb/__init__.py b/xvfb/xvfb/__init__.py new file mode 100644 index 0000000..6609bc4 --- /dev/null +++ b/xvfb/xvfb/__init__.py @@ -0,0 +1,39 @@ +import os +import sys + +DIR = os.path.join(os.path.dirname(__file__), "install") +BIN_DIR = os.path.join(DIR, "bin") +LIB_DIR = os.path.join(DIR, "lib") +XKB_DIR = os.path.join(DIR, "share", "X11", "xkb") + +XVFB_BIN = os.path.join(BIN_DIR, "Xvfb") +XKBCOMP_BIN = os.path.join(BIN_DIR, "xkbcomp") + + +def _run_xvfb(): + # The bundled Xvfb has its compile-time XkbBinDirectory blanked out so it + # invokes xkbcomp via PATH lookup; prepend our bin dir so the bundled + # xkbcomp wins. -xkbdir points the server at the bundled keymap data. + env = os.environ.copy() + env["PATH"] = BIN_DIR + os.pathsep + env.get("PATH", "") + args = sys.argv[1:] + if not any(a == "-xkbdir" for a in args): + args = ["-xkbdir", XKB_DIR] + args + os.execvpe(XVFB_BIN, [XVFB_BIN] + args, env) + + +def smoketest(): + if sys.platform == "darwin": + return + assert os.path.isfile(XVFB_BIN), f"Xvfb not found at {XVFB_BIN}" + assert os.path.isfile(XKBCOMP_BIN), f"xkbcomp not found at {XKBCOMP_BIN}" + assert os.path.isdir(XKB_DIR), f"xkb data not found at {XKB_DIR}" + + import subprocess + # Xvfb prints usage to stderr and exits non-zero on `-help`; the banner + # mentions Xvfb-specific flags like -screen and -fbdir. If those appear, + # the binary loaded its bundled libs and ran far enough to print help. + result = subprocess.run([XVFB_BIN, "-help"], capture_output=True, text=True) + output = result.stderr + result.stdout + assert "-screen scrn WxHxD" in output, \ + f"Xvfb -help did not produce expected output: {output}"