diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f30e319..76a7a52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,14 @@ jobs: - run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config - run: cargo test --locked --target ${{ matrix.target }} --no-fail-fast + installer-shell: + name: install.sh regressions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: bash -n install.sh scripts/install_sh_test.sh + - run: scripts/install_sh_test.sh + rustdoc-and-package: name: rustdoc and cargo package runs-on: ubuntu-latest @@ -159,6 +167,7 @@ jobs: - check - clippy - test + - installer-shell - rustdoc-and-package - supply-chain - mcp-safety diff --git a/CHANGELOG.md b/CHANGELOG.md index 6950124..68bb526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- `install.sh` now recognizes Artix as pacman-based, chooses an explicitly + requested or unambiguous package manager for unknown distros, treats + ydotool as an optional fallback, installs the required xdotool keyboard + backend on X11, distinguishes pointer-only direct uinput from keyboard-ready + input in `doctor`, reports Wayland RemoteDesktop pointer availability + separately from its required keyboard contract, and skips automatic ydotoold + setup when a systemd user manager is unavailable. + ## [0.4.6] - 2026-08-05 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a78643f..4aab31c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,7 @@ cargo fmt --all -- --check cargo check --locked --all-targets cargo clippy --locked --all-targets -- -D warnings cargo test --locked --no-fail-fast +scripts/install_sh_test.sh scripts/mcp_safety_check.py agnix . ``` diff --git a/README.md b/README.md index b2240f2..7b24396 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ COSMIC users do not need a second package or a separate helper install when usin ### Option A — `./install.sh` from a clone -Installs system packages on Debian/Ubuntu, Fedora/RHEL-like, or Arch-like distros; installs Rust if needed; builds both release binaries; installs them to `~/.local/bin`; enables `ydotoold` as a user service; enables GNOME AT-SPI settings when running under GNOME; and installs the bundled GNOME Shell extension on GNOME Wayland. +Installs system packages on Debian/Ubuntu, Fedora/RHEL-like, Arch-like, or Artix systems; installs Rust if needed; builds both release binaries; installs them to `~/.local/bin`; configures `ydotoold` as a systemd user service when available; enables GNOME AT-SPI settings when running under GNOME; and installs the bundled GNOME Shell extension on GNOME Wayland. ```bash git clone https://github.com/agent-sh/computer-use-linux @@ -130,6 +130,8 @@ cd computer-use-linux computer-use-linux doctor | jq .readiness ``` +`ydotool` is an optional fallback. On X11, the installer includes `xdotool` as the required keyboard backend. If ydotool is unavailable from the configured repositories, the installer continues, but `doctor` still requires a keyboard-capable RemoteDesktop portal on Wayland or xdotool on X11; direct uinput provides absolute pointer input only. On non-systemd hosts, automatic `ydotoold` service setup is skipped and the installer prints a command suitable for a per-user supervisor. For an unrecognized distro, pass `--package-manager apt|dnf|pacman`; `--force-unknown-distro` auto-selects only when exactly one of those managers is available. + ### Option B — `cargo install` (Rust binaries, no system setup) Installs the Rust binaries from crates.io. You still handle the system-level pieces yourself: AT-SPI, desktop portals, the optional `ydotoold` fallback, and the GNOME extension if you need the GNOME Wayland exact-focus backend. @@ -370,7 +372,7 @@ files. Computer-use tooling is, by definition, a privilege-escalation surface. The threat model: -- **`ydotoold` runs as a per-user systemd service** with read/write access to `/dev/uinput`. Any process that can connect to its socket (`/run/user/$UID/.ydotool_socket`, mode `0600` by default) can synthesize arbitrary input — keypresses, clicks, anything. Keep the socket in the user runtime dir (the default), not in `/tmp` or any world-readable location. Do not run `ydotoold` as a system service. +- **`ydotoold` runs as a per-user service** with read/write access to `/dev/uinput`. `install.sh` automates this for systemd user sessions and prints manual supervisor guidance elsewhere. Any process that can connect to its socket (`/run/user/$UID/.ydotool_socket`, mode `0600` by default) can synthesize arbitrary input — keypresses, clicks, anything. Keep the socket in the user runtime dir (the default), not in `/tmp` or any world-readable location. Do not run `ydotoold` as root or as a system service. - **The screencast portal asks for permission once per session.** Granting it lets the calling MCP host capture the screen for the rest of the session. If you don't want that, decline the portal dialog and use `get_app_state` with `include_screenshot: false`. - **AT-SPI exposes window contents to any client on your session bus.** Enabling the AT-SPI bridge (`setup_accessibility`) is a prerequisite for this binary; it's also what screen readers use, and it shares the same trust boundary. - **The GNOME Shell extension** is loaded only into your user's GNOME Shell, runs in the Shell's JS sandbox, and exposes a single DBus interface on the user session bus. It does not request any extra permissions. @@ -385,9 +387,9 @@ If you're running this on a shared workstation, set `ydotoold`'s socket permissi - **`accessibility.at_spi_bus.ok = false`** — AT-SPI registry isn't running or the toolkit bridge is off. Fix: `computer-use-linux setup` (or call the `setup_accessibility` MCP tool). Restart the apps you want to drive. - **`windowing.gnome_shell_introspect.ok = false` and `gnome_shell_extension_dbus.ok = false`** — GNOME blocks introspection and the extension isn't installed. Fix: `computer-use-linux setup-window-targeting`, then log out and log back in. -- **`input.ydotool_socket.ok = false` while ydotool is the selected fallback** — daemon isn't running. Fix: `systemctl --user enable --now ydotoold`. If the unit doesn't exist, install the `ydotool` package and rerun `./install.sh` (or copy the unit from `systemd/ydotoold.service` in this repo). +- **`input.ydotool_socket.ok = false` while ydotool is the selected fallback** — daemon isn't running. On systemd, run `systemctl --user enable --now ydotoold`. On other init systems, rerun `./install.sh` and configure your per-user supervisor with the command it prints. If `ydotool` is not packaged for your distro, use another input backend or install a compatible ydotool release manually. - **`input.ydotool.ok = false` with an unsupported CLI message** — install ydotool 1.0.3 or newer. A running daemon or socket alone is not enough; `doctor` verifies the raw key, wheel, stdin typing, and absolute-movement command family before advertising the backend. -- **`input.uinput.ok = false`** — `/dev/uinput` isn't accessible to your user. Fix: add yourself to the `input` group (`sudo usermod -aG input $USER`) and re-login. On distros that ship `uinput` as a kernel module without auto-loading it, add `uinput` to `/etc/modules-load.d/`. +- **`input.uinput.ok = false`** — `/dev/uinput` isn't accessible to your user. Fix: add yourself to the `input` group (`sudo usermod -aG input $USER`) and re-login. On distros that ship `uinput` as a kernel module without auto-loading it, add `uinput` to `/etc/modules-load.d/`. Direct uinput supplies absolute pointer input only, so `doctor` also requires a keyboard-capable portal, xdotool, or ydotool backend. - **Portal calls hang or time out** — `xdg-desktop-portal` or its backend (`-gnome`, `-gtk`, `-kde`, `-wlr`) crashed. Fix: check `journalctl --user -u xdg-desktop-portal -u xdg-desktop-portal-gnome --since '5 min ago'` and restart the relevant unit. - **KWin / Hyprland / i3 / COSMIC / X11 windowing is unavailable** — check `doctor.windowing.backends`. KWin needs session-bus scripting; Hyprland needs `hyprctl`; i3 needs `i3-msg` and its IPC socket; generic X11 needs `wmctrl` and `xprop`. COSMIC needs `computer-use-linux-cosmic`, which the standard installers provide automatically; if you copied binaries by hand, copy the helper too or set `COMPUTER_USE_LINUX_COSMIC_HELPER`. - **Screenshots return black frames on multi-monitor setups** — known portal / compositor edge case. Use `get_app_state` with `include_screenshot: false` and rely on AT-SPI until the portal backend is healthy. diff --git a/install.sh b/install.sh index 2bbf634..ce22121 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,8 @@ IFS=$'\n\t' # ----------------------------------------------------------------------------- SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +OS_RELEASE_FILE="${COMPUTER_USE_LINUX_OS_RELEASE_FILE:-/etc/os-release}" +UINPUT_DEVICE="${COMPUTER_USE_LINUX_UINPUT_DEVICE:-/dev/uinput}" BIN_NAME="computer-use-linux" COSMIC_HELPER_NAME="computer-use-linux-cosmic" INSTALL_DIR="${HOME}/.local/bin" @@ -64,6 +66,7 @@ SKIP_YDOTOOL=0 SKIP_GNOME_EXT=0 SKIP_DOCTOR=0 FORCE_UNKNOWN_DISTRO=0 +PACKAGE_MANAGER_OVERRIDE="" usage() { cat </dev/null 2>&1 ;; + dnf) command -v dnf >/dev/null 2>&1 ;; + pacman) command -v pacman >/dev/null 2>&1 ;; + *) return 1 ;; + esac +} + +available_package_managers() { + local manager + for manager in apt dnf pacman; do + if package_manager_available "${manager}"; then + printf '%s\n' "${manager}" + fi + done +} detect_distro() { log_section "Step 1/9 — detect environment" @@ -124,45 +168,94 @@ detect_distro() { die "this script only supports Linux (got $(uname -s)). macOS/*BSD are not supported." fi - if [[ ! -r /etc/os-release ]]; then - die "/etc/os-release missing — cannot detect distro." + if [[ ! -r "${OS_RELEASE_FILE}" ]]; then + die "${OS_RELEASE_FILE} missing — cannot detect distro." fi + local ID="" ID_LIKE="" PRETTY_NAME="" # shellcheck disable=SC1091 - . /etc/os-release + . "${OS_RELEASE_FILE}" local id_like="${ID_LIKE:-} ${ID:-}" - case " ${id_like} " in - *" debian "*|*" ubuntu "*) - DISTRO_FAMILY="debian"; PKG_MANAGER="apt" ;; - *" fedora "*|*" rhel "*|*" centos "*) - DISTRO_FAMILY="fedora"; PKG_MANAGER="dnf" ;; - *" arch "*|*" archlinux "*|*" manjaro "*|*" endeavouros "*) - DISTRO_FAMILY="arch"; PKG_MANAGER="pacman" ;; - *) - if [[ ${FORCE_UNKNOWN_DISTRO} -eq 1 ]]; then - log_warn "unknown distro '${ID:-?}' — forcing debian/apt path" - DISTRO_FAMILY="debian"; PKG_MANAGER="apt" - else - log_fail "unsupported distro: ${ID:-unknown} (${PRETTY_NAME:-?})" - log_info "supported families: debian/ubuntu, fedora, arch" - log_info "re-run with --force-unknown-distro to attempt apt-based install" - exit 1 - fi ;; - esac - log_ok "distro family: ${DISTRO_FAMILY} (pkg manager: ${PKG_MANAGER})" + if [[ -n "${PACKAGE_MANAGER_OVERRIDE}" ]]; then + set_package_manager "${PACKAGE_MANAGER_OVERRIDE}" + log_warn "using requested package manager: ${PACKAGE_MANAGER_OVERRIDE}" + else + case " ${id_like} " in + *" debian "*|*" ubuntu "*) + set_package_manager apt ;; + *" fedora "*|*" rhel "*|*" centos "*) + set_package_manager dnf ;; + *" arch "*|*" archlinux "*|*" manjaro "*|*" endeavouros "*|*" artix "*|*" artixlinux "*) + set_package_manager pacman ;; + *) + if [[ ${FORCE_UNKNOWN_DISTRO} -eq 1 ]]; then + if [[ ${SKIP_SYSTEM_DEPS} -eq 1 ]]; then + DISTRO_FAMILY="unknown" + log_warn "unknown distro '${ID:-?}' — system package installation is skipped" + else + local available=() + mapfile -t available < <(available_package_managers) + if [[ ${#available[@]} -ne 1 ]]; then + log_fail "cannot choose a package manager for '${ID:-unknown}'" + log_info "found: ${available[*]:-none}; pass --package-manager apt|dnf|pacman" + return 1 + fi + set_package_manager "${available[0]}" + log_warn "unknown distro '${ID:-?}' — using detected ${PKG_MANAGER}" + fi + else + log_fail "unsupported distro: ${ID:-unknown} (${PRETTY_NAME:-?})" + log_info "supported families: debian/ubuntu, fedora, arch/artix" + log_info "re-run with --force-unknown-distro or --package-manager apt|dnf|pacman" + return 1 + fi ;; + esac + fi + + if [[ ${SKIP_SYSTEM_DEPS} -eq 0 ]] && ! package_manager_available "${PKG_MANAGER}"; then + log_fail "${PKG_MANAGER} was selected but its command is not on PATH" + return 1 + fi + if [[ -n "${PKG_MANAGER}" ]]; then + log_ok "distro family: ${DISTRO_FAMILY} (pkg manager: ${PKG_MANAGER})" + else + log_ok "distro family: ${DISTRO_FAMILY} (system packages skipped)" + fi # Display server. local session_type="" if [[ -n "${XDG_SESSION_ID:-}" ]] && command -v loginctl >/dev/null 2>&1; then session_type="$(loginctl show-session "${XDG_SESSION_ID}" -p Type --value 2>/dev/null || true)" fi - session_type="${session_type:-${XDG_SESSION_TYPE:-unknown}}" + session_type="${session_type:-${XDG_SESSION_TYPE:-}}" + local normalized_session_type="${session_type//[[:space:]]/}" + local wayland_display="${WAYLAND_DISPLAY:-}" + local display="${DISPLAY:-}" + local wayland_session=0 + if [[ -n "${normalized_session_type}" ]]; then + if [[ "${normalized_session_type,,}" == "wayland" ]]; then wayland_session=1; fi + elif [[ -n "${wayland_display//[[:space:]]/}" ]]; then + wayland_session=1 + fi + + X11_KEYBOARD_BACKEND_REQUIRED=0 + if [[ ${wayland_session} -eq 0 && -n "${display//[[:space:]]/}" ]]; then + X11_KEYBOARD_BACKEND_REQUIRED=1 + fi + + if [[ ${wayland_session} -eq 1 ]]; then + SESSION_TYPE="wayland" + elif [[ ${X11_KEYBOARD_BACKEND_REQUIRED} -eq 1 ]]; then + SESSION_TYPE="x11" + else + SESSION_TYPE="${normalized_session_type:-unknown}" + fi - case "${session_type}" in + case "${SESSION_TYPE}" in wayland) log_ok "display server: Wayland" ;; x11) log_warn "display server: X11 — supported but degraded (some features need Wayland)" ;; - *) log_warn "display server: ${session_type} (unrecognised — proceeding anyway)" ;; + *) log_warn "display server: ${SESSION_TYPE} (unrecognised — proceeding anyway)" ;; esac local desktop="${XDG_CURRENT_DESKTOP:-unknown}" @@ -179,6 +272,37 @@ detect_distro() { # Step 2: system package install # ----------------------------------------------------------------------------- +ydotool_package_available() { + case "${PKG_MANAGER}" in + apt) apt-cache show ydotool >/dev/null 2>&1 ;; + dnf) dnf info -q ydotool >/dev/null 2>&1 ;; + pacman) pacman -Si ydotool >/dev/null 2>&1 ;; + *) return 1 ;; + esac +} + +install_optional_ydotool() { + if command -v ydotool >/dev/null 2>&1 && command -v ydotoold >/dev/null 2>&1; then + log_ok "optional ydotool fallback already installed" + return 0 + fi + if ! ydotool_package_available; then + log_warn "optional ydotool package is unavailable from configured ${PKG_MANAGER} repositories" + log_info "a RemoteDesktop portal on Wayland or xdotool on X11 may still satisfy doctor" + return 0 + fi + + log_info "installing optional ydotool fallback" + case "${PKG_MANAGER}" in + apt) sudo apt-get install -y ydotool ;; + dnf) sudo dnf install -y ydotool ;; + pacman) sudo pacman -S --needed --noconfirm ydotool ;; + esac || { + log_warn "optional ydotool install failed — doctor will require a keyboard-capable portal or xdotool backend" + return 0 + } +} + install_system_deps() { log_section "Step 2/9 — system packages" if [[ ${SKIP_SYSTEM_DEPS} -eq 1 ]]; then log_skip "--skip-system-deps"; return 0; fi @@ -186,7 +310,8 @@ install_system_deps() { local desktop="${XDG_CURRENT_DESKTOP:-}" case "${PKG_MANAGER}" in apt) - local pkgs=(build-essential pkg-config libdbus-1-dev libssl-dev curl ydotool at-spi2-core) + local pkgs=(build-essential pkg-config libdbus-1-dev libssl-dev curl at-spi2-core) + if [[ ${X11_KEYBOARD_BACKEND_REQUIRED} -eq 1 ]]; then pkgs+=(xdotool); fi sudo apt-get update -qq if [[ "${desktop}" == *GNOME* ]] && ! command -v gnome-extensions >/dev/null 2>&1; then if apt-cache show gnome-shell >/dev/null 2>&1; then @@ -199,17 +324,20 @@ install_system_deps() { sudo apt-get install -y "${pkgs[@]}" || { log_fail "apt-get install failed"; return 1; } ;; dnf) - local pkgs=(gcc pkgconfig dbus-devel openssl-devel curl ydotool at-spi2-core) + local pkgs=(gcc pkgconfig dbus-devel openssl-devel curl at-spi2-core) + if [[ ${X11_KEYBOARD_BACKEND_REQUIRED} -eq 1 ]]; then pkgs+=(xdotool); fi log_info "sudo dnf install -y ${pkgs[*]}" sudo dnf install -y "${pkgs[@]}" || { log_fail "dnf install failed"; return 1; } ;; pacman) - local pkgs=(base-devel pkgconf dbus openssl curl ydotool at-spi2-core) + local pkgs=(base-devel pkgconf dbus openssl curl at-spi2-core) + if [[ ${X11_KEYBOARD_BACKEND_REQUIRED} -eq 1 ]]; then pkgs+=(xdotool); fi log_info "sudo pacman -S --needed --noconfirm ${pkgs[*]}" sudo pacman -S --needed --noconfirm "${pkgs[@]}" || { log_fail "pacman install failed"; return 1; } ;; esac - log_ok "system packages installed" + log_ok "required system packages installed" + install_optional_ydotool } # ----------------------------------------------------------------------------- @@ -306,21 +434,43 @@ enable_atspi() { } # ----------------------------------------------------------------------------- -# Step 6: ydotoold systemd --user service +# Step 6: ydotoold user service # ----------------------------------------------------------------------------- +systemd_user_manager_available() { + command -v systemctl >/dev/null 2>&1 && + systemctl --user show-environment >/dev/null 2>&1 +} + +show_manual_ydotoold_guidance() { + local ydotoold_path runtime_dir user_gid + ydotoold_path="$(command -v ydotoold 2>/dev/null || true)" + runtime_dir="${XDG_RUNTIME_DIR:-/run/user/${UID}}" + user_gid="$(id -g)" + if [[ -z "${ydotoold_path}" ]]; then + log_info "ydotool is optional; install it only if doctor needs that fallback" + return 0 + fi + log_info "configure your per-user supervisor to run:" + log_info " ${ydotoold_path} --socket-path=${runtime_dir}/.ydotool_socket --socket-own=${UID}:${user_gid}" + log_info "do not run ydotoold as root or expose its socket to other users" +} + setup_ydotoold() { log_section "Step 6/9 — ydotoold user service" if [[ ${SKIP_YDOTOOL} -eq 1 ]]; then log_skip "--skip-ydotool"; return 0; fi - command -v ydotoold >/dev/null 2>&1 || { log_fail "ydotoold not found in PATH (install via system deps step)"; return 1; } + if ! command -v ydotoold >/dev/null 2>&1; then + log_warn "optional ydotoold fallback is not installed — skipping its user service" + return 0 + fi # /dev/uinput permissions check. - if [[ ! -e /dev/uinput ]]; then - log_warn "/dev/uinput does not exist — kernel module may need loading" + if [[ ! -e "${UINPUT_DEVICE}" ]]; then + log_warn "${UINPUT_DEVICE} does not exist — kernel module may need loading" log_info " sudo modprobe uinput" - elif [[ ! -w /dev/uinput || ! -r /dev/uinput ]]; then - log_warn "/dev/uinput exists but is not user-accessible" + elif [[ ! -w "${UINPUT_DEVICE}" || ! -r "${UINPUT_DEVICE}" ]]; then + log_warn "${UINPUT_DEVICE} exists but is not user-accessible" log_info "Remediation (pick one, then log out/in):" log_info " sudo usermod -aG input \$USER" log_info "OR write a udev rule:" @@ -330,6 +480,12 @@ setup_ydotoold() { return 0 fi + if ! systemd_user_manager_available; then + log_warn "systemd --user is unavailable — skipping automatic ydotoold service setup" + show_manual_ydotoold_guidance + return 0 + fi + local unit_dir="${HOME}/.config/systemd/user" local unit_file="${unit_dir}/ydotoold.service" mkdir -p "${unit_dir}" @@ -507,4 +663,6 @@ main() { fi } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/scripts/install_sh_test.sh b/scripts/install_sh_test.sh new file mode 100755 index 0000000..5b72ae0 --- /dev/null +++ b/scripts/install_sh_test.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +INSTALLER="${ROOT_DIR}/install.sh" +FIXTURE_DIR="${ROOT_DIR}/tests/fixtures" + +assert_eq() { + local actual="$1" expected="$2" + if [[ "${actual}" != "${expected}" ]]; then + printf 'expected %q, got %q\n' "${expected}" "${actual}" >&2 + return 1 + fi +} + +assert_contains() { + local actual="$1" expected="$2" + if [[ "${actual}" != *"${expected}"* ]]; then + printf 'expected output to contain %q\n%s\n' "${expected}" "${actual}" >&2 + return 1 + fi +} + +assert_not_contains() { + local actual="$1" unexpected="$2" + if [[ "${actual}" == *"${unexpected}"* ]]; then + printf 'expected output not to contain %q\n%s\n' "${unexpected}" "${actual}" >&2 + return 1 + fi +} + +test_artix_selects_pacman() ( + export COMPUTER_USE_LINUX_OS_RELEASE_FILE="${FIXTURE_DIR}/os-release.artix" + export XDG_SESSION_TYPE=x11 + export XDG_CURRENT_DESKTOP=unknown + + # shellcheck source=../install.sh + source "${INSTALLER}" + package_manager_available() { [[ "$1" == "pacman" ]]; } + + detect_distro >/dev/null || return 1 + assert_eq "${PKG_MANAGER}" "pacman" || return 1 + assert_eq "${DISTRO_FAMILY}" "arch" || return 1 +) + +test_unknown_distro_selects_only_available_manager() ( + export COMPUTER_USE_LINUX_OS_RELEASE_FILE="${FIXTURE_DIR}/os-release.unknown" + export XDG_SESSION_TYPE=x11 + export XDG_CURRENT_DESKTOP=unknown + + # shellcheck source=../install.sh + source "${INSTALLER}" + FORCE_UNKNOWN_DISTRO=1 + package_manager_available() { [[ "$1" == "dnf" ]]; } + + detect_distro >/dev/null || return 1 + assert_eq "${PKG_MANAGER}" "dnf" || return 1 + assert_eq "${DISTRO_FAMILY}" "fedora" || return 1 +) + +test_skip_system_deps_needs_no_package_manager() ( + export COMPUTER_USE_LINUX_OS_RELEASE_FILE="${FIXTURE_DIR}/os-release.unknown" + export XDG_SESSION_TYPE=x11 + export XDG_CURRENT_DESKTOP=unknown + + # shellcheck source=../install.sh + source "${INSTALLER}" + FORCE_UNKNOWN_DISTRO=1 + SKIP_SYSTEM_DEPS=1 + package_manager_available() { return 1; } + + detect_distro >/dev/null || return 1 + assert_eq "${PKG_MANAGER}" "" || return 1 + assert_eq "${DISTRO_FAMILY}" "unknown" || return 1 +) + +test_skip_system_deps_allows_missing_override() ( + export COMPUTER_USE_LINUX_OS_RELEASE_FILE="${FIXTURE_DIR}/os-release.unknown" + export XDG_SESSION_TYPE=x11 + export XDG_CURRENT_DESKTOP=unknown + + # shellcheck source=../install.sh + source "${INSTALLER}" + PACKAGE_MANAGER_OVERRIDE=apt + SKIP_SYSTEM_DEPS=1 + package_manager_available() { return 1; } + + detect_distro >/dev/null || return 1 + assert_eq "${PKG_MANAGER}" "apt" || return 1 + assert_eq "${DISTRO_FAMILY}" "debian" || return 1 +) + +test_startx_system_deps_include_xdotool() ( + export COMPUTER_USE_LINUX_OS_RELEASE_FILE="${FIXTURE_DIR}/os-release.artix" + export XDG_SESSION_TYPE=tty + export DISPLAY=:0 + unset WAYLAND_DISPLAY XDG_SESSION_ID + export XDG_CURRENT_DESKTOP=unknown + + # shellcheck source=../install.sh + source "${INSTALLER}" + package_manager_available() { [[ "$1" == "pacman" ]]; } + sudo() { printf 'sudo %s\n' "$*"; } + install_optional_ydotool() { :; } + + local output + detect_distro >/dev/null || return 1 + output="$(install_system_deps)" || return 1 + assert_eq "${X11_KEYBOARD_BACKEND_REQUIRED}" "1" || return 1 + assert_contains "${output}" "pacman -S --needed --noconfirm" || return 1 + assert_contains "${output}" "xdotool" || return 1 +) + +test_wayland_system_deps_exclude_xdotool() ( + export COMPUTER_USE_LINUX_OS_RELEASE_FILE="${FIXTURE_DIR}/os-release.artix" + export XDG_SESSION_TYPE=wayland + export WAYLAND_DISPLAY=wayland-0 + export DISPLAY=:0 + unset XDG_SESSION_ID + export XDG_CURRENT_DESKTOP=unknown + + # shellcheck source=../install.sh + source "${INSTALLER}" + package_manager_available() { [[ "$1" == "pacman" ]]; } + sudo() { printf 'sudo %s\n' "$*"; } + install_optional_ydotool() { :; } + + local output + detect_distro >/dev/null || return 1 + output="$(install_system_deps)" || return 1 + assert_eq "${X11_KEYBOARD_BACKEND_REQUIRED}" "0" || return 1 + assert_not_contains "${output}" "xdotool" || return 1 +) + +test_non_systemd_host_gets_manual_guidance() ( + export XDG_RUNTIME_DIR="/run/user/test" + local uinput + uinput="$(mktemp)" + trap 'rm -f "${uinput}"' EXIT + export COMPUTER_USE_LINUX_UINPUT_DEVICE="${uinput}" + + # shellcheck source=../install.sh + source "${INSTALLER}" + systemd_user_manager_available() { return 1; } + ydotoold() { :; } + + local output + output="$(setup_ydotoold)" || return 1 + assert_contains "${output}" "systemd --user is unavailable" || return 1 + assert_contains "${output}" "configure your per-user supervisor to run" || return 1 + assert_contains "${output}" "ydotoold --socket-path=/run/user/test/.ydotool_socket" || return 1 + assert_contains "${output}" "do not run ydotoold as root" || return 1 +) + +test_non_systemd_host_requires_uinput_access() ( + local uinput + uinput="$(mktemp)" + chmod 000 "${uinput}" + trap 'chmod 600 "${uinput}"; rm -f "${uinput}"' EXIT + export COMPUTER_USE_LINUX_UINPUT_DEVICE="${uinput}" + + # shellcheck source=../install.sh + source "${INSTALLER}" + systemd_user_manager_available() { return 1; } + ydotoold() { :; } + + local output + output="$(setup_ydotoold)" || return 1 + assert_contains "${output}" "exists but is not user-accessible" || return 1 + assert_not_contains "${output}" "configure your per-user supervisor to run" || return 1 +) + +run_test() { + local name="$1" test_fn="$2" + if "${test_fn}"; then + printf 'ok - %s\n' "${name}" + else + printf 'not ok - %s\n' "${name}" >&2 + return 1 + fi +} + +run_test "Artix selects pacman" test_artix_selects_pacman +run_test "unknown distro selects its only supported manager" test_unknown_distro_selects_only_available_manager +run_test "--skip-system-deps needs no package manager" test_skip_system_deps_needs_no_package_manager +run_test "--skip-system-deps allows a missing override" test_skip_system_deps_allows_missing_override +run_test "startx system dependencies include xdotool" test_startx_system_deps_include_xdotool +run_test "Wayland system dependencies exclude xdotool" test_wayland_system_deps_exclude_xdotool +run_test "non-systemd host gets manual ydotoold guidance" test_non_systemd_host_gets_manual_guidance +run_test "non-systemd host requires uinput access" test_non_systemd_host_requires_uinput_access diff --git a/src/diagnostics.rs b/src/diagnostics.rs index dffe2ca..8748a1e 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -32,6 +32,25 @@ const FORCE_YDOTOOL_POINTER_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_YDOT const FORCE_XDOTOOL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_XDOTOOL_KEYBOARD"]; const FORCE_PORTAL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_PORTAL_KEYBOARD"]; const FORCE_PORTAL_POINTER_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_PORTAL_POINTER"]; +const PORTAL_DEVICE_KEYBOARD: u32 = 1; +const PORTAL_DEVICE_POINTER: u32 = 2; +const PORTAL_SOURCE_MONITOR: u32 = 1; +const REMOTE_DESKTOP_KEYBOARD_METHODS: &[&str] = &[ + "CreateSession", + "SelectDevices", + "Start", + "NotifyKeyboardKeycode", + "NotifyKeyboardKeysym", +]; +const REMOTE_DESKTOP_POINTER_METHODS: &[&str] = &[ + "CreateSession", + "SelectDevices", + "Start", + "NotifyPointerMotionAbsolute", + "NotifyPointerButton", + "NotifyPointerAxisDiscrete", +]; +const SCREENCAST_POINTER_METHODS: &[&str] = &["SelectSources"]; #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct DoctorReport { @@ -178,13 +197,26 @@ pub fn doctor_report() -> DoctorReport { hydrate_session_bus_env(); let platform = platform_report(); - let portals = portal_report(); + let (portals, remote_desktop_keyboard) = portal_report(); let accessibility = accessibility_report(); let windowing = windowing_report(&platform); let input = input_report(); - let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input); + let readiness = readiness_report_with_portal_keyboard( + &platform, + &remote_desktop_keyboard, + &accessibility, + &windowing, + &input, + ); - let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input); + let capabilities = capability_map_with_portal_keyboard( + &platform, + &portals, + &remote_desktop_keyboard, + &accessibility, + &windowing, + &input, + ); DoctorReport { platform, @@ -199,12 +231,31 @@ pub fn doctor_report() -> DoctorReport { /// Derive the per-layer backend capability map from the individual checks. Lists /// are ordered best-first and mirror the order the tool actually tries them. +#[cfg(test)] fn capability_map( platform: &PlatformReport, portals: &PortalReport, accessibility: &AccessibilityReport, windowing: &WindowingReport, input: &InputReport, +) -> CapabilityMap { + capability_map_with_portal_keyboard( + platform, + portals, + &portals.remote_desktop, + accessibility, + windowing, + input, + ) +} + +fn capability_map_with_portal_keyboard( + platform: &PlatformReport, + portals: &PortalReport, + remote_desktop_keyboard: &Check, + accessibility: &AccessibilityReport, + windowing: &WindowingReport, + input: &InputReport, ) -> CapabilityMap { let mut input_backends = Vec::new(); // Absolute uinput pointer: accurate, non-blocking of coordinates; preferred. @@ -213,13 +264,18 @@ fn capability_map( } let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS); let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS); - let portal_available = portal_input_available(platform, portals); - let portal_forced_for_all_input = force_portal_for_all_input( - env_flag_enabled_any(FORCE_PORTAL_POINTER_ENV_KEYS), - env_flag_enabled_any(FORCE_PORTAL_KEYBOARD_ENV_KEYS), - env_flag_enabled_any(FORCE_YDOTOOL_POINTER_ENV_KEYS), - force_ydotool, - ); + let portal_pointer_available = portal_pointer_input_available(platform, portals); + let portal_keyboard_available = + portal_keyboard_input_available(platform, remote_desktop_keyboard); + let portal_available = portal_pointer_available || portal_keyboard_available; + let portal_forced_for_all_input = portal_pointer_available + && portal_keyboard_available + && force_portal_for_all_input( + env_flag_enabled_any(FORCE_PORTAL_POINTER_ENV_KEYS), + env_flag_enabled_any(FORCE_PORTAL_KEYBOARD_ENV_KEYS), + env_flag_enabled_any(FORCE_YDOTOOL_POINTER_ENV_KEYS), + force_ydotool, + ); if should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) { input_backends.push("xdotool".to_string()); } @@ -603,16 +659,20 @@ fn platform_report() -> PlatformReport { } } -fn portal_report() -> PortalReport { - PortalReport { - desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"), - remote_desktop: portal_interface_check("org.freedesktop.portal.RemoteDesktop"), - screencast: portal_interface_check("org.freedesktop.portal.ScreenCast"), - screenshot: portal_interface_check("org.freedesktop.portal.Screenshot"), - input_capture: portal_interface_check("org.freedesktop.portal.InputCapture"), - mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"), - mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"), - } +fn portal_report() -> (PortalReport, Check) { + let (remote_desktop, remote_desktop_keyboard, screencast) = remote_desktop_portal_checks(); + ( + PortalReport { + desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"), + remote_desktop, + screencast, + screenshot: portal_interface_check("org.freedesktop.portal.Screenshot"), + input_capture: portal_interface_check("org.freedesktop.portal.InputCapture"), + mutter_remote_desktop: bus_name_check("org.gnome.Mutter.RemoteDesktop"), + mutter_screencast: bus_name_check("org.gnome.Mutter.ScreenCast"), + }, + remote_desktop_keyboard, + ) } fn accessibility_report() -> AccessibilityReport { @@ -704,19 +764,37 @@ fn input_report() -> InputReport { } } +#[cfg(test)] fn readiness_report( platform: &PlatformReport, portals: &PortalReport, accessibility: &AccessibilityReport, windowing: &WindowingReport, input: &InputReport, +) -> ReadinessReport { + readiness_report_with_portal_keyboard( + platform, + &portals.remote_desktop, + accessibility, + windowing, + input, + ) +} + +fn readiness_report_with_portal_keyboard( + platform: &PlatformReport, + remote_desktop_keyboard: &Check, + accessibility: &AccessibilityReport, + windowing: &WindowingReport, + input: &InputReport, ) -> ReadinessReport { let mut blockers = Vec::new(); let can_build_accessibility_tree = can_build_accessibility_tree(accessibility); let can_query_windows = windowing.can_list_windows; let can_focus_apps = windowing.can_focus_apps; let can_focus_windows = windowing.can_focus_windows; - let can_send_development_input = can_send_development_input(platform, portals, input); + let can_send_development_input = + can_send_development_input(platform, remote_desktop_keyboard, input); if !can_build_accessibility_tree { blockers.push( @@ -743,7 +821,7 @@ fn readiness_report( if !can_send_development_input { blockers.push( - "Development input is unavailable; enable read/write /dev/uinput, XDG RemoteDesktop portal input on Wayland, xdotool with DISPLAY on X11, or ydotool with a connectable ydotoold socket." + "Development keyboard input is unavailable; enable XDG RemoteDesktop portal input on Wayland, xdotool with DISPLAY on X11, or ydotool with a connectable ydotoold socket. Read/write /dev/uinput alone provides only absolute pointer input." .to_string(), ); } @@ -763,7 +841,7 @@ fn readiness_report( } else if !can_focus_windows { "Enable an exact-focus window backend before using window_id, title, or terminal-targeted input.".to_string() } else if !can_send_development_input { - "Enable a supported input backend: grant read/write /dev/uinput, enable the XDG RemoteDesktop portal on Wayland, install xdotool for X11, or start ydotoold with a socket accessible to this desktop user." + "Enable a keyboard-capable input backend: enable the XDG RemoteDesktop portal on Wayland, install xdotool for X11, or start ydotoold with a socket accessible to this desktop user." .to_string() } else { "Computer Use is ready: AT-SPI tree support, window targeting, and a Linux input backend are available." @@ -784,21 +862,27 @@ fn readiness_report( fn can_send_development_input( platform: &PlatformReport, - portals: &PortalReport, + remote_desktop_keyboard: &Check, input: &InputReport, ) -> bool { let force_ydotool = env_flag_enabled_any(FORCE_YDOTOOL_KEYBOARD_ENV_KEYS); let force_xdotool = env_flag_enabled_any(FORCE_XDOTOOL_KEYBOARD_ENV_KEYS); - input.uinput.ok - || portal_input_available(platform, portals) + portal_keyboard_input_available(platform, remote_desktop_keyboard) || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) || input.ydotool.ok && input.ydotool_socket.ok } -fn portal_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool { +fn portal_pointer_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool { platform_is_wayland(platform) && portals.remote_desktop.ok } +fn portal_keyboard_input_available( + platform: &PlatformReport, + remote_desktop_keyboard: &Check, +) -> bool { + platform_is_wayland(platform) && remote_desktop_keyboard.ok +} + fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool { platform .xdg_current_desktop @@ -974,6 +1058,192 @@ fn portal_interface_check(interface: &str) -> Check { ) } +fn remote_desktop_portal_checks() -> (Check, Check, Check) { + let introspection = portal_interface_check("org.freedesktop.portal.RemoteDesktop"); + let screencast = portal_interface_check("org.freedesktop.portal.ScreenCast"); + if !introspection.ok { + return (introspection.clone(), introspection, screencast); + } + + let available_device_types = command_check_with_session_bus( + "busctl", + &[ + "--user", + "get-property", + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.RemoteDesktop", + "AvailableDeviceTypes", + ], + ); + let available_source_types = command_check_with_session_bus( + "busctl", + &[ + "--user", + "get-property", + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.ScreenCast", + "AvailableSourceTypes", + ], + ); + let pointer = remote_desktop_pointer_check_from( + &introspection, + &screencast, + &available_device_types, + &available_source_types, + ); + let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types); + (pointer, keyboard, screencast) +} + +fn remote_desktop_pointer_check_from( + introspection: &Check, + screencast: &Check, + available_device_types: &Check, + available_source_types: &Check, +) -> Check { + if !introspection.ok { + return Check::fail(introspection.detail.clone()); + } + let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_POINTER_METHODS); + if !missing_methods.is_empty() { + return Check::fail(format!( + "RemoteDesktop interface is missing required pointer methods: {}", + missing_methods.join(", ") + )); + } + + if !screencast.ok { + return Check::fail(format!( + "ScreenCast interface is unavailable for portal pointer input: {}", + screencast.detail + )); + } + let missing_screencast_methods = missing_busctl_methods(screencast, SCREENCAST_POINTER_METHODS); + if !missing_screencast_methods.is_empty() { + return Check::fail(format!( + "ScreenCast interface is missing required pointer methods: {}", + missing_screencast_methods.join(", ") + )); + } + + let device_types = match remote_desktop_device_types(available_device_types) { + Ok(device_types) => device_types, + Err(detail) => return Check::fail(detail), + }; + if device_types & PORTAL_DEVICE_POINTER == 0 { + return Check::fail(format!( + "RemoteDesktop AvailableDeviceTypes={device_types} does not include pointer input" + )); + } + + let source_types = match screencast_source_types(available_source_types) { + Ok(source_types) => source_types, + Err(detail) => return Check::fail(detail), + }; + if source_types & PORTAL_SOURCE_MONITOR == 0 { + return Check::fail(format!( + "ScreenCast AvailableSourceTypes={source_types} does not include monitor sources" + )); + } + + Check::ok(format!( + "pointer-capable RemoteDesktop portal (AvailableDeviceTypes={device_types}, AvailableSourceTypes={source_types})" + )) +} + +fn remote_desktop_keyboard_check_from( + introspection: &Check, + available_device_types: &Check, +) -> Check { + if !introspection.ok { + return Check::fail(introspection.detail.clone()); + } + + let missing_methods = missing_busctl_methods(introspection, REMOTE_DESKTOP_KEYBOARD_METHODS); + if !missing_methods.is_empty() { + return Check::fail(format!( + "RemoteDesktop interface is missing required keyboard methods: {}", + missing_methods.join(", ") + )); + } + + let device_types = match remote_desktop_device_types(available_device_types) { + Ok(device_types) => device_types, + Err(detail) => return Check::fail(detail), + }; + if device_types & PORTAL_DEVICE_KEYBOARD == 0 { + return Check::fail(format!( + "RemoteDesktop AvailableDeviceTypes={device_types} does not include keyboard input" + )); + } + + Check::ok(format!( + "keyboard-capable RemoteDesktop portal (AvailableDeviceTypes={device_types})" + )) +} + +fn missing_busctl_methods<'a>(introspection: &Check, methods: &'a [&'a str]) -> Vec<&'a str> { + methods + .iter() + .copied() + .filter(|method| !busctl_introspection_has_method(&introspection.detail, method)) + .collect() +} + +fn remote_desktop_device_types(available_device_types: &Check) -> Result { + if !available_device_types.ok { + return Err(format!( + "RemoteDesktop AvailableDeviceTypes is unavailable: {}", + available_device_types.detail + )); + } + parse_busctl_u32_property(&available_device_types.detail).ok_or_else(|| { + format!( + "RemoteDesktop AvailableDeviceTypes has an unexpected value: {}", + available_device_types.detail + ) + }) +} + +fn screencast_source_types(available_source_types: &Check) -> Result { + if !available_source_types.ok { + return Err(format!( + "ScreenCast AvailableSourceTypes is unavailable: {}", + available_source_types.detail + )); + } + parse_busctl_u32_property(&available_source_types.detail).ok_or_else(|| { + format!( + "ScreenCast AvailableSourceTypes has an unexpected value: {}", + available_source_types.detail + ) + }) +} + +fn busctl_introspection_has_method(detail: &str, method: &str) -> bool { + detail.lines().any(|line| { + let mut fields = line.split_whitespace(); + fields + .next() + .is_some_and(|name| name.trim_start_matches('.') == method) + && fields.next() == Some("method") + }) +} + +fn parse_busctl_u32_property(detail: &str) -> Option { + let mut fields = detail.split_whitespace(); + if fields.next()? != "u" { + return None; + } + let value = fields.next()?; + value + .strip_prefix("0x") + .map(|hex| u32::from_str_radix(hex, 16).ok()) + .unwrap_or_else(|| value.parse().ok()) +} + fn atspi_bus_address_check() -> Check { let busctl = command_check_with_session_bus( "busctl", @@ -1336,6 +1606,210 @@ mod tests { assert!(!force_portal_for_all_input(true, true, false, true)); } + fn remote_desktop_runtime_introspection() -> Check { + Check::ok( + "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\ + .CreateSession method a{sv} o -\n\ + .SelectDevices method oa{sv} o -\n\ + .Start method osa{sv} o -\n\ + .NotifyPointerMotionAbsolute method oa{sv}udd - -\n\ + .NotifyPointerButton method oa{sv}iu - -\n\ + .NotifyPointerAxisDiscrete method oa{sv}ui - -\n\ + .NotifyKeyboardKeycode method ouu - -\n\ + .NotifyKeyboardKeysym method ouu - -\n\ + .AvailableDeviceTypes property u 3 emits-change", + ) + } + + fn screencast_runtime_introspection() -> Check { + Check::ok( + "NAME TYPE SIGNATURE RESULT/VALUE FLAGS\n\ + .SelectSources method oa{sv} o -", + ) + } + + #[test] + fn remote_desktop_portal_rejects_header_only_introspection() { + let introspection = Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"); + let available_device_types = Check::ok("u 3"); + let pointer = remote_desktop_pointer_check_from( + &introspection, + &screencast_runtime_introspection(), + &available_device_types, + &Check::ok("u 1"), + ); + let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types); + + assert!(!pointer.ok); + assert!(pointer.detail.contains("CreateSession")); + assert!(pointer.detail.contains("NotifyPointerButton")); + assert!(!keyboard.ok); + assert!(keyboard.detail.contains("NotifyKeyboardKeysym")); + + let input = input_report_parts( + Check::ok("ydotool"), + Check::ok("ydotoold"), + Check::fail("no connectable ydotool socket"), + Check::ok("read/write: /dev/uinput"), + ); + let platform = platform_report(); + let portals = portal_report(pointer); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let capabilities = capability_map_with_portal_keyboard( + &platform, + &portals, + &keyboard, + &accessibility, + &windowing, + &input, + ); + let readiness = readiness_report_with_portal_keyboard( + &platform, + &keyboard, + &accessibility, + &windowing, + &input, + ); + + assert!(!capabilities.input.iter().any(|backend| backend == "portal")); + assert!(!readiness.can_send_development_input); + } + + #[test] + fn keyboard_only_portal_remains_advertised_without_pointer_capability() { + let platform = platform_report(); + let keyboard = remote_desktop_keyboard_check_from( + &remote_desktop_runtime_introspection(), + &Check::ok("u 1"), + ); + let portals = portal_report(Check::fail( + "ScreenCast AvailableSourceTypes=2 does not include monitor sources", + )); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let input = input_report_parts( + Check::fail("missing ydotool"), + Check::fail("ydotoold not running"), + Check::fail("no connectable ydotool socket"), + Check::fail("/dev/uinput: Permission denied"), + ); + + let capabilities = capability_map_with_portal_keyboard( + &platform, + &portals, + &keyboard, + &accessibility, + &windowing, + &input, + ); + let readiness = readiness_report_with_portal_keyboard( + &platform, + &keyboard, + &accessibility, + &windowing, + &input, + ); + + assert!(!portals.remote_desktop.ok); + assert_eq!(capabilities.input, ["portal"]); + assert_eq!(capabilities.preferred.input.as_deref(), Some("portal")); + assert!(readiness.can_send_development_input); + } + + #[test] + fn remote_desktop_pointer_rejects_missing_screencast_contract() { + let check = remote_desktop_pointer_check_from( + &remote_desktop_runtime_introspection(), + &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"), + &Check::ok("u 2"), + &Check::ok("u 1"), + ); + + assert!(!check.ok); + assert!(check.detail.contains("SelectSources")); + } + + #[test] + fn remote_desktop_pointer_rejects_missing_monitor_source_type() { + let check = remote_desktop_pointer_check_from( + &remote_desktop_runtime_introspection(), + &screencast_runtime_introspection(), + &Check::ok("u 2"), + &Check::ok("u 2"), + ); + + assert!(!check.ok); + assert!(check.detail.contains("does not include monitor sources")); + } + + #[test] + fn remote_desktop_portal_rejects_missing_keyboard_device_type() { + let check = remote_desktop_keyboard_check_from( + &remote_desktop_runtime_introspection(), + &Check::ok("u 2"), + ); + + assert!(!check.ok); + assert!(check.detail.contains("does not include keyboard input")); + } + + #[test] + fn remote_desktop_portal_accepts_runtime_keyboard_contract() { + let check = remote_desktop_keyboard_check_from( + &remote_desktop_runtime_introspection(), + &Check::ok("u 3"), + ); + + assert!(check.ok); + assert!(check.detail.contains("AvailableDeviceTypes=3")); + } + + #[test] + fn pointer_only_portal_remains_advertised_without_keyboard_readiness() { + let platform = platform_report(); + let available_device_types = Check::ok("u 2"); + let pointer = remote_desktop_pointer_check_from( + &remote_desktop_runtime_introspection(), + &screencast_runtime_introspection(), + &available_device_types, + &Check::ok("u 1"), + ); + let keyboard = remote_desktop_keyboard_check_from( + &remote_desktop_runtime_introspection(), + &available_device_types, + ); + let portals = portal_report(pointer); + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let input = input_report_parts( + Check::fail("missing ydotool"), + Check::fail("ydotoold not running"), + Check::fail("no connectable ydotool socket"), + Check::fail("/dev/uinput: Permission denied"), + ); + + let capabilities = capability_map_with_portal_keyboard( + &platform, + &portals, + &keyboard, + &accessibility, + &windowing, + &input, + ); + let readiness = readiness_report_with_portal_keyboard( + &platform, + &keyboard, + &accessibility_report(Check::ok("bus"), Check::ok("true")), + &windowing_report(true, true), + &input, + ); + + assert!(portals.remote_desktop.ok); + assert!(capabilities.input.iter().any(|backend| backend == "portal")); + assert!(!readiness.can_send_development_input); + } + #[test] fn capabilities_require_display_to_advertise_xdotool() { let mut platform = platform_report(); @@ -1518,7 +1992,7 @@ mod tests { } #[test] - fn readiness_accepts_direct_uinput_without_connectable_ydotool_socket() { + fn wayland_readiness_rejects_pointer_only_uinput_without_keyboard_backend() { let platform = platform_report(); let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); let windowing = windowing_report(true, true); @@ -1537,8 +2011,39 @@ mod tests { &input, ); - assert!(readiness.can_send_development_input); - assert!(readiness.blockers.is_empty()); + assert!(!readiness.can_send_development_input); + assert!(readiness + .blockers + .iter() + .any(|blocker| blocker.contains("absolute pointer input"))); + assert!(readiness + .recommended_next_step + .contains("keyboard-capable input backend")); + } + + #[test] + fn x11_readiness_rejects_pointer_only_uinput_without_keyboard_backend() { + let mut platform = platform_report(); + platform.xdg_session_type = Some("x11".to_string()); + platform.wayland_display = None; + let accessibility = accessibility_report(Check::ok("bus"), Check::ok("true")); + let windowing = windowing_report(true, true); + let input = input_report_parts( + Check::fail("missing ydotool"), + Check::fail("ydotoold not running"), + Check::fail("no connectable ydotool socket"), + Check::ok("read/write: /dev/uinput"), + ); + + let readiness = readiness_report( + &platform, + &portal_report(Check::fail("missing")), + &accessibility, + &windowing, + &input, + ); + + assert!(!readiness.can_send_development_input); } #[test] @@ -1588,11 +2093,11 @@ mod tests { assert!(!readiness.can_send_development_input); assert!(readiness .recommended_next_step - .contains("Enable a supported input backend")); + .contains("Enable a keyboard-capable input backend")); assert!(readiness .blockers .iter() - .any(|blocker| blocker.contains("Development input is unavailable"))); + .any(|blocker| blocker.contains("Development keyboard input is unavailable"))); } #[test] diff --git a/tests/fixtures/os-release.artix b/tests/fixtures/os-release.artix new file mode 100644 index 0000000..755c731 --- /dev/null +++ b/tests/fixtures/os-release.artix @@ -0,0 +1,4 @@ +NAME="Artix Linux" +PRETTY_NAME="Artix Linux" +ID=artix +BUILD_ID=rolling diff --git a/tests/fixtures/os-release.unknown b/tests/fixtures/os-release.unknown new file mode 100644 index 0000000..24d0c07 --- /dev/null +++ b/tests/fixtures/os-release.unknown @@ -0,0 +1,3 @@ +NAME="Unknown Linux" +PRETTY_NAME="Unknown Linux" +ID=unknown