From 74055f496f2e2c421aa343ff6e3f7edc55526d80 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Tue, 4 Aug 2026 22:53:57 +0300 Subject: [PATCH 01/12] fix: support Artix and non-systemd installers --- .github/workflows/ci.yml | 9 ++ CHANGELOG.md | 4 + CONTRIBUTING.md | 1 + README.md | 8 +- install.sh | 183 +++++++++++++++++++++++++----- scripts/install_sh_test.sh | 82 +++++++++++++ tests/fixtures/os-release.artix | 4 + tests/fixtures/os-release.unknown | 3 + 8 files changed, 260 insertions(+), 34 deletions(-) create mode 100755 scripts/install_sh_test.sh create mode 100644 tests/fixtures/os-release.artix create mode 100644 tests/fixtures/os-release.unknown 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 1a6d447..c45af61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ 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, and skips automatic ydotoold setup when a + systemd user manager is unavailable. - Native X11 coordinate clicks now use one supervised `xdotool mousemove -- X Y click --repeat N BUTTON` command after the absolute pointer and eligible portal paths, with `ydotool` fallback only 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..a4b64ce 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. If it is unavailable from the configured repositories, the installer continues so the portal, direct uinput, or X11 xdotool backends can still satisfy `doctor`. 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,7 +387,7 @@ 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/`. - **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. diff --git a/install.sh b/install.sh index 2bbf634..8e634b6 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,7 @@ 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}" BIN_NAME="computer-use-linux" COSMIC_HELPER_NAME="computer-use-linux-cosmic" INSTALL_DIR="${HOME}/.local/bin" @@ -64,6 +65,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,32 +165,50 @@ 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 + 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 + 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}" + 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 ! package_manager_available "${PKG_MANAGER}"; then + log_fail "${PKG_MANAGER} was selected but its command is not on PATH" + return 1 + fi log_ok "distro family: ${DISTRO_FAMILY} (pkg manager: ${PKG_MANAGER})" # Display server. @@ -179,6 +238,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 "the portal, direct uinput, or X11 xdotool backends 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 — continuing with the other input backends" + 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 +276,7 @@ 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) 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 +289,18 @@ 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) 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) 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,14 +397,42 @@ 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 ! systemd_user_manager_available; then + log_warn "systemd --user is unavailable — skipping automatic ydotoold service setup" + show_manual_ydotoold_guidance + return 0 + fi + + 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 @@ -507,4 +626,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..d7380a0 --- /dev/null +++ b/scripts/install_sh_test.sh @@ -0,0 +1,82 @@ +#!/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 +} + +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 + assert_eq "${PKG_MANAGER}" "pacman" + assert_eq "${DISTRO_FAMILY}" "arch" +) + +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 + assert_eq "${PKG_MANAGER}" "dnf" + assert_eq "${DISTRO_FAMILY}" "fedora" +) + +test_non_systemd_host_gets_manual_guidance() ( + export XDG_RUNTIME_DIR="/run/user/test" + + # shellcheck source=../install.sh + source "${INSTALLER}" + systemd_user_manager_available() { return 1; } + ydotoold() { :; } + + local output + output="$(setup_ydotoold)" + assert_contains "${output}" "systemd --user is unavailable" + assert_contains "${output}" "configure your per-user supervisor to run" + assert_contains "${output}" "ydotoold --socket-path=/run/user/test/.ydotool_socket" + assert_contains "${output}" "do not run ydotoold as root" +) + +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 "non-systemd host gets manual ydotoold guidance" test_non_systemd_host_gets_manual_guidance 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 From 875633c239580ea0f7f0021f315f18de1dac9a7c Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 02:33:00 +0300 Subject: [PATCH 02/12] fix: harden non-systemd installer setup --- install.sh | 50 +++++++++++++--------- scripts/install_sh_test.sh | 87 +++++++++++++++++++++++++++++++++----- 2 files changed, 106 insertions(+), 31 deletions(-) diff --git a/install.sh b/install.sh index 8e634b6..7ea8562 100755 --- a/install.sh +++ b/install.sh @@ -18,6 +18,7 @@ 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" @@ -187,15 +188,20 @@ detect_distro() { set_package_manager pacman ;; *) if [[ ${FORCE_UNKNOWN_DISTRO} -eq 1 ]]; then - 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 + 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 - set_package_manager "${available[0]}" - log_warn "unknown distro '${ID:-?}' — using detected ${PKG_MANAGER}" else log_fail "unsupported distro: ${ID:-unknown} (${PRETTY_NAME:-?})" log_info "supported families: debian/ubuntu, fedora, arch/artix" @@ -205,11 +211,15 @@ detect_distro() { esac fi - if ! package_manager_available "${PKG_MANAGER}"; then + 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 - log_ok "distro family: ${DISTRO_FAMILY} (pkg manager: ${PKG_MANAGER})" + 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="" @@ -423,23 +433,17 @@ setup_ydotoold() { log_section "Step 6/9 — ydotoold user service" if [[ ${SKIP_YDOTOOL} -eq 1 ]]; then log_skip "--skip-ydotool"; 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 - 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:" @@ -449,6 +453,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}" diff --git a/scripts/install_sh_test.sh b/scripts/install_sh_test.sh index d7380a0..6766ad7 100755 --- a/scripts/install_sh_test.sh +++ b/scripts/install_sh_test.sh @@ -22,6 +22,14 @@ assert_contains() { 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 @@ -31,9 +39,9 @@ test_artix_selects_pacman() ( source "${INSTALLER}" package_manager_available() { [[ "$1" == "pacman" ]]; } - detect_distro >/dev/null - assert_eq "${PKG_MANAGER}" "pacman" - assert_eq "${DISTRO_FAMILY}" "arch" + 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() ( @@ -46,13 +54,69 @@ test_unknown_distro_selects_only_available_manager() ( FORCE_UNKNOWN_DISTRO=1 package_manager_available() { [[ "$1" == "dnf" ]]; } - detect_distro >/dev/null - assert_eq "${PKG_MANAGER}" "dnf" - assert_eq "${DISTRO_FAMILY}" "fedora" + 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_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}" @@ -60,11 +124,9 @@ test_non_systemd_host_gets_manual_guidance() ( ydotoold() { :; } local output - output="$(setup_ydotoold)" - assert_contains "${output}" "systemd --user is unavailable" - assert_contains "${output}" "configure your per-user supervisor to run" - assert_contains "${output}" "ydotoold --socket-path=/run/user/test/.ydotool_socket" - assert_contains "${output}" "do not run ydotoold as root" + 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() { @@ -79,4 +141,7 @@ run_test() { 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 "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 From acad0940a8aada26b25d12f69b007162bd9579a4 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 02:41:59 +0300 Subject: [PATCH 03/12] fix: install xdotool for X11 sessions --- CHANGELOG.md | 5 +++-- README.md | 2 +- install.sh | 9 +++++++-- scripts/install_sh_test.sh | 17 +++++++++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc4d977..c5503ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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, and skips automatic ydotoold setup when a - systemd user manager is unavailable. + ydotool as an optional fallback, installs the required xdotool keyboard + backend on X11, and skips automatic ydotoold setup when a systemd user + manager is unavailable. - Native X11 coordinate clicks now use one supervised `xdotool mousemove -- X Y click --repeat N BUTTON` command after the absolute pointer and eligible portal paths, with `ydotool` fallback only diff --git a/README.md b/README.md index a4b64ce..35c81c3 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ cd computer-use-linux computer-use-linux doctor | jq .readiness ``` -`ydotool` is an optional fallback. If it is unavailable from the configured repositories, the installer continues so the portal, direct uinput, or X11 xdotool backends can still satisfy `doctor`. 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. +`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 so the portal, direct uinput, or X11 xdotool backends can still satisfy `doctor`. 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) diff --git a/install.sh b/install.sh index 7ea8562..a6eba6a 100755 --- a/install.sh +++ b/install.sh @@ -131,6 +131,7 @@ esac DISTRO_FAMILY="" PKG_MANAGER="" +SESSION_TYPE="" set_package_manager() { case "$1" in @@ -227,11 +228,12 @@ detect_distro() { 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}" - 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}" @@ -287,6 +289,7 @@ install_system_deps() { case "${PKG_MANAGER}" in apt) local pkgs=(build-essential pkg-config libdbus-1-dev libssl-dev curl at-spi2-core) + if [[ "${SESSION_TYPE}" == "x11" ]]; 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 @@ -300,11 +303,13 @@ install_system_deps() { ;; dnf) local pkgs=(gcc pkgconfig dbus-devel openssl-devel curl at-spi2-core) + if [[ "${SESSION_TYPE}" == "x11" ]]; 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 at-spi2-core) + if [[ "${SESSION_TYPE}" == "x11" ]]; 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; } ;; diff --git a/scripts/install_sh_test.sh b/scripts/install_sh_test.sh index 6766ad7..64db8d1 100755 --- a/scripts/install_sh_test.sh +++ b/scripts/install_sh_test.sh @@ -91,6 +91,22 @@ test_skip_system_deps_allows_missing_override() ( assert_eq "${DISTRO_FAMILY}" "debian" || return 1 ) +test_x11_system_deps_include_xdotool() ( + export XDG_CURRENT_DESKTOP=unknown + + # shellcheck source=../install.sh + source "${INSTALLER}" + PKG_MANAGER=pacman + SESSION_TYPE=x11 + sudo() { printf 'sudo %s\n' "$*"; } + install_optional_ydotool() { :; } + + local output + output="$(install_system_deps)" || return 1 + assert_contains "${output}" "pacman -S --needed --noconfirm" || return 1 + assert_contains "${output}" "xdotool" || return 1 +) + test_non_systemd_host_gets_manual_guidance() ( export XDG_RUNTIME_DIR="/run/user/test" local uinput @@ -143,5 +159,6 @@ 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 "X11 system dependencies include xdotool" test_x11_system_deps_include_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 From cbf56981db2ff6f3b4ddc572d49458edfe16e618 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 02:50:28 +0300 Subject: [PATCH 04/12] fix: align X11 dependency detection --- install.sh | 32 +++++++++++++++++++++++++++----- scripts/install_sh_test.sh | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/install.sh b/install.sh index a6eba6a..9d18f33 100755 --- a/install.sh +++ b/install.sh @@ -132,6 +132,7 @@ esac DISTRO_FAMILY="" PKG_MANAGER="" SESSION_TYPE="" +X11_KEYBOARD_BACKEND_REQUIRED=0 set_package_manager() { case "$1" in @@ -227,8 +228,29 @@ detect_distro() { 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}" + 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 wayland) log_ok "display server: Wayland" ;; @@ -289,7 +311,7 @@ install_system_deps() { case "${PKG_MANAGER}" in apt) local pkgs=(build-essential pkg-config libdbus-1-dev libssl-dev curl at-spi2-core) - if [[ "${SESSION_TYPE}" == "x11" ]]; then pkgs+=(xdotool); fi + 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 @@ -303,13 +325,13 @@ install_system_deps() { ;; dnf) local pkgs=(gcc pkgconfig dbus-devel openssl-devel curl at-spi2-core) - if [[ "${SESSION_TYPE}" == "x11" ]]; then pkgs+=(xdotool); fi + 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 at-spi2-core) - if [[ "${SESSION_TYPE}" == "x11" ]]; then pkgs+=(xdotool); fi + 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; } ;; diff --git a/scripts/install_sh_test.sh b/scripts/install_sh_test.sh index 64db8d1..5b72ae0 100755 --- a/scripts/install_sh_test.sh +++ b/scripts/install_sh_test.sh @@ -91,22 +91,48 @@ test_skip_system_deps_allows_missing_override() ( assert_eq "${DISTRO_FAMILY}" "debian" || return 1 ) -test_x11_system_deps_include_xdotool() ( +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}" - PKG_MANAGER=pacman - SESSION_TYPE=x11 + 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 @@ -159,6 +185,7 @@ 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 "X11 system dependencies include xdotool" test_x11_system_deps_include_xdotool +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 From 444ba532ec9f843c6a1577d93288da94e9f32a07 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 03:00:35 +0300 Subject: [PATCH 05/12] fix: require keyboard-capable doctor input --- CHANGELOG.md | 3 ++- README.md | 4 ++-- install.sh | 4 ++-- src/diagnostics.rs | 48 +++++++++++++++++++++++++++++++++++++--------- 4 files changed, 45 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5503ad..b1520ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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, and skips automatic ydotoold setup when a systemd user + backend on X11, distinguishes pointer-only direct uinput from keyboard-ready + input in `doctor`, and skips automatic ydotoold setup when a systemd user manager is unavailable. - Native X11 coordinate clicks now use one supervised `xdotool mousemove -- X Y click --repeat N BUTTON` command after the diff --git a/README.md b/README.md index 35c81c3..7b24396 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ 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 so the portal, direct uinput, or X11 xdotool backends can still satisfy `doctor`. 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. +`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) @@ -389,7 +389,7 @@ If you're running this on a shared workstation, set `ydotoold`'s socket permissi - **`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. 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 9d18f33..ce22121 100755 --- a/install.sh +++ b/install.sh @@ -288,7 +288,7 @@ install_optional_ydotool() { fi if ! ydotool_package_available; then log_warn "optional ydotool package is unavailable from configured ${PKG_MANAGER} repositories" - log_info "the portal, direct uinput, or X11 xdotool backends may still satisfy doctor" + log_info "a RemoteDesktop portal on Wayland or xdotool on X11 may still satisfy doctor" return 0 fi @@ -298,7 +298,7 @@ install_optional_ydotool() { dnf) sudo dnf install -y ydotool ;; pacman) sudo pacman -S --needed --noconfirm ydotool ;; esac || { - log_warn "optional ydotool install failed — continuing with the other input backends" + log_warn "optional ydotool install failed — doctor will require a keyboard-capable portal or xdotool backend" return 0 } } diff --git a/src/diagnostics.rs b/src/diagnostics.rs index dffe2ca..072e2cd 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -743,7 +743,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 +763,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." @@ -789,8 +789,7 @@ fn can_send_development_input( ) -> 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_input_available(platform, portals) || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) || input.ydotool.ok && input.ydotool_socket.ok } @@ -1518,7 +1517,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 +1536,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 +1618,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] From 7ca0bde9ab5bcd652be2ed795a4346c843fc5885 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 03:07:18 +0300 Subject: [PATCH 06/12] docs: move installer changes to unreleased --- CHANGELOG.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1520ec..e4d6459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [0.4.6] - 2026-08-05 - ### Fixed - `install.sh` now recognizes Artix as pacman-based, chooses an explicitly requested or unambiguous package manager for unknown distros, treats @@ -16,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 backend on X11, distinguishes pointer-only direct uinput from keyboard-ready input in `doctor`, and skips automatic ydotoold setup when a systemd user manager is unavailable. + +## [0.4.6] - 2026-08-05 + +### Fixed - Native X11 coordinate clicks now use one supervised `xdotool mousemove -- X Y click --repeat N BUTTON` command after the absolute pointer and eligible portal paths, with `ydotool` fallback only From bf2d782aae0dad1ab460011ac8de84804f4d4f15 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 03:17:37 +0300 Subject: [PATCH 07/12] fix: verify portal keyboard capability --- CHANGELOG.md | 5 +- src/diagnostics.rs | 157 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4d6459..d9bad22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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`, and skips automatic ydotoold setup when a systemd user - manager is unavailable. + input in `doctor`, verifies that a Wayland RemoteDesktop portal exposes the + required keyboard contract, and skips automatic ydotoold setup when a + systemd user manager is unavailable. ## [0.4.6] - 2026-08-05 diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 072e2cd..3760881 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -32,6 +32,14 @@ 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 REMOTE_DESKTOP_KEYBOARD_METHODS: &[&str] = &[ + "CreateSession", + "SelectDevices", + "Start", + "NotifyKeyboardKeycode", + "NotifyKeyboardKeysym", +]; #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct DoctorReport { @@ -606,7 +614,7 @@ 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"), + remote_desktop: remote_desktop_portal_check(), 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"), @@ -973,6 +981,91 @@ fn portal_interface_check(interface: &str) -> Check { ) } +fn remote_desktop_portal_check() -> Check { + let introspection = portal_interface_check("org.freedesktop.portal.RemoteDesktop"); + if !introspection.ok { + return introspection; + } + + 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", + ], + ); + remote_desktop_portal_check_from(&introspection, &available_device_types) +} + +fn remote_desktop_portal_check_from( + introspection: &Check, + available_device_types: &Check, +) -> Check { + if !introspection.ok { + return Check::fail(introspection.detail.clone()); + } + + let missing_methods = REMOTE_DESKTOP_KEYBOARD_METHODS + .iter() + .copied() + .filter(|method| !busctl_introspection_has_method(&introspection.detail, method)) + .collect::>(); + if !missing_methods.is_empty() { + return Check::fail(format!( + "RemoteDesktop interface is missing required keyboard methods: {}", + missing_methods.join(", ") + )); + } + + if !available_device_types.ok { + return Check::fail(format!( + "RemoteDesktop AvailableDeviceTypes is unavailable: {}", + available_device_types.detail + )); + } + let Some(device_types) = parse_busctl_u32_property(&available_device_types.detail) else { + return Check::fail(format!( + "RemoteDesktop AvailableDeviceTypes has an unexpected value: {}", + available_device_types.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 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", @@ -1335,6 +1428,68 @@ mod tests { assert!(!force_portal_for_all_input(true, true, false, true)); } + fn keyboard_capable_remote_desktop_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\ + .NotifyKeyboardKeycode method ouu - -\n\ + .NotifyKeyboardKeysym method ouu - -\n\ + .AvailableDeviceTypes property u 3 emits-change", + ) + } + + #[test] + fn remote_desktop_portal_rejects_header_only_introspection() { + let check = remote_desktop_portal_check_from( + &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"), + &Check::ok("u 3"), + ); + + assert!(!check.ok); + assert!(check.detail.contains("CreateSession")); + assert!(check.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 readiness = readiness_report( + &platform_report(), + &portal_report(check), + &accessibility_report(Check::ok("bus"), Check::ok("true")), + &windowing_report(true, true), + &input, + ); + + assert!(!readiness.can_send_development_input); + } + + #[test] + fn remote_desktop_portal_rejects_missing_keyboard_device_type() { + let check = remote_desktop_portal_check_from( + &keyboard_capable_remote_desktop_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_portal_check_from( + &keyboard_capable_remote_desktop_introspection(), + &Check::ok("u 3"), + ); + + assert!(check.ok); + assert!(check.detail.contains("AvailableDeviceTypes=3")); + } + #[test] fn capabilities_require_display_to_advertise_xdotool() { let mut platform = platform_report(); From db70e5161e0d1b4756bb93fd906b464b87a4ffb8 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 03:26:40 +0300 Subject: [PATCH 08/12] chore: retrigger code scanning From b5b12b6a2c36ce2d84465bc61b7a16abc22c3724 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 03:56:54 +0300 Subject: [PATCH 09/12] fix: separate portal pointer and keyboard readiness --- CHANGELOG.md | 6 ++--- src/diagnostics.rs | 64 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9bad22..68bb526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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`, verifies that a Wayland RemoteDesktop portal exposes the - required keyboard contract, and skips automatic ydotoold setup when a - systemd user manager is unavailable. + 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 diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 3760881..a5250a5 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -98,6 +98,7 @@ pub struct PlatformReport { pub struct PortalReport { pub desktop_portal: Check, pub remote_desktop: Check, + pub remote_desktop_keyboard: Check, pub screencast: Check, pub screenshot: Check, pub input_capture: Check, @@ -612,9 +613,11 @@ fn platform_report() -> PlatformReport { } fn portal_report() -> PortalReport { + let (remote_desktop, remote_desktop_keyboard) = remote_desktop_portal_checks(); PortalReport { desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"), - remote_desktop: remote_desktop_portal_check(), + remote_desktop, + remote_desktop_keyboard, 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"), @@ -797,7 +800,7 @@ fn can_send_development_input( ) -> 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); - portal_input_available(platform, portals) + portal_keyboard_input_available(platform, portals) || should_advertise_xdotool(platform, input, force_ydotool, force_xdotool) || input.ydotool.ok && input.ydotool_socket.ok } @@ -806,6 +809,10 @@ fn portal_input_available(platform: &PlatformReport, portals: &PortalReport) -> platform_is_wayland(platform) && portals.remote_desktop.ok } +fn portal_keyboard_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool { + platform_is_wayland(platform) && portals.remote_desktop_keyboard.ok +} + fn is_cosmic_wayland_platform(platform: &PlatformReport) -> bool { platform .xdg_current_desktop @@ -981,10 +988,10 @@ fn portal_interface_check(interface: &str) -> Check { ) } -fn remote_desktop_portal_check() -> Check { +fn remote_desktop_portal_checks() -> (Check, Check) { let introspection = portal_interface_check("org.freedesktop.portal.RemoteDesktop"); if !introspection.ok { - return introspection; + return (introspection.clone(), introspection); } let available_device_types = command_check_with_session_bus( @@ -998,10 +1005,11 @@ fn remote_desktop_portal_check() -> Check { "AvailableDeviceTypes", ], ); - remote_desktop_portal_check_from(&introspection, &available_device_types) + let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types); + (introspection, keyboard) } -fn remote_desktop_portal_check_from( +fn remote_desktop_keyboard_check_from( introspection: &Check, available_device_types: &Check, ) -> Check { @@ -1195,9 +1203,18 @@ mod tests { } fn portal_report(remote_desktop: Check) -> PortalReport { + let remote_desktop_keyboard = remote_desktop.clone(); + portal_report_with_keyboard(remote_desktop, remote_desktop_keyboard) + } + + fn portal_report_with_keyboard( + remote_desktop: Check, + remote_desktop_keyboard: Check, + ) -> PortalReport { PortalReport { desktop_portal: Check::ok("ok"), remote_desktop, + remote_desktop_keyboard, screencast: Check::fail("missing"), screenshot: Check::fail("missing"), input_capture: Check::fail("missing"), @@ -1442,7 +1459,7 @@ mod tests { #[test] fn remote_desktop_portal_rejects_header_only_introspection() { - let check = remote_desktop_portal_check_from( + let check = remote_desktop_keyboard_check_from( &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"), &Check::ok("u 3"), ); @@ -1459,7 +1476,7 @@ mod tests { ); let readiness = readiness_report( &platform_report(), - &portal_report(check), + &portal_report_with_keyboard(Check::ok("org.freedesktop.portal.RemoteDesktop"), check), &accessibility_report(Check::ok("bus"), Check::ok("true")), &windowing_report(true, true), &input, @@ -1470,7 +1487,7 @@ mod tests { #[test] fn remote_desktop_portal_rejects_missing_keyboard_device_type() { - let check = remote_desktop_portal_check_from( + let check = remote_desktop_keyboard_check_from( &keyboard_capable_remote_desktop_introspection(), &Check::ok("u 2"), ); @@ -1481,7 +1498,7 @@ mod tests { #[test] fn remote_desktop_portal_accepts_runtime_keyboard_contract() { - let check = remote_desktop_portal_check_from( + let check = remote_desktop_keyboard_check_from( &keyboard_capable_remote_desktop_introspection(), &Check::ok("u 3"), ); @@ -1490,6 +1507,33 @@ mod tests { assert!(check.detail.contains("AvailableDeviceTypes=3")); } + #[test] + fn pointer_only_portal_remains_advertised_without_keyboard_readiness() { + let platform = platform_report(); + let keyboard = remote_desktop_keyboard_check_from( + &keyboard_capable_remote_desktop_introspection(), + &Check::ok("u 2"), + ); + let portals = portal_report_with_keyboard( + Check::ok("org.freedesktop.portal.RemoteDesktop"), + keyboard, + ); + 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(&platform, &portals, &accessibility, &windowing, &input); + let readiness = readiness_report(&platform, &portals, &accessibility, &windowing, &input); + + 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(); From 29def0534483f22c35575c61bfa9c89d657a1032 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 04:10:53 +0300 Subject: [PATCH 10/12] fix: validate portal input modalities --- src/diagnostics.rs | 264 +++++++++++++++++++++++++++++++++------------ 1 file changed, 198 insertions(+), 66 deletions(-) diff --git a/src/diagnostics.rs b/src/diagnostics.rs index a5250a5..a632b45 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -33,6 +33,7 @@ const FORCE_XDOTOOL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_XDO 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 REMOTE_DESKTOP_KEYBOARD_METHODS: &[&str] = &[ "CreateSession", "SelectDevices", @@ -40,6 +41,15 @@ const REMOTE_DESKTOP_KEYBOARD_METHODS: &[&str] = &[ "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 { @@ -98,7 +108,6 @@ pub struct PlatformReport { pub struct PortalReport { pub desktop_portal: Check, pub remote_desktop: Check, - pub remote_desktop_keyboard: Check, pub screencast: Check, pub screenshot: Check, pub input_capture: Check, @@ -187,11 +196,17 @@ 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); @@ -612,18 +627,20 @@ fn platform_report() -> PlatformReport { } } -fn portal_report() -> PortalReport { - let (remote_desktop, remote_desktop_keyboard) = remote_desktop_portal_checks(); - PortalReport { - desktop_portal: bus_name_check("org.freedesktop.portal.Desktop"), - remote_desktop, +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, - 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 accessibility_report() -> AccessibilityReport { @@ -715,19 +732,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( @@ -795,12 +830,12 @@ 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); - portal_keyboard_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 } @@ -809,8 +844,11 @@ fn portal_input_available(platform: &PlatformReport, portals: &PortalReport) -> platform_is_wayland(platform) && portals.remote_desktop.ok } -fn portal_keyboard_input_available(platform: &PlatformReport, portals: &PortalReport) -> bool { - platform_is_wayland(platform) && portals.remote_desktop_keyboard.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 { @@ -988,10 +1026,11 @@ fn portal_interface_check(interface: &str) -> Check { ) } -fn remote_desktop_portal_checks() -> (Check, 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); + return (introspection.clone(), introspection, screencast); } let available_device_types = command_check_with_session_bus( @@ -1005,41 +1044,76 @@ fn remote_desktop_portal_checks() -> (Check, Check) { "AvailableDeviceTypes", ], ); + let pointer = + remote_desktop_pointer_check_from(&introspection, &screencast, &available_device_types); let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types); - (introspection, keyboard) + (pointer, keyboard, screencast) } -fn remote_desktop_keyboard_check_from( +fn remote_desktop_pointer_check_from( introspection: &Check, + screencast: &Check, available_device_types: &Check, ) -> Check { if !introspection.ok { return Check::fail(introspection.detail.clone()); } - - let missing_methods = REMOTE_DESKTOP_KEYBOARD_METHODS - .iter() - .copied() - .filter(|method| !busctl_introspection_has_method(&introspection.detail, method)) - .collect::>(); + 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 keyboard methods: {}", + "RemoteDesktop interface is missing required pointer methods: {}", missing_methods.join(", ") )); } - if !available_device_types.ok { + if !screencast.ok { return Check::fail(format!( - "RemoteDesktop AvailableDeviceTypes is unavailable: {}", - available_device_types.detail + "ScreenCast interface is unavailable for portal pointer input: {}", + screencast.detail )); } - let Some(device_types) = parse_busctl_u32_property(&available_device_types.detail) else { + let missing_screencast_methods = missing_busctl_methods(screencast, SCREENCAST_POINTER_METHODS); + if !missing_screencast_methods.is_empty() { return Check::fail(format!( - "RemoteDesktop AvailableDeviceTypes has an unexpected value: {}", - available_device_types.detail + "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" + )); + } + + Check::ok(format!( + "pointer-capable RemoteDesktop portal (AvailableDeviceTypes={device_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!( @@ -1052,6 +1126,29 @@ fn remote_desktop_keyboard_check_from( )) } +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 busctl_introspection_has_method(detail: &str, method: &str) -> bool { detail.lines().any(|line| { let mut fields = line.split_whitespace(); @@ -1203,18 +1300,9 @@ mod tests { } fn portal_report(remote_desktop: Check) -> PortalReport { - let remote_desktop_keyboard = remote_desktop.clone(); - portal_report_with_keyboard(remote_desktop, remote_desktop_keyboard) - } - - fn portal_report_with_keyboard( - remote_desktop: Check, - remote_desktop_keyboard: Check, - ) -> PortalReport { PortalReport { desktop_portal: Check::ok("ok"), remote_desktop, - remote_desktop_keyboard, screencast: Check::fail("missing"), screenshot: Check::fail("missing"), input_capture: Check::fail("missing"), @@ -1445,28 +1533,44 @@ mod tests { assert!(!force_portal_for_all_input(true, true, false, true)); } - fn keyboard_capable_remote_desktop_introspection() -> Check { + 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 check = remote_desktop_keyboard_check_from( - &Check::ok("NAME TYPE SIGNATURE RESULT/VALUE FLAGS"), - &Check::ok("u 3"), + 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, ); + let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types); - assert!(!check.ok); - assert!(check.detail.contains("CreateSession")); - assert!(check.detail.contains("NotifyKeyboardKeysym")); + 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"), @@ -1474,21 +1578,39 @@ mod tests { Check::fail("no connectable ydotool socket"), Check::ok("read/write: /dev/uinput"), ); - let readiness = readiness_report( - &platform_report(), - &portal_report_with_keyboard(Check::ok("org.freedesktop.portal.RemoteDesktop"), check), - &accessibility_report(Check::ok("bus"), Check::ok("true")), - &windowing_report(true, true), + 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(&platform, &portals, &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 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"), + ); + + assert!(!check.ok); + assert!(check.detail.contains("SelectSources")); + } + #[test] fn remote_desktop_portal_rejects_missing_keyboard_device_type() { let check = remote_desktop_keyboard_check_from( - &keyboard_capable_remote_desktop_introspection(), + &remote_desktop_runtime_introspection(), &Check::ok("u 2"), ); @@ -1499,7 +1621,7 @@ mod tests { #[test] fn remote_desktop_portal_accepts_runtime_keyboard_contract() { let check = remote_desktop_keyboard_check_from( - &keyboard_capable_remote_desktop_introspection(), + &remote_desktop_runtime_introspection(), &Check::ok("u 3"), ); @@ -1510,14 +1632,17 @@ mod tests { #[test] fn pointer_only_portal_remains_advertised_without_keyboard_readiness() { let platform = platform_report(); - let keyboard = remote_desktop_keyboard_check_from( - &keyboard_capable_remote_desktop_introspection(), - &Check::ok("u 2"), + 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, ); - let portals = portal_report_with_keyboard( - Check::ok("org.freedesktop.portal.RemoteDesktop"), - keyboard, + 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( @@ -1528,8 +1653,15 @@ mod tests { ); let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input); - let readiness = readiness_report(&platform, &portals, &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); } From 517e6885a0251eb894c56a929945e7476a6e0b64 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 04:16:35 +0300 Subject: [PATCH 11/12] fix: require monitor portal sources --- src/diagnostics.rs | 64 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/src/diagnostics.rs b/src/diagnostics.rs index a632b45..8913c75 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -34,6 +34,7 @@ const FORCE_PORTAL_KEYBOARD_ENV_KEYS: &[&str] = &["COMPUTER_USE_LINUX_FORCE_PORT 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", @@ -1044,8 +1045,23 @@ fn remote_desktop_portal_checks() -> (Check, Check, Check) { "AvailableDeviceTypes", ], ); - let pointer = - remote_desktop_pointer_check_from(&introspection, &screencast, &available_device_types); + 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) } @@ -1054,6 +1070,7 @@ 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()); @@ -1090,8 +1107,18 @@ fn remote_desktop_pointer_check_from( )); } + 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})" + "pointer-capable RemoteDesktop portal (AvailableDeviceTypes={device_types}, AvailableSourceTypes={source_types})" )) } @@ -1149,6 +1176,21 @@ fn remote_desktop_device_types(available_device_types: &Check) -> Result 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(); @@ -1563,6 +1605,7 @@ mod tests { &introspection, &screencast_runtime_introspection(), &available_device_types, + &Check::ok("u 1"), ); let keyboard = remote_desktop_keyboard_check_from(&introspection, &available_device_types); @@ -1601,12 +1644,26 @@ mod tests { &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( @@ -1637,6 +1694,7 @@ mod tests { &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(), From d718a4da4cf32c8449745b5b9ea21d48554c0635 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 9 Aug 2026 04:25:35 +0300 Subject: [PATCH 12/12] fix: advertise keyboard-only portal input --- src/diagnostics.rs | 108 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 11 deletions(-) diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 8913c75..8748a1e 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -209,7 +209,14 @@ pub fn doctor_report() -> DoctorReport { &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, @@ -224,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. @@ -238,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()); } @@ -841,7 +872,7 @@ fn can_send_development_input( || 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 } @@ -1625,7 +1656,14 @@ mod tests { 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(&platform, &portals, &accessibility, &windowing, &input); + let capabilities = capability_map_with_portal_keyboard( + &platform, + &portals, + &keyboard, + &accessibility, + &windowing, + &input, + ); let readiness = readiness_report_with_portal_keyboard( &platform, &keyboard, @@ -1638,6 +1676,47 @@ mod tests { 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( @@ -1710,7 +1789,14 @@ mod tests { Check::fail("/dev/uinput: Permission denied"), ); - let capabilities = capability_map(&platform, &portals, &accessibility, &windowing, &input); + let capabilities = capability_map_with_portal_keyboard( + &platform, + &portals, + &keyboard, + &accessibility, + &windowing, + &input, + ); let readiness = readiness_report_with_portal_keyboard( &platform, &keyboard,