diff --git a/.gitignore b/.gitignore index 1f9588d..d131413 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,10 @@ server/telemetry/.env /infra/ /artifacts/ /release_tests/ -/tests/ +/tests/* +!/tests/test_game_launch_compatibility.py +!/tests/test_launcher_resilience.py +!/tests/test_window_focus_regressions.py /tools/ /.gitea/ /.run/ diff --git a/RELEASE_README.md b/RELEASE_README.md index 296c952..09f1c85 100644 --- a/RELEASE_README.md +++ b/RELEASE_README.md @@ -1,6 +1,6 @@ # PristonTale EU for Linux -Version 1.1.1 installs, updates, repairs, and launches PristonTale EU through a +Version 1.1.3 installs, updates, repairs, and launches PristonTale EU through a native Linux dashboard. Game files come directly from the publisher and are not included in this archive. @@ -15,11 +15,11 @@ Then run: ```text openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum openssl dgst -sha256 -verify launcher-update-public.pem \ - -signature pristontale-eu-linux-installer-v1-1-1.tar.gz.sig \ - pristontale-eu-linux-installer-v1-1-1.tar.gz -sha256sum -c pristontale-eu-linux-installer-v1-1-1.tar.gz.sha256 -tar -xzf pristontale-eu-linux-installer-v1-1-1.tar.gz -cd pristontale-eu-linux-installer-v1-1-1 + -signature pristontale-eu-linux-installer-v1-1-3.tar.gz.sig \ + pristontale-eu-linux-installer-v1-1-3.tar.gz +sha256sum -c pristontale-eu-linux-installer-v1-1-3.tar.gz.sha256 +tar -xzf pristontale-eu-linux-installer-v1-1-3.tar.gz +cd pristontale-eu-linux-installer-v1-1-3 ./install.sh ``` diff --git a/native/gamescope/README.md b/native/gamescope/README.md new file mode 100644 index 0000000..c27ebc8 --- /dev/null +++ b/native/gamescope/README.md @@ -0,0 +1,53 @@ +# Gamescope compatibility build provenance + +This directory is the canonical source record for the patched Gamescope helper +shipped by the launcher. The executable remains in the Python package because +runtime lookup and installed paths are compatibility interfaces. + +## Artifact + +| Property | Recorded value | +| --- | --- | +| Source package | Ubuntu `gamescope 3.16.20+ds-1` | +| Build environment | `ubuntu:26.04` container | +| Target | Ubuntu 26.04, amd64, Linux x86-64 baseline | +| Packaged source | `src/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64` | +| Installed path | `ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64` | +| ELF build ID | `165d21610879119d75ab8e4c3d7389d8f0743926` | +| SHA-256 | `3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639` | + +`source.sha256` records the exact Ubuntu source archives. Obtain both archives +without renaming them and place them in +`.artifacts/native/gamescope/sources/`: + +- `gamescope_3.16.20+ds.orig.tar.xz` +- `gamescope_3.16.20+ds-1.debian.tar.xz` + +The patches are applied in this order: + +1. `gamescope-wayland-letterbox-backing.patch` +2. `gamescope-wayland-libdecor-input-scope.patch` +3. `gamescope-lazy-output-images.patch` +4. `gamescope-wayland-live-resize.patch` +5. `gamescope-keep-nested-windows-visible.patch` + +## Build + +Run the recipe from any directory: + +```text +./native/gamescope/build.sh +``` + +Pass a different source-archive directory as the first argument and an output +directory as the second argument when needed. Set `CONTAINER_ENGINE` to select +Docker or Podman explicitly. The recipe builds with `dpkg-buildpackage` in an +Ubuntu 26.04 amd64 container, writes below `.artifacts/` by default, and checks +the result against `binary.sha256`. It refuses to write over the shipped helper. +The container downloads build dependencies, so the Ubuntu 26.04 package +repositories used at build time remain part of the build environment. + +The files named `gamescope-compat.README`, `gamescope-compat.copyright`, and the +five patches continue to be staged at their historical flat installed paths. +The full Debian copyright record is retained in +`gamescope-compat.copyright`. diff --git a/native/gamescope/binary.sha256 b/native/gamescope/binary.sha256 new file mode 100644 index 0000000..ac84d9d --- /dev/null +++ b/native/gamescope/binary.sha256 @@ -0,0 +1 @@ +3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639 gamescope-3.16.20-ubuntu26.04-amd64 diff --git a/native/gamescope/build-in-container.sh b/native/gamescope/build-in-container.sh new file mode 100755 index 0000000..c9351ed --- /dev/null +++ b/native/gamescope/build-in-container.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash + +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +if [[ -f /etc/apt/sources.list.d/ubuntu.sources ]] +then + sed -i 's/^Types: deb$/Types: deb deb-src/' \ + /etc/apt/sources.list.d/ubuntu.sources +fi +apt-get update +apt-get install --yes build-essential ca-certificates devscripts dpkg-dev patch xz-utils +apt-get build-dep --yes gamescope + +BUILD_ROOT="$(mktemp -d /tmp/ptlinux-gamescope.XXXXXX)" +trap 'rm -rf -- "$BUILD_ROOT"' EXIT +tar --extract \ + --file /sources/gamescope_3.16.20+ds.orig.tar.xz \ + --directory "$BUILD_ROOT" +SOURCE_ROOT="$(find "$BUILD_ROOT" -mindepth 1 -maxdepth 1 -type d -print -quit)" +if [[ -z "$SOURCE_ROOT" ]] +then + printf '%s\n' "The Gamescope source archive has no top-level directory." >&2 + exit 1 +fi +tar --extract \ + --file /sources/gamescope_3.16.20+ds-1.debian.tar.xz \ + --directory "$SOURCE_ROOT" + +PATCH_ROOT="/workspace/native/gamescope/patches" +PATCH_SERIES=( + gamescope-wayland-letterbox-backing.patch + gamescope-wayland-libdecor-input-scope.patch + gamescope-lazy-output-images.patch + gamescope-wayland-live-resize.patch + gamescope-keep-nested-windows-visible.patch +) +mkdir -p -- "${SOURCE_ROOT}/debian/patches" +touch "${SOURCE_ROOT}/debian/patches/series" +for patch_name in "${PATCH_SERIES[@]}" +do + install -m 0644 \ + "${PATCH_ROOT}/${patch_name}" \ + "${SOURCE_ROOT}/debian/patches/${patch_name}" + if ! grep -Fqx -- "$patch_name" "${SOURCE_ROOT}/debian/patches/series" + then + printf '%s\n' "$patch_name" >> "${SOURCE_ROOT}/debian/patches/series" + fi +done + +( + cd "$SOURCE_ROOT" + DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage --build=binary --no-sign -jauto +) +PACKAGE="$(find "$BUILD_ROOT" -maxdepth 1 -type f \ + -name 'gamescope_*_amd64.deb' -print -quit)" +if [[ -z "$PACKAGE" ]] +then + printf '%s\n' "The Gamescope build did not produce an amd64 Debian package." >&2 + exit 1 +fi +EXTRACT_ROOT="${BUILD_ROOT}/package" +dpkg-deb --extract "$PACKAGE" "$EXTRACT_ROOT" +if [[ -x "${EXTRACT_ROOT}/usr/games/gamescope" ]] +then + BINARY="${EXTRACT_ROOT}/usr/games/gamescope" +elif [[ -x "${EXTRACT_ROOT}/usr/bin/gamescope" ]] +then + BINARY="${EXTRACT_ROOT}/usr/bin/gamescope" +else + printf '%s\n' "The Gamescope Debian package contains no executable." >&2 + exit 1 +fi +install -m 0755 "$BINARY" \ + /output/gamescope-3.16.20-ubuntu26.04-amd64 +chown "${HOST_UID}:${HOST_GID}" \ + /output/gamescope-3.16.20-ubuntu26.04-amd64 diff --git a/native/gamescope/build.sh b/native/gamescope/build.sh new file mode 100755 index 0000000..1dac2e9 --- /dev/null +++ b/native/gamescope/build.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROVENANCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd -- "${PROVENANCE_ROOT}/../.." && pwd)" +SOURCE_ARCHIVE_DIRECTORY="${1:-${PROJECT_ROOT}/.artifacts/native/gamescope/sources}" +OUTPUT_DIRECTORY="${2:-${PROJECT_ROOT}/.artifacts/native/gamescope}" +ARTIFACT_NAME="gamescope-3.16.20-ubuntu26.04-amd64" +SHIPPED_ARTIFACT="${PROJECT_ROOT}/src/ptlinux/native/${ARTIFACT_NAME}" + +if [[ ! -d "$SOURCE_ARCHIVE_DIRECTORY" ]] +then + printf 'Gamescope source-archive directory does not exist: %s\n' \ + "$SOURCE_ARCHIVE_DIRECTORY" >&2 + exit 2 +fi +mkdir -p -- "$OUTPUT_DIRECTORY" +SOURCE_ARCHIVE_DIRECTORY="$(cd -- "$SOURCE_ARCHIVE_DIRECTORY" && pwd)" +OUTPUT_DIRECTORY="$(cd -- "$OUTPUT_DIRECTORY" && pwd)" +if [[ "${OUTPUT_DIRECTORY}/${ARTIFACT_NAME}" == "$SHIPPED_ARTIFACT" ]] +then + printf '%s\n' "Refusing to overwrite the shipped Gamescope executable." >&2 + exit 2 +fi + +( + cd "$SOURCE_ARCHIVE_DIRECTORY" + sha256sum --check "${PROVENANCE_ROOT}/source.sha256" +) + +if [[ -n "${CONTAINER_ENGINE:-}" ]] +then + ENGINE="$CONTAINER_ENGINE" +elif command -v podman >/dev/null 2>&1 +then + ENGINE="podman" +elif command -v docker >/dev/null 2>&1 +then + ENGINE="docker" +else + printf '%s\n' "Docker or Podman is required to rebuild Gamescope." >&2 + exit 1 +fi +if ! command -v "$ENGINE" >/dev/null 2>&1 +then + printf 'Container engine was not found: %s\n' "$ENGINE" >&2 + exit 1 +fi + +rm -f -- "${OUTPUT_DIRECTORY}/${ARTIFACT_NAME}" +"$ENGINE" run --rm --platform linux/amd64 \ + --env "HOST_UID=$(id -u)" \ + --env "HOST_GID=$(id -g)" \ + --volume "${PROJECT_ROOT}:/workspace:ro" \ + --volume "${SOURCE_ARCHIVE_DIRECTORY}:/sources:ro" \ + --volume "${OUTPUT_DIRECTORY}:/output" \ + ubuntu:26.04 \ + bash /workspace/native/gamescope/build-in-container.sh +( + cd "$OUTPUT_DIRECTORY" + sha256sum --check "${PROVENANCE_ROOT}/binary.sha256" +) diff --git a/ptlinux/native/gamescope-compat.README b/native/gamescope/gamescope-compat.README similarity index 100% rename from ptlinux/native/gamescope-compat.README rename to native/gamescope/gamescope-compat.README diff --git a/ptlinux/native/gamescope-compat.copyright b/native/gamescope/gamescope-compat.copyright similarity index 100% rename from ptlinux/native/gamescope-compat.copyright rename to native/gamescope/gamescope-compat.copyright diff --git a/ptlinux/native/gamescope-keep-nested-windows-visible.patch b/native/gamescope/patches/gamescope-keep-nested-windows-visible.patch similarity index 100% rename from ptlinux/native/gamescope-keep-nested-windows-visible.patch rename to native/gamescope/patches/gamescope-keep-nested-windows-visible.patch diff --git a/ptlinux/native/gamescope-lazy-output-images.patch b/native/gamescope/patches/gamescope-lazy-output-images.patch similarity index 100% rename from ptlinux/native/gamescope-lazy-output-images.patch rename to native/gamescope/patches/gamescope-lazy-output-images.patch diff --git a/ptlinux/native/gamescope-wayland-letterbox-backing.patch b/native/gamescope/patches/gamescope-wayland-letterbox-backing.patch similarity index 100% rename from ptlinux/native/gamescope-wayland-letterbox-backing.patch rename to native/gamescope/patches/gamescope-wayland-letterbox-backing.patch diff --git a/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch b/native/gamescope/patches/gamescope-wayland-libdecor-input-scope.patch similarity index 100% rename from ptlinux/native/gamescope-wayland-libdecor-input-scope.patch rename to native/gamescope/patches/gamescope-wayland-libdecor-input-scope.patch diff --git a/ptlinux/native/gamescope-wayland-live-resize.patch b/native/gamescope/patches/gamescope-wayland-live-resize.patch similarity index 100% rename from ptlinux/native/gamescope-wayland-live-resize.patch rename to native/gamescope/patches/gamescope-wayland-live-resize.patch diff --git a/native/gamescope/source.sha256 b/native/gamescope/source.sha256 new file mode 100644 index 0000000..6768384 --- /dev/null +++ b/native/gamescope/source.sha256 @@ -0,0 +1,2 @@ +a27984fdab2bbfe353efc772de214ae88cccab07c4507747ed4b7f063b2faa0e gamescope_3.16.20+ds.orig.tar.xz +ae6e9bac39b576e7d9c15912a713796049a29756f6858364ef49bd02197cb153 gamescope_3.16.20+ds-1.debian.tar.xz diff --git a/packaging/install-dependencies.sh b/packaging/install-dependencies.sh index f9a635a..0fc0ab1 100644 --- a/packaging/install-dependencies.sh +++ b/packaging/install-dependencies.sh @@ -234,7 +234,7 @@ ptlinux_detect_missing_dependencies() if ! command -v "$python_bin" >/dev/null 2>&1 then PTLINUX_MISSING_DEPENDENCIES+=(python python-tk) - elif ! "$python_bin" -c 'import _tkinter' >/dev/null 2>&1 + elif ! "$python_bin" -c 'import tkinter; from tkinter import ttk, filedialog; tkinter.Tcl()' >/dev/null 2>&1 then PTLINUX_MISSING_DEPENDENCIES+=(python-tk) fi diff --git a/pyproject.toml b/pyproject.toml index 89d8383..9bb838b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "PTEU-Linux-Launcher" -version = "1.1.1" +version = "1.1.3" description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness" requires-python = ">=3.11" dependencies = [] diff --git a/release/README.md b/release/README.md index fd80716..8a2fc12 100644 --- a/release/README.md +++ b/release/README.md @@ -3,11 +3,12 @@ Manual publication mode is currently active. Build a complete local package with: ```text -./scripts/release.sh stable 1.8.0 -./scripts/release.sh prerelease 1.9.0-beta.1 +SIGNING_KEY_PATH="${XDG_CONFIG_HOME:-$HOME/.config}/pristontale-eu/release/launcher-update-private.pem" +PTLINUX_SIGNING_KEY="$SIGNING_KEY_PATH" ./scripts/release.sh stable 1.8.0 +PTLINUX_SIGNING_KEY="$SIGNING_KEY_PATH" ./scripts/release.sh prerelease 1.9.0-beta.1 ``` -The command creates `dist/v/`, validates the channel/version pair, +The command creates `.artifacts/releases/v/`, validates the channel/version pair, builds and previews release notes, runs the configured preflight, creates a deterministic archive, signs the manifest, and verifies the complete result. It changes the requested version only inside the staged package, so the source @@ -24,11 +25,15 @@ releases after `0.9.11` return to the flat layout. Use `--description` for short Markdown or `--description-file` for a prepared Markdown document. Generated commit notes are appended by default. Run `./scripts/release.sh --help` for dry-run, editor, changelog-only, signing-key, -and test options. +output-directory, and test options. Custom release output must remain below +`.artifacts/releases/` or an operating-system temporary directory. See the +[artifact layout and migration guide](../docs/development/ARTIFACTS.md) for all +generated-output locations. The private signing key has no checkout default; +provide a mode-`0600` regular file outside the repository and artifact output. After a successful build, create a new release in Gitea with tag `v`. Leave **Pre-release** clear for `stable`; select it for `prerelease`. Use -`dist/v/release-notes.md` as the description and upload exactly these +`.artifacts/releases/v/release-notes.md` as the description and upload exactly these five files from that version directory: - `PristonTale-EU-.tar.gz` @@ -43,14 +48,14 @@ signed archive URL includes its SHA-256 as a query parameter so Cloudflare and other CDNs cannot reuse an empty response cached before the attachment existed. After the Gitea release exists and each attachment has a nonzero size, publish the matching files generated under -`dist/v/channel/` to the repository: +`.artifacts/releases/v/channel/` to the repository: ```text -dist/v/channel/stable.json -> updates/stable.json -dist/v/channel/stable.json.sig -> updates/stable.json.sig +.artifacts/releases/v/channel/stable.json -> updates/stable.json +.artifacts/releases/v/channel/stable.json.sig -> updates/stable.json.sig -dist/v/channel/prerelease.json -> updates/prerelease.json -dist/v/channel/prerelease.json.sig -> updates/prerelease.json.sig +.artifacts/releases/v/channel/prerelease.json -> updates/prerelease.json +.artifacts/releases/v/channel/prerelease.json.sig -> updates/prerelease.json.sig ``` Commit and push only the selected channel pair. Publishing a prerelease must not @@ -71,8 +76,8 @@ For direct artifact tooling without release notes or manual-upload guidance: ```text PTLINUX_PYTHON=/path/to/python \ - ./scripts/build-release.sh 0.9.11 stable ./dist/v0.9.11 -./scripts/verify-release.sh 0.9.11 stable ./dist/v0.9.11 + ./scripts/build-release.sh 0.9.11 stable ./.artifacts/releases/v0.9.11 +./scripts/verify-release.sh 0.9.11 stable ./.artifacts/releases/v0.9.11 ``` The builder selects the runtime payload declared in `config.json`; it does not @@ -101,13 +106,17 @@ must provide Git, Bash, curl, jq, Python 3.11 or newer, tar, gzip, coreutils uses `actions/checkout@v4` and checks that the checked-out commit is the exact commit referenced by the annotated tag. +The tracked runner template and local setup instructions are in +[`../infra/gitea-runner/`](../infra/gitea-runner/). Its `.env`, registration +token, and persistent state stay outside source control. + Configure these protected Actions secrets: - `RELEASE_TOKEN`: a dedicated token with only the repository release and repository-content write permissions needed to create releases, upload attachments, and update signed channel metadata. - `MANIFEST_SIGNING_KEY`: the private key corresponding to - `ptlinux/launcher-update-public.pem`. + `src/ptlinux/launcher-update-public.pem`. Secrets are available only to trusted tag and release-status workflows. Do not add the release secrets to pull-request workflows. The signing key is written under diff --git a/release/config.json b/release/config.json index 950d670..ff2c7e2 100644 --- a/release/config.json +++ b/release/config.json @@ -27,7 +27,8 @@ "version_mirrors": [ "pyproject.toml" ], - "version_source": "ptlinux/__init__.py", + "version_source": "src/ptlinux/__init__.py", + "packaged_version_path": "ptlinux/__init__.py", "payload": { "python_trees": [ { @@ -35,61 +36,80 @@ "desktop_entry.py", "launcher_wrapper.py" ], - "source": "ptlinux" + "source": "src/ptlinux", + "destination": "ptlinux" } ], "files": [ { "mode": "0644", - "path": "ptlinux/launcher-update-public.pem" + "source": "src/ptlinux/launcher-update-public.pem", + "destination": "ptlinux/launcher-update-public.pem" }, { "mode": "0644", - "path": "ptdiag/__init__.py" + "source": "src/ptdiag/__init__.py", + "destination": "ptdiag/__init__.py" }, { "mode": "0644", - "path": "ptdiag/assbin.py" + "source": "src/ptdiag/assbin.py", + "destination": "ptdiag/assbin.py" }, { "mode": "0644", - "path": "ptdiag/logs.py" + "source": "src/ptdiag/logs.py", + "destination": "ptdiag/logs.py" }, { "mode": "0644", - "path": "ptlinux/native/pt-identity-probe.exe" + "source": "src/ptlinux/native/pt-identity-probe.exe", + "destination": "ptlinux/native/pt-identity-probe.exe" + }, + { + "mode": "0644", + "source": "src/ptlinux/native/ptr-bcrypt-proxy.dll", + "destination": "ptlinux/native/ptr-bcrypt-proxy.dll" }, { "mode": "0755", - "path": "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" + "source": "src/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64", + "destination": "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" }, { "mode": "0644", - "path": "ptlinux/native/gamescope-compat.README" + "source": "native/gamescope/gamescope-compat.README", + "destination": "ptlinux/native/gamescope-compat.README" }, { "mode": "0644", - "path": "ptlinux/native/gamescope-compat.copyright" + "source": "native/gamescope/gamescope-compat.copyright", + "destination": "ptlinux/native/gamescope-compat.copyright" }, { "mode": "0644", - "path": "ptlinux/native/gamescope-lazy-output-images.patch" + "source": "native/gamescope/patches/gamescope-lazy-output-images.patch", + "destination": "ptlinux/native/gamescope-lazy-output-images.patch" }, { "mode": "0644", - "path": "ptlinux/native/gamescope-wayland-live-resize.patch" + "source": "native/gamescope/patches/gamescope-wayland-live-resize.patch", + "destination": "ptlinux/native/gamescope-wayland-live-resize.patch" }, { "mode": "0644", - "path": "ptlinux/native/gamescope-wayland-letterbox-backing.patch" + "source": "native/gamescope/patches/gamescope-wayland-letterbox-backing.patch", + "destination": "ptlinux/native/gamescope-wayland-letterbox-backing.patch" }, { "mode": "0644", - "path": "ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" + "source": "native/gamescope/patches/gamescope-wayland-libdecor-input-scope.patch", + "destination": "ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" }, { "mode": "0644", - "path": "ptlinux/native/gamescope-keep-nested-windows-visible.patch" + "source": "native/gamescope/patches/gamescope-keep-nested-windows-visible.patch", + "destination": "ptlinux/native/gamescope-keep-nested-windows-visible.patch" } ] }, @@ -97,12 +117,76 @@ "release.json", "ptlinux/__init__.py", "ptlinux/__main__.py", + "ptlinux/application/__init__.py", + "ptlinux/application/common.py", + "ptlinux/application/commands/__init__.py", + "ptlinux/application/commands/health.py", + "ptlinux/application/commands/installation.py", + "ptlinux/application/commands/launcher_update.py", + "ptlinux/application/commands/play.py", + "ptlinux/application/commands/settings.py", "ptlinux/cli.py", + "ptlinux/diagnostics/__init__.py", + "ptlinux/diagnostics/crash_dumps.py", + "ptlinux/diagnostics/native_logs.py", + "ptlinux/game/__init__.py", + "ptlinux/game/arguments.py", + "ptlinux/game/compatibility/__init__.py", + "ptlinux/game/compatibility/assbin.py", + "ptlinux/game/depot/__init__.py", + "ptlinux/game/depot/catalogs.py", + "ptlinux/game/depot/filesystem.py", + "ptlinux/game/depot/integrity.py", + "ptlinux/game/depot/manifest.py", + "ptlinux/game/depot/models.py", + "ptlinux/game/depot/transfer.py", + "ptlinux/game/ptr_compat.py", + "ptlinux/gui_bootstrap.py", + "ptlinux/launcher_updates/__init__.py", + "ptlinux/launcher_updates/activation.py", + "ptlinux/launcher_updates/archive.py", + "ptlinux/launcher_updates/discovery.py", + "ptlinux/launcher_updates/models.py", + "ptlinux/launcher_updates/parsing.py", + "ptlinux/launcher_updates/rollback.py", + "ptlinux/launcher_updates/staging.py", "ptlinux/self_update.py", + "ptlinux/telemetry/__init__.py", + "ptlinux/telemetry/dependencies.py", + "ptlinux/telemetry/environment.py", + "ptlinux/telemetry/errors.py", + "ptlinux/telemetry/extended.py", + "ptlinux/telemetry/graphics.py", + "ptlinux/telemetry/identity.py", + "ptlinux/telemetry/payload.py", + "ptlinux/telemetry/privacy.py", + "ptlinux/telemetry/probes.py", + "ptlinux/telemetry/runtime.py", + "ptlinux/telemetry/service.py", + "ptlinux/telemetry/transport.py", + "ptlinux/telemetry_environment.py", + "ptlinux/ui/components/content_view.py", + "ptlinux/ui/components/preloader.py", + "ptlinux/ui/components/startup_preloader.py", + "ptlinux/ui/controllers/__init__.py", + "ptlinux/ui/controllers/content.py", + "ptlinux/ui/controllers/events.py", + "ptlinux/ui/controllers/installation.py", + "ptlinux/ui/controllers/launcher_update.py", + "ptlinux/ui/controllers/operation.py", + "ptlinux/ui/controllers/play.py", + "ptlinux/ui/dialogs/debug.py", + "ptlinux/ui/dialogs/game_preloader.py", + "ptlinux/ui/viewmodels/debug.py", "ptlinux/update_resolver.py", - "ptlinux/launcher-update-public.pem" + "ptlinux/launcher-update-public.pem", + "ptlinux/native/ptr-bcrypt-proxy.dll", + "ptdiag/__init__.py", + "ptdiag/assbin.py", + "ptdiag/logs.py" ], "exclusions": [ + ".artifacts", ".git", ".gitea", ".idea", @@ -133,8 +217,6 @@ "ptlinux/launcher-update-public.pem" ], "preflight": [ - "\"$PTLINUX_PYTHON\" -m compileall -q ptlinux ptdiag scripts release_tests", - "\"$PTLINUX_PYTHON\" -m unittest discover -s release_tests -p 'test_*.py'", - "\"$PTLINUX_PYTHON\" -m unittest discover -s tests -p 'test_*.py'" + "\"$PTLINUX_PYTHON\" -m compileall -q src/ptlinux src/ptdiag scripts" ] } diff --git a/release/notes/1.1.3.md b/release/notes/1.1.3.md new file mode 100644 index 0000000..a0da44d --- /dev/null +++ b/release/notes/1.1.3.md @@ -0,0 +1,5 @@ +- Improved game-window startup handling and focus tracking. +- Fixed Gamescope cleanup after game exit or interrupted startup. +- Corrected exit reporting when Gamescope closes without a recorded game status. +- Kept the launcher visible when game-window monitoring is unavailable. +- Added bounded local window diagnostics and preserved shutdown evidence in noisy logs. diff --git a/src/ptlinux/__init__.py b/src/ptlinux/__init__.py index cb922a9..c4a1af6 100644 --- a/src/ptlinux/__init__.py +++ b/src/ptlinux/__init__.py @@ -4,4 +4,4 @@ from .paths import AppPaths __all__ = ["AppPaths", "__version__"] -__version__ = "1.1.2" +__version__ = "1.1.3" diff --git a/src/ptlinux/application/commands/play.py b/src/ptlinux/application/commands/play.py index ff175c4..10ef418 100644 --- a/src/ptlinux/application/commands/play.py +++ b/src/ptlinux/application/commands/play.py @@ -31,6 +31,7 @@ from ...umu import UmuBootstrapResult from ...window_diagnostics import ( WINDOW_DIAGNOSTICS_ENV, WINDOW_DIAGNOSTICS_SESSION_ENV, + WindowDiagnosticLog, ) @@ -82,6 +83,11 @@ def play_locked( and bool(getattr(telemetry, "enabled", True)) ) launch_session_id = uuid4().hex if window_diagnostics_active else None + window_diagnostic_log = ( + WindowDiagnosticLog(paths.logs / f"windows-{launch_session_id}.jsonl", launch_session_id) + if launch_session_id is not None + else None + ) launch_session_metadata = ( {"launch_session_id": launch_session_id} if launch_session_id is not None @@ -218,6 +224,7 @@ def play_locked( # CommandRunner keeps a bounded output tail. Preserve readiness here so a # long, noisy session cannot evict the evidence and become a false failure. observed_readiness_signal: str | None = None + observed_gamescope_exit: dict[str, object] = {} launch_success_reported = False reported_runtime_warnings: set[str] = set() if telemetry is not None and not dry_run: @@ -245,6 +252,11 @@ def play_locked( """Timestamp launch success when the supervisor first confirms readiness.""" nonlocal observed_readiness_signal, launch_success_reported + if "gamescope exited without a recorded game status" in line.casefold(): + # Retain supervisor evidence even if noisy child cleanup later evicts + # this line from CommandRunner's bounded stderr capture. + with launch_success_lock: + observed_gamescope_exit.update(gamescope_exit_summary(line)) presentation_failure = api._gamescope_presentation_failure(line) if telemetry is not None and not dry_run and presentation_failure is not None: with launch_success_lock: @@ -271,6 +283,9 @@ def play_locked( diagnostic_session_id = window_diagnostic.get("session_id") if launch_session_id is None or diagnostic_session_id != launch_session_id: return line + if window_diagnostic_log is not None: + with launch_success_lock: + window_diagnostic_log.record(window_diagnostic) if telemetry is not None and window_diagnostics_active: with launch_success_lock: telemetry.send_event( @@ -386,6 +401,7 @@ def play_locked( game_started_ns, ) runtime_issues = api._runtime_issue_summary(outcome.stderr) + runtime_issues.update(observed_gamescope_exit) retained_readiness_signal = api._game_readiness_signal(outcome.stderr) game_readiness_signal = observed_readiness_signal or retained_readiness_signal game_window_ready = game_readiness_signal is not None @@ -400,7 +416,8 @@ def play_locked( ) if recommended_comparison is not None: runtime_issues["recommended_one_variable_comparison"] = recommended_comparison - clean_exit = outcome.returncode == 0 and current_game_crash is None + game_status_missing = runtime_issues.get("gamescope_child_status_missing") is True + clean_exit = outcome.returncode == 0 and current_game_crash is None and not game_status_missing session_issue = game_log_issue_reason(current_game_log) launch_evidence = game_readiness_signal if launch_evidence is None and current_game_log is not None: @@ -432,6 +449,11 @@ def play_locked( "current_game_log": current_game_log, "current_game_crash": current_game_crash, "runtime_issues": runtime_issues, + "window_diagnostics_log": ( + str(window_diagnostic_log.path) + if window_diagnostic_log is not None and window_diagnostic_log.path.is_file() + else None + ), } game_runtime_seconds = max(0.0, time.monotonic() - game_started_at) if telemetry is not None and not dry_run: @@ -449,8 +471,16 @@ def play_locked( "session_duration_class": api._session_duration_class(game_runtime_seconds), "runtime": selection.kind, "realm": start_option.name, - **api._game_exit_metadata(outcome.returncode, selection.kind), + **api._game_exit_metadata( + outcome.returncode, "gamescope" if game_status_missing else selection.kind, + ), } + if game_status_missing: + common_game_metadata.update({ + "game_exit_status_available": False, + "exit_status_source": "gamescope_supervisor", + "gamescope_exit_code": runtime_issues.get("gamescope_exit_code"), + }) if not clean_exit or session_issue is not None: common_game_metadata["failure_reason"] = game_failure_reason( outcome, @@ -509,6 +539,8 @@ def play_locked( if current_game_crash is not None else "Game session timed out" if outcome.timed_out + else gamescope_exit_message(runtime_issues) + if game_status_missing else f"Game client exited with status {outcome.returncode}" ), metadata={ @@ -531,6 +563,8 @@ def play_locked( exit_message = ( "Game client exited after logging Direct3D resource exhaustion" ) + elif game_status_missing: + exit_message = gamescope_exit_message(runtime_issues) elif common_game_metadata.get("signal_inferred") is True: signal_wrapper = ( "Proton" if selection.kind == "proton7" else "UMU" @@ -580,7 +614,9 @@ def play_locked( runtime_issues, current_game_crash, ) - return 0 if outcome.returncode == 0 and current_game_crash is None else 1 + if not clean_exit and report["window_diagnostics_log"] is not None: + print(f"Window diagnostics: {report['window_diagnostics_log']}", file=sys.stderr) + return 0 if clean_exit else 1 def session_duration_class(duration_seconds: float) -> str: @@ -686,6 +722,11 @@ def game_failure_reason( return "client_crash_dump" if outcome.timed_out: return "session_timeout" + if ( + exit_metadata.get("game_exit_status_available") is False + or "gamescope exited without a recorded game status" in outcome.stderr.casefold() + ): + return "gamescope_game_status_missing" if "signal" in exit_metadata: return "process_signal" return "nonzero_exit" @@ -923,6 +964,20 @@ def crash_dump_telemetry_summary(report: dict[str, object]) -> dict[str, object] return {key: report[key] for key in keys if key in report} +def gamescope_exit_summary(stderr: str) -> dict[str, object]: + folded = stderr.casefold() + gamescope_status = re.search( + r"gamescope exited without a recorded game status \(compositor status (-?\d{1,3})\)", + folded, + ) + return { + "gamescope_child_status_missing": ( + "gamescope exited without a recorded game status" in folded + ), + "gamescope_exit_code": int(gamescope_status[1]) if gamescope_status is not None else None, + } + + def runtime_issue_summary(stderr: str) -> dict[str, object]: folded = stderr.casefold() futex_message = "the futex facility returned an unexpected error code" @@ -965,9 +1020,7 @@ def runtime_issue_summary(stderr: str) -> dict[str, object]: "gamescope_forced_cleanup": ( "gamescope did not stop gracefully; forcing cleanup" in folded ), - "gamescope_child_status_missing": ( - "gamescope exited without a recorded game status" in folded - ), + **gamescope_exit_summary(stderr), "proton_game_drive_warning": "unable to use parent for game drive" in folded, "steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded, "dbghelp_symdeferred_assertion_count": folded.count(dbghelp_assertion), @@ -995,6 +1048,9 @@ def game_window_was_ready(stderr: str) -> bool: def game_readiness_signal(output: str) -> str | None: folded = output.casefold() + # Older supervisors used a readiness prefix without observing a window. + # Preserve their UI fallback without treating it as successful presentation. + folded = folded.replace("game window ready: unmanaged ", "unmonitored game process: ") for signal_name, marker in ( ("window_ready", "game window ready:"), ("rendered_frame", "rendered game frame:"), @@ -1035,6 +1091,14 @@ def window_diagnostic_severity(diagnostic: dict[str, object]) -> str: return "debug" +def gamescope_exit_message(runtime_issues: dict[str, object]) -> str: + message = "Game session ended before the game reported its exit status" + status = runtime_issues.get("gamescope_exit_code") + if isinstance(status, int): + message += f" (Gamescope status {status})" + return message + + def print_game_outcome( outcome: CommandOutcome, current_game_log: dict[str, object] | None, @@ -1063,6 +1127,8 @@ def print_game_outcome( "exhaustion.", file=sys.stderr, ) + elif runtime_issues.get("gamescope_child_status_missing") is True: + print(gamescope_exit_message(runtime_issues) + ".", file=sys.stderr) elif outcome.returncode == 0: print("Game client exited normally.") elif outcome.returncode is None: diff --git a/src/ptlinux/game/arguments.py b/src/ptlinux/game/arguments.py new file mode 100644 index 0000000..e3d6860 --- /dev/null +++ b/src/ptlinux/game/arguments.py @@ -0,0 +1,53 @@ +"""Parse the publisher's Windows argument string without invoking a shell.""" + +from __future__ import annotations + + +def split_windows_arguments(command_line: str) -> tuple[str, ...]: + """Decode MS C runtime argument quoting (the executable is supplied separately). + + Backslashes are literal except before double quotes; single quotes and shell + metacharacters are ordinary characters. Empty quoted arguments are retained. + See https://learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments + """ + + if any(ord(character) < 32 and character != "\t" for character in command_line): + raise ValueError("launch arguments contain control characters") + arguments: list[str] = [] + index = 0 + while index < len(command_line): + while index < len(command_line) and command_line[index] in " \t": + index += 1 + if index == len(command_line): + break + argument: list[str] = [] + quoted = False + while index < len(command_line): + character = command_line[index] + if character in " \t" and not quoted: + break + if character == "\\": + start = index + while index < len(command_line) and command_line[index] == "\\": + index += 1 + count = index - start + if index == len(command_line) or command_line[index] != '"': + argument.append("\\" * count) + continue + argument.append("\\" * (count // 2)) + if count % 2: + argument.append('"') + index += 1 + continue + character = '"' + if character == '"': + if quoted and command_line[index:index + 2] == '""': + argument.append('"') + index += 2 + continue + quoted = not quoted + else: + argument.append(character) + index += 1 + arguments.append("".join(argument)) + return tuple(arguments) diff --git a/src/ptlinux/gui_bootstrap.py b/src/ptlinux/gui_bootstrap.py new file mode 100644 index 0000000..685b2c0 --- /dev/null +++ b/src/ptlinux/gui_bootstrap.py @@ -0,0 +1,121 @@ +"""Recover a usable GUI interpreter and report missing Tk without a Tk window.""" + +from __future__ import annotations + +from importlib import import_module +import os +from pathlib import Path +import shutil +import subprocess +import sys +from typing import Sequence + + +class GuiDependencyError(RuntimeError): + """The current Python installation cannot provide the dashboard toolkit.""" + + +def tkinter_path() -> str | None: + """Check the Python package, extension, and Tcl data without opening a display.""" + + try: + tk = import_module("tkinter") + import_module("tkinter.ttk") + import_module("tkinter.filedialog") + tk.Tcl() + return tk.__file__ or "available" + except (ImportError, OSError, RuntimeError): + return None + except Exception as exc: + # TclError is only available after a successful tkinter import. + if type(exc).__name__ == "TclError": + return None + raise + + +def recover_gui_python(arguments: Sequence[str]) -> bool: + """Use the distribution's Python if a PATH/venv Python has no usable Tk.""" + + if tkinter_path() is not None: + return True + if os.environ.get("PTLINUX_PYTHON"): + return False + candidate = Path("/usr/bin/python3") + if not candidate.is_file() or os.environ.get("PTLINUX_GUI_PYTHON_RETRIED") == "1": + return False + # A venv may share the same binary but have different package search paths. + if str(candidate) == sys.executable and sys.prefix == sys.base_prefix: + return False + probe = ( + "import sys; assert sys.version_info >= (3, 11); " + "import tkinter; from tkinter import ttk, filedialog; tkinter.Tcl()" + ) + try: + result = subprocess.run( + [str(candidate), "-I", "-B", "-c", probe], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=5, check=False, + ) + if result.returncode != 0: + return False + environment = os.environ.copy() + # Preserve the selected release when restarting from python -m or a + # desktop entry. Ignore Python-home settings belonging to the failed SDK. + environment.pop("PYTHONHOME", None) + environment["PYTHONPATH"] = str(Path(__file__).resolve().parent.parent) + environment["PYTHONSAFEPATH"] = "1" + environment["PYTHONDONTWRITEBYTECODE"] = "1" + environment["PTLINUX_GUI_PYTHON_RETRIED"] = "1" + os.execve(str(candidate), [str(candidate), "-B", "-P", "-m", "ptlinux", *arguments], environment) + except (OSError, subprocess.TimeoutExpired): + return False + return False + + +def missing_tk_message() -> str: + from .system_identity import detect_distribution_id + + distribution = detect_distribution_id() + if distribution in {"ubuntu", "debian", "linuxmint", "pop", "elementary", "zorin"}: + repair = "Install python3-tk with your package manager (sudo apt install python3-tk)." + elif distribution in {"fedora", "nobara"}: + repair = "Install python3-tkinter with your package manager." + elif distribution in {"arch", "cachyos", "endeavouros", "garuda", "manjaro"}: + repair = "Install tk with your package manager." + elif distribution == "steamos": + repair = "Use a Python installation that includes tkinter and Tcl/Tk." + else: + repair = "Install the Tk package matching your Python version with your distribution's package manager." + return ( + f"Python Tk is unavailable for {sys.executable} (Python {sys.version_info.major}.{sys.version_info.minor}). " + f"{repair} If you selected a custom Python, install its matching Tk support or " + "start with PTLINUX_PYTHON=/usr/bin/python3. " + "The pristontale-eu command-line interface remains available." + ) + + +def show_gui_dependency_error(message: str) -> None: + """Give desktop launches an actionable message even when Tk cannot import.""" + + if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): + return + for name, arguments in ( + ("kdialog", ["--title", "PristonTale EU", "--error", message]), + ("zenity", ["--error", "--title=PristonTale EU", "--text", message]), + ("notify-send", ["--urgency=critical", "PristonTale EU", message]), + ): + executable = shutil.which(name) + if executable is None: + continue + try: + result = subprocess.run( + [executable, *arguments], stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=60, check=False, + ) + if result.returncode == 0: + return + except subprocess.TimeoutExpired: + return + except OSError: + continue diff --git a/src/ptlinux/presentation/game_window.py b/src/ptlinux/presentation/game_window.py index 597c7f8..bb4f5ed 100644 --- a/src/ptlinux/presentation/game_window.py +++ b/src/ptlinux/presentation/game_window.py @@ -2605,7 +2605,7 @@ def run_game_window( raise if mapper is None: print( - f"Game window ready: unmanaged {window_title}", + f"Game window monitoring unavailable; process started: {window_title}", file=sys.stderr, flush=True, ) diff --git a/src/ptlinux/presentation/gamescope.py b/src/ptlinux/presentation/gamescope.py index ba45938..a5d8a0c 100644 --- a/src/ptlinux/presentation/gamescope.py +++ b/src/ptlinux/presentation/gamescope.py @@ -336,8 +336,10 @@ def _supervised_command( str(command[0]), "--backend", backend, - "--keep-alive", ] + # The child records its status before exiting. Leave Gamescope's normal + # child-exit shutdown enabled so a reporting failure (for example ENOSPC) + # cannot strand an empty compositor with no status for us to observe. if backend == "sdl" or force_composition: result.append("--force-composition") result.extend(map(str, command[1:])) @@ -611,20 +613,19 @@ def _wait_for_optimized_gamescope( child_status = ( _read_child_status(status_file) if status_file is not None else None ) - if status_file is not None and child_status is None: + if child_status is None: # A compositor can exit with 0 when its host window closes, # even though its nested game was killed. Only the game's # recorded status can establish a clean game exit. print( - "Gamescope exited without a recorded game status; " - "the game session was interrupted.", + "Gamescope exited without a recorded game status " + f"(compositor status {gamescope_status}); " + "the game's exit result is unknown.", file=sys.stderr, flush=True, ) return _process_exit_status(gamescope_status) or 1 - return _process_exit_status( - gamescope_status if child_status is None else child_status - ) + return _process_exit_status(child_status) time.sleep(CHILD_STATUS_POLL_SECONDS) @@ -880,104 +881,104 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: diagnostics.close(process_started=False, backend=backend) raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc - if kde_wayland_xwayland_fallback: - print( - "KDE Wayland compatibility mode active: Gamescope is using its " - "SDL host window through XWayland to avoid KWin presentation, " - "fullscreen, and fractional-scaling failures.", - file=sys.stderr, - flush=True, - ) - elif cinnamon_wayland_xwayland_fallback: - print( - "Cinnamon Wayland compatibility mode active: Gamescope is using " - "its resizable SDL host through XWayland so Muffin provides the " - "native minimize, maximize, and close controls.", - file=sys.stderr, - flush=True, - ) - if backend == "wayland" and not force_composition: - message = ( - "Gamescope resize optimization active: native Wayland direct " - "presentation keeps live host-window changes off the game surface." - ) - if using_bundled_gamescope: - message += ( - " Launcher-local lazy-output, live-resize, letterbox backing, " - "decoration-input, and login-window visibility fixes are active." - ) - print(message, file=sys.stderr, flush=True) - elif backend == "wayland" and kde_session: - print( - "KDE Wayland compatibility mode active: Gamescope is using its " - "composed native fallback because no XWayland display is available.", - file=sys.stderr, - flush=True, - ) - elif backend == "wayland": - print( - "Warning: this Gamescope build lacks safe direct Wayland letterbox " - "backing. Forced composition prevents flicker, but live resizing may " - "pause; install Gamescope 3.16.28 or newer for direct presentation.", - file=sys.stderr, - flush=True, - ) - elif mapper is not None: - try: - if diagnostic_sink is None: - increments = _tune_process_window( - mapper, - process, - width, - height, - host_focus_publisher=host_focus_publisher, - ) - else: - increments = _tune_process_window( - mapper, - process, - width, - height, - host_focus_publisher=host_focus_publisher, - diagnostics=diagnostic_sink, - ) - except Exception as exc: - increments = None - diagnostics.record( - "host_scaling_configuration_failed", - error_type=type(exc).__name__, - ) - print( - f"Warning: Gamescope resize tuning failed: {exc}; " - "continuing normally.", - file=sys.stderr, - flush=True, - ) - if increments is not None: - print( - "Gamescope resize optimization active: the host window remains " - "composited and size changes use " - f"{increments[0]}x{increments[1]} pixel steps.", - file=sys.stderr, - flush=True, - ) - elif process.poll() is None: - diagnostics.record("host_scaling_unavailable") - print( - "Warning: Gamescope's host window could not be resize-tuned; " - "continuing normally.", - file=sys.stderr, - flush=True, - ) - else: - diagnostics.record("host_x11_monitor_unavailable") - print( - "Gamescope is using its SDL presentation fallback; host X11 resize " - "tuning is unavailable.", - file=sys.stderr, - flush=True, - ) try: + if kde_wayland_xwayland_fallback: + print( + "KDE Wayland compatibility mode active: Gamescope is using its " + "SDL host window through XWayland to avoid KWin presentation, " + "fullscreen, and fractional-scaling failures.", + file=sys.stderr, + flush=True, + ) + elif cinnamon_wayland_xwayland_fallback: + print( + "Cinnamon Wayland compatibility mode active: Gamescope is using " + "its resizable SDL host through XWayland so Muffin provides the " + "native minimize, maximize, and close controls.", + file=sys.stderr, + flush=True, + ) + if backend == "wayland" and not force_composition: + message = ( + "Gamescope resize optimization active: native Wayland direct " + "presentation keeps live host-window changes off the game surface." + ) + if using_bundled_gamescope: + message += ( + " Launcher-local lazy-output, live-resize, letterbox backing, " + "decoration-input, and login-window visibility fixes are active." + ) + print(message, file=sys.stderr, flush=True) + elif backend == "wayland" and kde_session: + print( + "KDE Wayland compatibility mode active: Gamescope is using its " + "composed native fallback because no XWayland display is available.", + file=sys.stderr, + flush=True, + ) + elif backend == "wayland": + print( + "Warning: this Gamescope build lacks safe direct Wayland letterbox " + "backing. Forced composition prevents flicker, but live resizing may " + "pause; install Gamescope 3.16.28 or newer for direct presentation.", + file=sys.stderr, + flush=True, + ) + elif mapper is not None: + try: + if diagnostic_sink is None: + increments = _tune_process_window( + mapper, + process, + width, + height, + host_focus_publisher=host_focus_publisher, + ) + else: + increments = _tune_process_window( + mapper, + process, + width, + height, + host_focus_publisher=host_focus_publisher, + diagnostics=diagnostic_sink, + ) + except Exception as exc: + increments = None + diagnostics.record( + "host_scaling_configuration_failed", + error_type=type(exc).__name__, + ) + print( + f"Warning: Gamescope resize tuning failed: {exc}; " + "continuing normally.", + file=sys.stderr, + flush=True, + ) + if increments is not None: + print( + "Gamescope resize optimization active: the host window remains " + "composited and size changes use " + f"{increments[0]}x{increments[1]} pixel steps.", + file=sys.stderr, + flush=True, + ) + elif process.poll() is None: + diagnostics.record("host_scaling_unavailable") + print( + "Warning: Gamescope's host window could not be resize-tuned; " + "continuing normally.", + file=sys.stderr, + flush=True, + ) + else: + diagnostics.record("host_x11_monitor_unavailable") + print( + "Gamescope is using its SDL presentation fallback; host X11 resize " + "tuning is unavailable.", + file=sys.stderr, + flush=True, + ) return _wait_for_optimized_gamescope( process, status_file, diff --git a/src/ptlinux/ui/controllers/operation.py b/src/ptlinux/ui/controllers/operation.py index 7032dff..b6a4b7d 100644 --- a/src/ptlinux/ui/controllers/operation.py +++ b/src/ptlinux/ui/controllers/operation.py @@ -208,6 +208,10 @@ def start_operation_process( ) events.put(("game_started", game_client_id)) if game_client_id is not None and line.startswith( + ("Game window monitoring unavailable; process started:", "Game window ready: unmanaged ") + ): + events.put(("game_window_unmonitored", game_client_id)) + elif game_client_id is not None and line.startswith( ("Game window ready:", "Rendered game frame:") ): events.put(("game_window_ready", game_client_id)) diff --git a/src/ptlinux/ui/dashboard.py b/src/ptlinux/ui/dashboard.py index d227cba..504f6c3 100644 --- a/src/ptlinux/ui/dashboard.py +++ b/src/ptlinux/ui/dashboard.py @@ -1012,6 +1012,7 @@ def run_dashboard(paths: AppPaths) -> None: ready: bool, returncode: int | None = None, cancelled: bool = False, + window_unmonitored: bool = False, ) -> None: nonlocal active_game_launch_id nonlocal operation_active, game_window_active @@ -1037,6 +1038,9 @@ def run_dashboard(paths: AppPaths) -> None: status.set("Ready for another account.") progress_text.set("Click Play to open another game window.") dashboard_chrome.minimize() + elif window_unmonitored: + status.set(f"Client {client_id} started; window readiness could not be checked.") + progress_text.set("Select the game window to continue, or see the activity log for details.") elif cancelled: root.lift() status.set("Game launch cancelled.") @@ -1166,6 +1170,19 @@ def run_dashboard(paths: AppPaths) -> None: game_window_active = True release_game_launch_controls(value, ready=True) + def handle_game_window_unmonitored_event(value: object) -> None: + nonlocal game_window_active + if not ( + isinstance(value, int) + and not game_launch_supervisors.is_cancel_requested(value) + ): + return + if game_clients.mark_running(value): + game_window_active = True + # Release the modal preloader without claiming a visible window or + # minimizing the dashboard before the user can inspect startup errors. + release_game_launch_controls(value, ready=False, window_unmonitored=True) + def handle_game_done_event(value: object) -> None: nonlocal game_window_active if not ( @@ -1366,6 +1383,7 @@ def run_dashboard(paths: AppPaths) -> None: "game_operation_status": handle_game_operation_status_event, "game_started": handle_game_started_event, "game_window_ready": handle_game_window_ready_event, + "game_window_unmonitored": handle_game_window_unmonitored_event, "game_done": handle_game_done_event, "broadcast": handle_broadcast_event, "server_status_check": handle_server_status_event, diff --git a/src/ptlinux/window_diagnostics.py b/src/ptlinux/window_diagnostics.py index f719ecb..1b0b63f 100644 --- a/src/ptlinux/window_diagnostics.py +++ b/src/ptlinux/window_diagnostics.py @@ -7,6 +7,7 @@ from collections.abc import Callable, Mapping, Sequence import json import math import os +from pathlib import Path import re import sys import time @@ -33,6 +34,46 @@ def window_diagnostics_enabled( return env.get(WINDOW_DIAGNOSTICS_ENV, "").strip().casefold() in _TRUE_VALUES +class WindowDiagnosticLog: + """Retain bounded supervisor evidence independently of remote telemetry.""" + + def __init__(self, path: Path, session_id: str) -> None: + self.path = path + self.session_id = session_id + self._samples: Counter[str] = Counter() + self._summaries: set[str] = set() + self._failed = False + + def record(self, payload: dict[str, object]) -> None: + if self._failed or payload.get("session_id") != self.session_id: + return + component = payload.get("component") + if not isinstance(component, str) or component not in WINDOW_DIAGNOSTIC_COMPONENTS: + return + if payload.get("diagnostic") == "session_summary": + if component in self._summaries: + return + self._summaries.add(component) + elif self._samples[component] >= WINDOW_DIAGNOSTIC_EVENT_LIMIT: + return + else: + self._samples[component] += 1 + try: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + if len(encoded.encode("utf-8")) > WINDOW_DIAGNOSTIC_LINE_LIMIT: + return + self.path.parent.mkdir(parents=True, exist_ok=True) + descriptor = os.open( + self.path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600, + ) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(encoded + "\n") + except (OSError, TypeError, ValueError): + # Logging must never interrupt the game or repeatedly fill stderr. + self._failed = True + print("Warning: window diagnostics could not be saved locally.", file=sys.stderr) + + class WindowDiagnosticRecorder: """Emit a few samples while retaining aggregate counts for session shutdown.""" diff --git a/tests/test_dashboard_operation_controller.py b/tests/test_dashboard_operation_controller.py new file mode 100644 index 0000000..dfe55b4 --- /dev/null +++ b/tests/test_dashboard_operation_controller.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import queue +import subprocess +import unittest +from unittest.mock import MagicMock + +from ptlinux.ui.controllers.operation import start_operation_process +from ptlinux.ui.viewmodels.downloader import ProgressCoalescer + + +class _InlineThread: + def __init__(self, *, target, name: str, daemon: bool) -> None: + self._target = target + self.name = name + self.daemon = daemon + + def start(self) -> None: + self._target() + + +class _Process: + def __init__(self, lines: list[str], returncode: int = 0, pid: int = 4321) -> None: + self.stdout = iter(lines) + self.returncode = returncode + self.pid = pid + + def wait(self) -> int: + return self.returncode + + +def _drain(events: queue.Queue[tuple[str, object]]) -> list[tuple[str, object]]: + values: list[tuple[str, object]] = [] + while not events.empty(): + values.append(events.get_nowait()) + return values + + +class DashboardOperationControllerTests(unittest.TestCase): + def test_routes_status_progress_output_and_completion(self) -> None: + events: queue.Queue[tuple[str, object]] = queue.Queue() + progress = ProgressCoalescer() + registry = MagicMock() + process = _Process( + [ + 'STATUS {"step": 1, "total": 3, "message": "Starting"}\n', + 'PROGRESS {"phase": "downloading", "bytes_completed": 8}\n', + "plain output\n", + "STATUS invalid\n", + ], + returncode=7, + ) + seen_process_args: list[tuple[list[str], dict[str, object]]] = [] + + def process_factory(command: list[str], **kwargs: object) -> _Process: + seen_process_args.append((command, kwargs)) + return process + + start_operation_process( + ["launcher", "update"], + events=events, + progress_updates=progress, + game_launch_supervisors=registry, + child_environment=lambda: {"ROLE": "operation"}, + interrupt_supervisor=MagicMock(), + process_factory=process_factory, + thread_factory=_InlineThread, + ) + + self.assertEqual( + _drain(events), + [ + ( + "operation_status", + {"step": 1, "total": 3, "message": "Starting"}, + ), + ("output", "plain output\n"), + ("output", "STATUS invalid\n"), + ("done", 7), + ], + ) + self.assertEqual( + progress.take(), + {"phase": "downloading", "bytes_completed": 8}, + ) + self.assertEqual(seen_process_args[0][0], ["launcher", "update"]) + self.assertEqual(seen_process_args[0][1]["env"], {"ROLE": "operation"}) + self.assertEqual(seen_process_args[0][1]["stdout"], subprocess.PIPE) + self.assertEqual(seen_process_args[0][1]["stderr"], subprocess.STDOUT) + + def test_routes_game_readiness_and_cancellation_race(self) -> None: + events: queue.Queue[tuple[str, object]] = queue.Queue() + registry = MagicMock() + registry.is_cancel_requested.return_value = False + registry.attach.return_value = True + process = _Process( + [ + "Game window created; preparing it\n", + "Game window ready: PristonTale EU\n", + ], + ) + interrupt = MagicMock(return_value=True) + + start_operation_process( + ["launcher", "play"], + events=events, + progress_updates=ProgressCoalescer(), + game_launch_supervisors=registry, + child_environment=dict, + interrupt_supervisor=interrupt, + game_client_id=3, + process_factory=lambda *_args, **_kwargs: process, + thread_factory=_InlineThread, + ) + + kinds = [kind for kind, _value in _drain(events)] + self.assertEqual( + kinds, + [ + "game_supervisor_started", + "output", + "output", + "game_operation_status", + "game_started", + "output", + "game_window_ready", + "game_done", + ], + ) + registry.attach.assert_called_once_with(3, process) + registry.detach.assert_called_once_with(3, process) + interrupt.assert_called_once_with(process) + + def test_unmonitored_process_releases_controls_without_a_window_ready_event(self) -> None: + for line in ( + "Game window monitoring unavailable; process started: PristonTale EU\n", + "Game window ready: unmanaged PristonTale EU\n", + ): + with self.subTest(line=line): + events: queue.Queue[tuple[str, object]] = queue.Queue() + registry = MagicMock() + registry.is_cancel_requested.return_value = False + registry.attach.return_value = False + process = _Process([line], returncode=1) + start_operation_process( + ["launcher", "play"], events=events, + progress_updates=ProgressCoalescer(), game_launch_supervisors=registry, + child_environment=dict, interrupt_supervisor=MagicMock(), game_client_id=3, + process_factory=lambda *_args, **_kwargs: process, thread_factory=_InlineThread, + ) + result = _drain(events) + self.assertIn(("game_window_unmonitored", 3), result) + self.assertNotIn(("game_window_ready", 3), result) + self.assertEqual(result[-1], ("game_done", (3, 1))) + + def test_cancellation_before_process_start_skips_the_child(self) -> None: + events: queue.Queue[tuple[str, object]] = queue.Queue() + registry = MagicMock() + registry.is_cancel_requested.return_value = True + process_factory = MagicMock() + + start_operation_process( + ["launcher", "play"], + events=events, + progress_updates=ProgressCoalescer(), + game_launch_supervisors=registry, + child_environment=dict, + interrupt_supervisor=MagicMock(), + game_client_id=4, + process_factory=process_factory, + thread_factory=_InlineThread, + ) + + self.assertEqual( + _drain(events), + [ + ( + "output", + "[Client 4] Launch cancelled before the game supervisor started.\n", + ), + ("game_done", (4, 130)), + ], + ) + process_factory.assert_not_called() + + def test_process_start_failure_uses_the_matching_completion_event(self) -> None: + for client_id, done_event in ((None, ("done", 1)), (5, ("game_done", (5, 1)))): + with self.subTest(client_id=client_id): + events: queue.Queue[tuple[str, object]] = queue.Queue() + registry = MagicMock() + registry.is_cancel_requested.return_value = False + + def fail_to_start(*_args: object, **_kwargs: object) -> _Process: + raise OSError("missing executable") + + start_operation_process( + ["missing"], + events=events, + progress_updates=ProgressCoalescer(), + game_launch_supervisors=registry, + child_environment=dict, + interrupt_supervisor=MagicMock(), + game_client_id=client_id, + process_factory=fail_to_start, + thread_factory=_InlineThread, + ) + + emitted = _drain(events) + self.assertIn(done_event, emitted) + self.assertIn("Could not start operation", str(emitted[0][1])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_game_launch_compatibility.py b/tests/test_game_launch_compatibility.py new file mode 100644 index 0000000..5094c66 --- /dev/null +++ b/tests/test_game_launch_compatibility.py @@ -0,0 +1,594 @@ +from dataclasses import asdict, replace +import ctypes +import json +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from ptlinux.application.commands.play import ( + ensure_portable_umu_for_play, + play_locked, + runtime_issue_summary, +) +from ptlinux.cli import _load_or_default_settings +from ptlinux.depot import DepotError, DepotLaunchCatalog, DepotStartOption +from ptlinux.game.arguments import split_windows_arguments +from ptlinux.game.depot.catalogs import select_depot_start_option +from ptlinux.game.depot.manifest import parse_start_options +from ptlinux.game.runtime import ( + RuntimeErrorPT, + RuntimeSelection, + game_runtime_ready, + prepare_game_runtime, + run_game, + run_runtime_prefix_command, + runtime_settings_for_realm, + select_runtime, +) +from ptlinux.game.runtime_view import ( + KNOWN_SOURCE_SHA256, + OverlayError, + _scene_action, + prepare_runtime_view, + select_scene_action, +) +from ptlinux.paths import AppPaths +from ptlinux.presentation import game_window +from ptlinux.settings import ( + Settings, + ensure_settings, + load_settings, + reset_settings, + save_settings, + update_settings, +) +from ptlinux.telemetry.runtime import _selected_prefix, _wine_command + + +class WindowsArgumentTests(unittest.TestCase): + def test_preserves_windows_paths_quotes_empty_arguments_and_shell_literals(self) -> None: + arguments = ( + "PTEU_REBORN", r"C:\game\data", "Babel's Realm", "", "a b", + 'a"b', "trailing\\", r"$(touch /tmp/should-not-exist)", "a;b", "%TEMP%", + ) + self.assertEqual(split_windows_arguments(subprocess.list2cmdline(arguments)), arguments) + + def test_windows_quoting_examples(self) -> None: + for command, expected in ( + ('"a b c" d e', ("a b c", "d", "e")), + (r'a\\\b d"e f"g h', (r"a\\\b", "de fg", "h")), + (r'a\\\"b c d', ('a\\"b', "c", "d")), + (r'a\\\\"b c" d e', (r"a\\b c", "d", "e")), + ('a"b"" c d', ('ab" c d',)), + ("Babel's Realm", ("Babel's", "Realm")), + (" \t ", ()), + ): + with self.subTest(command=command): + self.assertEqual(split_windows_arguments(command), expected) + + def test_manifest_allows_no_arguments_and_keeps_publisher_default(self) -> None: + options, default = parse_start_options( + [ + {"Name": "Babel", "Path": "Game.exe", "Arguments": ""}, + {"Name": "New Realm", "Path": "New.exe"}, + ], + "New Realm", + ) + self.assertEqual(options[0].arguments, "") + self.assertEqual(options[1].arguments, "") + catalog = DepotLaunchCatalog(10, options, default) + self.assertEqual(select_depot_start_option(catalog, "Removed Realm"), options[1]) + + +class RuntimeDefaultMigrationTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache"))) + self.paths.config.mkdir(parents=True) + + def write_settings(self, value: dict[str, object]) -> None: + self.paths.settings_file.write_text(json.dumps(value), encoding="utf-8") + + def test_new_and_reset_installations_use_ge_proton(self) -> None: + self.assertEqual(Settings().runtime, "ge-proton") + self.assertEqual(load_settings(self.paths).runtime, "ge-proton") + self.assertFalse(self.paths.settings_file.exists()) + self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton") + save_settings(self.paths, Settings(runtime="proton7")) + self.assertEqual(reset_settings(self.paths).runtime, "ge-proton") + self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton") + + def test_all_old_profiles_migrate_without_changing_other_preferences(self) -> None: + for runtime in ("auto", "proton7", "proton7-container", "umu", "ge-proton"): + with self.subTest(runtime=runtime): + old_settings = Settings( + runtime=runtime, + proton_path="/Steam Library/Proton 7.0/proton", + renderer="wined3d", + proton_sync="esync", + telemetry_enabled=False, + game_crash_dump_upload_enabled=False, + game_source="/Games/Publisher", + ui_scale="150", + borderless=True, + allow_window_resizing=True, + suppress_unresponsive_warnings=False, + download_speed_limit_mbps=4.0, + low_bandwidth_mode=True, + update_channel="prerelease", + ) + self.write_settings({ + **asdict(old_settings), "schema": 1, + "title_frame_default_migrated": True, + "future_setting": {"keep": True}, + }) + before = self.paths.settings_file.read_bytes() + expected = replace(old_settings, runtime="ge-proton") + self.assertEqual(load_settings(self.paths), expected) + self.assertEqual(self.paths.settings_file.read_bytes(), before) + self.assertEqual(ensure_settings(self.paths), expected) + payload = json.loads(self.paths.settings_file.read_text()) + self.assertEqual(payload["runtime"], "ge-proton") + self.assertIs(payload["ge_proton_default_migrated"], True) + self.assertEqual(payload["future_setting"], {"keep": True}) + self.assertEqual(payload["schema"], 1) + self.assertEqual(self.paths.settings_file.stat().st_mode & 0o777, 0o600) + + def test_migration_is_persisted_once_and_later_manual_choices_survive(self) -> None: + self.write_settings({"runtime": "proton7", "ge_proton_default_migrated": False}) + self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton") + before = self.paths.settings_file.stat() + self.assertEqual(ensure_settings(self.paths).runtime, "ge-proton") + after = self.paths.settings_file.stat() + self.assertEqual((after.st_ino, after.st_mtime_ns), (before.st_ino, before.st_mtime_ns)) + for runtime in ("auto", "proton7", "proton7-container", "umu"): + with self.subTest(runtime=runtime): + update_settings(self.paths, runtime=runtime) + update_settings(self.paths, proton_sync="esync") + self.assertEqual(ensure_settings(self.paths).runtime, runtime) + self.assertEqual(load_settings(self.paths).runtime, runtime) + + def test_unrelated_update_migrates_the_runtime(self) -> None: + self.write_settings({"runtime": "umu"}) + settings = update_settings(self.paths, low_bandwidth_mode=True) + self.assertEqual(settings.runtime, "ge-proton") + self.assertTrue(settings.low_bandwidth_mode) + self.assertEqual(ensure_settings(self.paths), settings) + + def test_explicit_runtime_choice_during_first_save_is_preserved(self) -> None: + for full_save in (False, True): + with self.subTest(full_save=full_save): + self.write_settings({"runtime": "auto"}) + if full_save: + save_settings(self.paths, Settings(runtime="umu")) + else: + update_settings(self.paths, runtime="umu") + self.assertEqual(ensure_settings(self.paths).runtime, "umu") + + def test_migration_keeps_the_ptr_override_and_ignores_its_path_for_babel(self) -> None: + proton = self.root / "Steam/steamapps/common/Proton 7.0/proton" + proton.parent.mkdir(parents=True) + proton.touch() + for realm in ("PTR Babel", "Test Babel"): + with self.subTest(realm=realm): + self.write_settings({"runtime": "proton7", "realm": realm, "proton_path": str(proton)}) + settings = ensure_settings(self.paths) + self.assertEqual(settings.runtime, "ge-proton") + self.assertEqual(settings.realm, realm) + self.assertEqual(runtime_settings_for_realm(settings).runtime, "proton7") + with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"): + ptr = select_runtime(settings, {"HOME": str(self.root)}) + babel = select_runtime(replace(settings, realm="Babel"), {}) + self.assertEqual(ptr.kind, "proton7") + self.assertEqual(ptr.proton_path, str(proton)) + self.assertEqual(babel.kind, "ge-proton") + self.assertIsNone(babel.proton_path) + + def test_invalid_runtime_and_marker_are_rejected_without_rewriting(self) -> None: + for value in ( + {"runtime": "unsupported"}, {"runtime": None}, {"runtime": True}, + {"ge_proton_default_migrated": "true"}, {"ge_proton_default_migrated": 1}, + ): + with self.subTest(value=value): + self.write_settings(value) + before = self.paths.settings_file.read_bytes() + with self.assertRaises(ValueError): + ensure_settings(self.paths) + self.assertEqual(self.paths.settings_file.read_bytes(), before) + + def test_default_cannot_silently_fall_back_to_legacy_proton(self) -> None: + with ( + patch("ptlinux.game.runtime.find_umu_launcher", return_value=None), + patch("ptlinux.game.runtime.find_steam_proton7") as find_legacy, + ): + with self.assertRaisesRegex(RuntimeErrorPT, "umu-run was not found"): + select_runtime(Settings(), {}) + find_legacy.assert_not_called() + + def test_default_bootstrap_error_does_not_fall_back_to_proton7(self) -> None: + api = MagicMock() + api.provision_umu.side_effect = RuntimeError("network unavailable") + with self.assertRaisesRegex(RuntimeError, "portable UMU.*network unavailable"): + ensure_portable_umu_for_play(api, self.paths, Settings(), dry_run=False) + api.select_runtime.assert_not_called() + + def test_cli_migrates_existing_settings_and_dry_runs_remain_read_only(self) -> None: + self.write_settings({"runtime": "proton7"}) + before = self.paths.settings_file.read_bytes() + self.assertEqual(_load_or_default_settings(self.paths, persist=False).runtime, "ge-proton") + self.assertEqual(self.paths.settings_file.read_bytes(), before) + self.assertEqual(_load_or_default_settings(self.paths, persist=True).runtime, "ge-proton") + payload = json.loads(self.paths.settings_file.read_text()) + self.assertEqual(payload["runtime"], "ge-proton") + self.assertIs(payload["ge_proton_default_migrated"], True) + + +class LaunchCompatibilityTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache"))) + self.paths.runtime_view.mkdir(parents=True) + (self.paths.runtime_view / "Game.exe").touch() + self.runner = MagicMock(dry_run=False) + self.selection = RuntimeSelection("ge-proton", "/usr/bin/umu-run", None, None) + self.host_check = patch("ptlinux.game.runtime._require_container_host") + self.host_check.start() + self.addCleanup(self.host_check.stop) + + def test_recovers_legacy_renderer_option_and_persists_migration(self) -> None: + self.paths.config.mkdir(parents=True) + self.paths.settings_file.write_text(json.dumps({"proton_path": "PROTON_USE_WINED3D=1"})) + settings = ensure_settings(self.paths) + self.assertIsNone(settings.proton_path) + self.assertEqual(settings.renderer, "wined3d") + self.assertEqual(load_settings(self.paths), settings) + self.assertIsNone(json.loads(self.paths.settings_file.read_text())["proton_path"]) + with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"): + selection = select_runtime(settings, {}) + run_game(self.paths, settings, selection, self.runner, use_gamebox=False) + environment = self.runner.run.call_args.kwargs["env_overrides"] + self.assertEqual(environment["PROTON_USE_WINED3D"], "1") + self.assertIn("d3d9=b", environment["WINEDLLOVERRIDES"]) + + def test_does_not_execute_or_migrate_arbitrary_path_commands(self) -> None: + for value in ("PROTON_LOG=1", "env PROTON_USE_WINED3D=1 %command%"): + with self.subTest(value=value): + settings = Settings(runtime="auto", proton_path=value) + with self.assertRaisesRegex(ValueError, "filesystem path"): + save_settings(self.paths, settings) + with self.assertRaisesRegex(RuntimeErrorPT, "filesystem path"): + select_runtime(settings, {}) + + def test_existing_explicit_renderer_wins_over_misplaced_legacy_option(self) -> None: + self.paths.config.mkdir(parents=True) + self.paths.settings_file.write_text(json.dumps({ + "proton_path": "PROTON_USE_WINED3D=1", "renderer": "dxvk", + })) + self.assertEqual(ensure_settings(self.paths).renderer, "dxvk") + + def test_proton_script_field_accepts_a_steam_proton_directory_with_spaces(self) -> None: + proton = self.root / "Steam/steamapps/common/Proton 7.0/proton" + proton.parent.mkdir(parents=True) + proton.touch() + selection = select_runtime(Settings(runtime="proton7", proton_path=str(proton.parent)), {}) + self.assertEqual(selection.proton_path, str(proton)) + + def test_auto_selects_ge_across_distributions_and_keeps_explicit_umu(self) -> None: + with patch("ptlinux.game.runtime.find_umu_launcher", return_value="/usr/bin/umu-run"): + for runtime in ("auto", "ge-proton"): + for distribution in ("ubuntu", "cachyos", "arch", "fedora"): + with self.subTest(runtime=runtime, distribution=distribution): + self.assertEqual(select_runtime(Settings(runtime=runtime), {}, distribution_id=distribution).kind, "ge-proton") + self.assertEqual(select_runtime(Settings(runtime="umu"), {}).kind, "umu") + + def test_future_publisher_scene_is_copied_unchanged_without_old_texture_repair(self) -> None: + source = self.paths.source_game + for relative, content in ( + ("Game.exe", b"new executable"), + ("game.dll", b"new dll"), + ("game.ini", b"[Screen]\nWindowed=1\n"), + ("game/maps/WhisperingVale.rovug", b"future publisher scene format"), + ): + path = source / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + with patch("ptlinux.game.runtime_view.normalize_wrapped_assbin_textures") as repair: + result = prepare_runtime_view(self.paths, source) + self.assertEqual(result.scene_action, "upstream-unmodified-copy") + self.assertEqual(result.binary_action, "upstream-pair") + self.assertEqual(result.source_scene_sha256, result.runtime_scene_sha256) + self.assertEqual((source / "game/maps/WhisperingVale.rovug").read_bytes(), b"future publisher scene format") + repair.assert_not_called() + + def test_unknown_scene_texture_count_does_not_trigger_legacy_patch(self) -> None: + self.assertEqual(_scene_action(MagicMock(file_sha256="new", embedded_textures=())), "upstream-unmodified-copy") + self.assertEqual(select_scene_action("new", [16, 16], [2, 2]), "upstream-unmodified-copy") + self.assertEqual(select_scene_action(KNOWN_SOURCE_SHA256, [16, 16], [2, 2]), "normalize-known-profile") + with self.assertRaises(OverlayError): + select_scene_action(KNOWN_SOURCE_SHA256, [16, 8], [2, 2]) + + def test_ge_setup_game_and_helpers_use_one_isolated_wow64_prefix(self) -> None: + self.runner.dry_run = True + prepare_game_runtime(self.paths, self.selection, self.runner) + run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False) + run_runtime_prefix_command(self.paths, self.selection, self.runner, ["reg.exe", "query"]) + self.assertEqual(self.runner.run.call_count, 3) + for call in self.runner.run.call_args_list: + environment = call.kwargs["env_overrides"] + self.assertEqual(environment["PROTONPATH"], "GE-Proton") + self.assertEqual(environment["PROTON_USE_WOW64"], "1") + self.assertEqual(environment["WINEPREFIX"], str(self.paths.ge_proton_prefix)) + self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.umu_prefix) + self.assertNotEqual(self.paths.ge_proton_prefix, self.paths.proton_prefix) + + def test_failed_directx_postcondition_does_not_mark_runtime_ready(self) -> None: + with self.assertRaisesRegex(RuntimeErrorPT, "DirectX setup completed without"): + prepare_game_runtime(self.paths, self.selection, self.runner) + self.assertFalse(self.paths.game_runtime_marker.exists()) + self.assertFalse(game_runtime_ready(self.paths, self.selection)) + + def test_shared_clients_cannot_reuse_a_prefix_for_another_runtime(self) -> None: + self.paths.state.mkdir(parents=True) + for prefix in (self.paths.umu_prefix, self.paths.ge_proton_prefix): + directory = prefix / "drive_c/windows/syswow64" + directory.mkdir(parents=True) + for name in ("d3dx9_43", "d3dcompiler_43"): + (directory / f"{name}.dll").touch() + (prefix / "user.reg").write_text('"*d3dx9_43"="native"\n"*d3dcompiler_43"="native"\n') + self.paths.game_runtime_marker.write_text(json.dumps({ + "configuration_version": 1, "runtime": asdict(self.selection), + })) + self.assertTrue(game_runtime_ready(self.paths, self.selection)) + self.assertFalse(game_runtime_ready(self.paths, RuntimeSelection("umu", "/usr/bin/umu-run", None, None))) + + def test_shared_ge_clients_keep_same_environment_except_for_verb(self) -> None: + for concurrent in (False, True): + run_game( + self.paths, Settings(), self.selection, self.runner, + use_gamebox=False, concurrent_prefix_launch=concurrent, + ) + first, second = [dict(call.kwargs["env_overrides"]) for call in self.runner.run.call_args_list] + self.assertEqual(first.pop("PROTON_VERB"), "waitforexitandrun") + self.assertEqual(second.pop("PROTON_VERB"), "runinprefix") + self.assertEqual(first, second) + + def test_renderer_is_environment_and_native_helpers_preserve_unrelated_overrides(self) -> None: + for kind in ("umu", "ge-proton", "proton7", "proton7-container"): + for renderer, value, override in (("dxvk", "0", "d3d9=n,b"), ("wined3d", "1", "d3d9=b")): + with self.subTest(kind=kind, renderer=renderer): + selection = RuntimeSelection(kind, "/usr/bin/umu-run", "/Steam/proton", "/Steam") + run_game( + self.paths, Settings(renderer=renderer), selection, self.runner, + use_gamebox=False, + extra_environment={"WINEDLLOVERRIDES": "*d3dx9_43,foo=b;d3dcompiler_43=;d3d9=b"}, + ) + environment = self.runner.run.call_args.kwargs["env_overrides"] + self.assertEqual(environment["PROTON_USE_WINED3D"], value) + self.assertEqual(environment["WINEDLLOVERRIDES"], + f"foo=b;d3dx9_43=n,b;d3dcompiler_43=n,b;{override}") + self.assertNotIn("PROTON_USE_WINED3D", " ".join(self.runner.run.call_args.args[0])) + + def test_windows_arguments_are_passed_in_order_without_shell_expansion(self) -> None: + folder = self.paths.runtime_view / "New Client" + folder.mkdir() + (folder / "CLIENT.exe").touch() + arguments = ("NEW_TOKEN", r"C:\data\assets", "", "a b", "$(echo literal)") + option = DepotStartOption("New Realm", "New Client/client.exe", subprocess.list2cmdline(arguments)) + run_game(self.paths, Settings(), self.selection, self.runner, start_option=option, use_gamebox=False) + call = self.runner.run.call_args + self.assertEqual(call.args[0], ["/usr/bin/umu-run", str(folder / "CLIENT.exe"), *arguments]) + self.assertEqual(call.kwargs["cwd"], self.paths.runtime_view) + + def test_missing_advertised_executable_fails_before_runtime_start(self) -> None: + with self.assertRaisesRegex(RuntimeErrorPT, "missing New.exe"): + run_game(self.paths, Settings(), self.selection, self.runner, + start_option=DepotStartOption("New", "New.exe", ""), use_gamebox=False) + self.runner.run.assert_not_called() + + def test_build_mismatch_blocks_play_before_game_view_or_runtime_changes(self) -> None: + self.paths.state.mkdir(parents=True) + self.paths.native_depot_state.write_text(json.dumps({"build": 100, "dry_run": False})) + api = MagicMock() + api.current_telemetry.return_value = None + api.game_import_requires_verification.return_value = False + api._load_or_default_settings.return_value = Settings() + api.is_running_inside_steam_runtime.return_value = False + for build in (99, 101, None): + api.read_cached_depot_launch_catalog.return_value = ( + DepotLaunchCatalog(build, (DepotStartOption("Babel", "Game.exe", "NEW"),), "Babel") + if build is not None else None + ) + with self.subTest(build=build), self.assertRaisesRegex(DepotError, "update.*before playing"): + play_locked(api, self.paths, None, False, False, False, False) + api.prepare_runtime_view.assert_not_called() + api.prepare_game_runtime.assert_not_called() + api.run_game.assert_not_called() + + def test_proton_telemetry_does_not_report_host_wine_as_bundled_wine(self) -> None: + with patch("ptlinux.telemetry.runtime.shutil.which", return_value="/usr/bin/wine") as which: + for kind in ("umu", "ge-proton"): + self.assertIsNone(_wine_command({"kind": kind, "executable": "/usr/bin/umu-run"}, {})) + which.assert_not_called() + (self.paths.ge_proton_prefix / "drive_c/windows").mkdir(parents=True) + self.assertEqual(_selected_prefix(self.paths, {"runtime": asdict(self.selection)}), self.paths.ge_proton_prefix) + + def test_gstreamer_negotiation_errors_are_visible_without_claiming_crash_cause(self) -> None: + report = runtime_issue_summary( + "GStreamer-Video-CRITICAL: gst_video_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n" + "GStreamer-Audio-CRITICAL: gst_audio_info_from_caps: assertion 'gst_caps_is_fixed (caps)' failed\n" + ) + self.assertEqual(report["gstreamer_caps_error_count"], 2) + self.assertFalse(report["gstreamer_stream_error"]) + + +class GameKeyboardFocusTests(unittest.TestCase): + def test_startup_hands_keyboard_focus_to_the_desktop_game_window(self) -> None: + # Model a WM that rejects raw X11 focus changes to an inactive client. + mapper = MagicMock(spec=game_window._X11) + mapper.window_state.return_value = 1 + active, keyboard = [7], [7] + pending: list[int] = [] + + def activate(window: int) -> bool: + pending.append(window) + return True + + def settle(_seconds: float) -> None: + if pending: + active[0] = pending.pop() + + def focus(window: int) -> None: + if active[0] == window: + keyboard[0] = window + + mapper.activate_window.side_effect = activate + mapper.window_is_active.side_effect = lambda window: active[0] == window + mapper.window_is_focused.side_effect = lambda window: keyboard[0] == window + mapper.restore_input_focus.side_effect = focus + with patch.object(game_window.time, "sleep", side_effect=settle): + game_window._focus_game_window(mapper, 12) + self.assertEqual((active[0], keyboard[0]), (12, 12)) + + def test_activation_preserves_minimize_before_and_during_handoff(self) -> None: + for states, requests in (([3], 0), ([1, 3], 1)): + with self.subTest(states=states): + mapper = MagicMock(spec=game_window._X11) + mapper.window_state.side_effect = states + mapper.window_is_active.return_value = False + with patch.object(game_window.time, "sleep"): + game_window._focus_game_window(mapper, 12) + self.assertEqual(mapper.activate_window.call_count, requests) + mapper.focus_window.assert_not_called() + + def test_nested_gamescope_focus_does_not_request_desktop_activation(self) -> None: + mapper = MagicMock(spec=game_window._X11) + mapper.window_state.return_value = 1 + mapper.window_is_focused.return_value = False + with patch.object(game_window.time, "sleep"): + game_window._focus_game_window(mapper, 12, activate=False) + mapper.activate_window.assert_not_called() + mapper.restore_input_focus.assert_called_once_with(12) + mapper.focus_window.assert_not_called() + + def test_activation_uses_only_the_games_own_user_time(self) -> None: + for properties, expected in ( + ({}, 0), + ({(12, b"_NET_WM_USER_TIME"): (12345,)}, 12345), + ({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,), + (13, b"_NET_WM_USER_TIME"): (45678,)}, 45678), + ({(12, b"_NET_WM_USER_TIME_WINDOW"): (13,), + (12, b"_NET_WM_USER_TIME"): (56789,)}, 56789), + ): + with self.subTest(properties=properties): + mapper: game_window._X11 = object.__new__(game_window._X11) + with ( + patch.object(mapper, "_intern_atom", return_value=77), + patch.object( + mapper, "_long_property", + side_effect=lambda window, name, _count: properties.get((window, name)), + ), + patch.object(mapper, "_send_root_client_message", return_value=True) as send, + ): + self.assertTrue(mapper.activate_window(12)) + send.assert_called_once_with(12, 77, (1, expected, 0, 0, 0)) + + def test_focus_diagnostics_distinguish_a_request_from_actual_activation(self) -> None: + mapper = MagicMock(spec=game_window._X11) + mapper.window_state.return_value = 1 + mapper.activate_window.return_value = True + mapper.window_is_active.return_value = False + mapper.window_diagnostic_snapshot.return_value = {"active": False, "focused": False} + diagnostics = MagicMock() + with ( + patch.object(game_window.time, "sleep"), + patch.object(game_window, "WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS", 0.0), + ): + game_window._focus_game_window(mapper, 12, diagnostics=diagnostics) + diagnostics.record.assert_called_once_with( + "focus_handoff", window_id=12, activation_requested=True, + activation_submitted=True, outcome="not_activated", active=False, focused=False, + ) + + def test_focus_repair_respects_background_and_minimized_windows(self) -> None: + for state, active in ((1, False), (3, True)): + with self.subTest(state=state, active=active): + mapper = MagicMock(spec=game_window._X11) + mapper.window_state.return_value = state + mapper.window_is_active.return_value = active + mapper.window_is_focused.return_value = False + self.assertFalse(game_window._repair_game_window_focus(mapper, 12)) + mapper.restore_input_focus.assert_not_called() + mapper.activate_window.assert_not_called() + mapper.focus_window.assert_not_called() + + def test_borderless_launch_keeps_focus_repair_without_live_resizing(self) -> None: + mapper = MagicMock(spec=game_window._X11) + mapper.root_window_ids.return_value = set() + mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96") + process = MagicMock() + process.poll.return_value = None + with ( + patch.object(game_window, "_X11", return_value=mapper), + patch.object(game_window.subprocess, "Popen", return_value=process), + patch.object(game_window, "WINDOW_READY_DELAY_SECONDS", 0), + patch.object(game_window, "_prepare_game_window"), + patch.object(game_window, "_focus_game_window"), + patch.object(game_window, "_guard_game_window_startup", return_value=True), + patch.object(game_window, "_maintain_game_window_scaling") as resize, + patch.object(game_window, "_maintain_game_window_focus", return_value=17) as maintain, + ): + self.assertEqual(game_window.run_game_window(["unused"], borderless=True), 17) + maintain.assert_called_once_with( + mapper, process, 12, window_title=game_window.WINDOW_TITLE, + existing_window_ids=set(), expected_pid=mapper.window_pid.return_value, + ) + resize.assert_not_called() + mapper.close.assert_called_once_with() + + def test_child_input_focus_is_preserved_and_foreign_focus_is_rejected(self) -> None: + for focused, parents, expected in ( + (12, {}, True), + (14, {14: 13, 13: 12}, True), + (24, {24: 23, 23: 1000, 1000: 0}, False), + (24, {}, False), # The focused window vanished during the read. + (24, {24: 25, 25: 24}, False), + (0, {}, False), + (1, {}, False), + ): + with self.subTest(focused=focused, parents=parents): + mapper: game_window._X11 = object.__new__(game_window._X11) + mapper.display = ctypes.c_void_p(1) + mapper.library = MagicMock() + allocations: list[object] = [] + + def read_focus(_display, pointer, _revert): + pointer._obj.value = focused + return 1 + + def query_tree(_display, window, root, parent, children, count): + if window not in parents: + return 0 + root._obj.value = 1000 + parent._obj.value = parents[window] + allocation = (ctypes.c_ulong * 1)(99) + allocations.append(allocation) + ctypes.cast(children, ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)))[0] = allocation + count._obj.value = 1 + return 1 + + mapper.library.XGetInputFocus.side_effect = read_focus + mapper.library.XQueryTree.side_effect = query_tree + with ( + patch.object(mapper, "window_state", return_value=1), + patch.object(mapper, "window_is_active", return_value=True), + patch.object(mapper, "restore_input_focus") as restore, + ): + self.assertEqual(game_window._repair_game_window_focus(mapper, 12), expected) + self.assertEqual(restore.call_count, 0 if expected else 1) + self.assertEqual(mapper.library.XFree.call_count, len(allocations)) diff --git a/tests/test_gamescope_session_outcomes.py b/tests/test_gamescope_session_outcomes.py new file mode 100644 index 0000000..1d5c5b6 --- /dev/null +++ b/tests/test_gamescope_session_outcomes.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from contextlib import redirect_stderr, redirect_stdout +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import MagicMock + +from ptlinux.application.commands.play import ( + game_exit_metadata, + game_failure_reason, + game_readiness_signal, + play_locked, + print_game_outcome, + runtime_issue_summary, +) +from ptlinux.command import CommandOutcome +from ptlinux.depot import DepotStartOption +from ptlinux.paths import AppPaths +from ptlinux.runtime import RuntimeSelection +from ptlinux.settings import Settings + + +def missing_status_message(status: int) -> str: + return ( + f"Gamescope exited without a recorded game status (compositor status {status}); " + "the game's exit result is unknown.\n" + ) + + +class GamescopeSessionOutcomeTests(unittest.TestCase): + def test_missing_status_is_not_attributed_to_the_game_or_umu(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache"))) + paths.runtime_view.mkdir(parents=True) + api = MagicMock() + api.game_import_requires_verification.return_value = False + api._load_or_default_settings.return_value = Settings(runtime="ge-proton") + api.is_running_inside_steam_runtime.return_value = False + api.read_cached_depot_launch_catalog.return_value = None + api.read_current_depot_file_catalog.return_value = None + api._current_game_log.return_value = None + api._current_game_crash.return_value = None + api.select_runtime.return_value = RuntimeSelection("ge-proton", "umu-run", None, None) + api.select_depot_start_option.return_value = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN") + api._runtime_issue_summary.side_effect = runtime_issue_summary + api._game_exit_metadata.side_effect = game_exit_metadata + for ready in (True, False): + for compositor_status, wrapper_status in ((0, 1), (-11, 139), (247, 247), (0, 0)): + with self.subTest(ready=ready, compositor=compositor_status, wrapper=wrapper_status): + api.current_telemetry.return_value.reset_mock() + api._game_readiness_signal.return_value = "window_ready" if ready else None + api.run_game.return_value = CommandOutcome( + [], wrapper_status, "", missing_status_message(compositor_status), 120.0, False, False, + ) + self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1) + report = api._emit.call_args.args[0] + self.assertFalse(report["ok"]) + self.assertEqual(report["launch_succeeded"], ready) + event_name = "game_exited" if ready else "game_launch_failed" + event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == event_name) + metadata = event.kwargs["metadata"] + self.assertEqual(metadata["failure_reason"], "gamescope_game_status_missing") + self.assertFalse(metadata["game_exit_status_available"]) + self.assertEqual(metadata["exit_status_source"], "gamescope_supervisor") + self.assertEqual(metadata["gamescope_exit_code"], compositor_status) + self.assertEqual(metadata["exit_code"], wrapper_status) + self.assertNotIn("signal_inferred", metadata) + self.assertNotIn("signal", metadata) + self.assertIn("before the game reported its exit status", event.kwargs["message"]) + self.assertNotIn("Game client exited with status", event.kwargs["message"]) + + def test_truncated_output_retains_observed_shutdown_status(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache"))) + paths.runtime_view.mkdir(parents=True) + api = MagicMock() + api.game_import_requires_verification.return_value = False + api._load_or_default_settings.return_value = Settings(runtime="ge-proton") + api.is_running_inside_steam_runtime.return_value = False + api.read_cached_depot_launch_catalog.return_value = None + api.read_current_depot_file_catalog.return_value = None + api._current_game_log.return_value = None + api._current_game_crash.return_value = None + api.select_runtime.return_value = RuntimeSelection("ge-proton", "umu-run", None, None) + api.select_depot_start_option.return_value = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN") + api._runtime_issue_summary.side_effect = runtime_issue_summary + api._game_readiness_signal.side_effect = game_readiness_signal + api._gamescope_presentation_failure.return_value = None + api.parse_window_diagnostic.return_value = None + api._game_exit_metadata.side_effect = game_exit_metadata + + def run(*args, **kwargs): + observe = kwargs["output_line_filter"] + observe("Game window ready: PristonTale EU\n") + observe(missing_status_message(247)) + return CommandOutcome([], 247, "", "final child cleanup output\n", 120.0, False, False) + + api.run_game.side_effect = run + self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1) + event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == "game_exited") + metadata = event.kwargs["metadata"] + self.assertEqual(metadata["failure_reason"], "gamescope_game_status_missing") + self.assertEqual(metadata["gamescope_exit_code"], 247) + self.assertNotIn("signal_inferred", metadata) + self.assertTrue(metadata["game_window_ready"]) + + def test_unobserved_window_is_not_readiness_evidence(self) -> None: + for message in ( + "Game window ready: unmanaged PristonTale EU\n", + "Game window monitoring unavailable; process started: PristonTale EU\n", + ): + with self.subTest(message=message): + self.assertIsNone(game_readiness_signal(message)) + self.assertFalse(runtime_issue_summary(message)["game_window_ready"]) + self.assertEqual(game_readiness_signal(message + "Game window ready: PristonTale EU\n"), "window_ready") + + def test_human_output_distinguishes_unknown_status_in_old_and_new_logs(self) -> None: + messages = ( + missing_status_message(0), + missing_status_message(-11), + "Gamescope exited without a recorded game status; the game session was interrupted.\n", + ) + for message in messages: + with self.subTest(message=message): + outcome = CommandOutcome([], 1, "", message, 120.0, False, False) + issues = runtime_issue_summary(message) + output = io.StringIO() + with redirect_stdout(output), redirect_stderr(output): + print_game_outcome(outcome, None, issues) + self.assertIn("before the game reported its exit status", output.getvalue()) + self.assertNotIn("Game client exited", output.getvalue()) + self.assertNotIn("crashed", output.getvalue()) + + def test_missing_status_preserves_stronger_failure_evidence(self) -> None: + cases = ( + ({"runtime_out_of_memory": True}, None, False, "direct3d_resource_exhaustion"), + (None, {"path": "/tmp/crash_game.dat"}, False, "client_crash_dump"), + (None, None, True, "session_timeout"), + ) + for native_log, crash, timed_out, reason in cases: + with self.subTest(reason=reason): + outcome = CommandOutcome([], 1, "", missing_status_message(0), 120.0, timed_out, False) + self.assertEqual(game_failure_reason(outcome, native_log, crash, {}), reason) + + def test_human_output_keeps_crash_and_timeout_evidence(self) -> None: + for timed_out, crash, expected in ( + (False, {"path": "/tmp/crash_game.dat"}, "crashed and wrote a dump"), + (True, None, "timed out and was stopped"), + ): + with self.subTest(expected=expected): + outcome = CommandOutcome([], 1, "", missing_status_message(0), 120.0, timed_out, False) + output = io.StringIO() + with redirect_stderr(output): + print_game_outcome(outcome, None, runtime_issue_summary(outcome.stderr), crash) + self.assertIn(expected, output.getvalue()) + self.assertNotIn("before the game reported", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gamescope_shutdown_regressions.py b/tests/test_gamescope_shutdown_regressions.py new file mode 100644 index 0000000..71e4678 --- /dev/null +++ b/tests/test_gamescope_shutdown_regressions.py @@ -0,0 +1,110 @@ +"""Exercise supervisor failure paths without launching a game or display server.""" + +from contextlib import redirect_stderr +import io +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +import textwrap +import unittest +from unittest.mock import MagicMock, patch + +from ptlinux.presentation import gamescope +from scripts.repository_paths import SOURCE_ROOT + + +class GamescopeShutdownRegressions(unittest.TestCase): + def test_missing_status_storage_cannot_be_reported_as_a_clean_game_exit(self) -> None: + process = MagicMock() + process.poll.return_value = 0 + output = io.StringIO() + with ( + patch.object(gamescope, "_select_host_backend", return_value="sdl"), + patch.object(gamescope, "_open_host_x11", return_value=None), + patch.object(gamescope, "_create_child_status_file", side_effect=OSError("no space left")), + patch.object(gamescope.subprocess, "Popen", return_value=process) as popen, + redirect_stderr(output), + ): + self.assertEqual(gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960), 1) + self.assertNotIn("--keep-alive", popen.call_args.args[0]) + self.assertIn("supervision is unavailable", output.getvalue()) + self.assertIn("without a recorded game status (compositor status 0)", output.getvalue()) + + def test_interrupt_during_startup_tuning_closes_the_compositor_and_state_files(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + mapper = MagicMock() + process = MagicMock() + process.poll.return_value = None + process.wait.return_value = -15 + with ( + patch.dict(os.environ, {"DISPLAY": ":0", "XDG_RUNTIME_DIR": temporary}, clear=True), + patch.object(gamescope, "_select_host_backend", return_value="sdl"), + patch.object(gamescope, "_open_host_x11", return_value=mapper), + patch.object(gamescope, "_tune_process_window", side_effect=KeyboardInterrupt), + patch.object(gamescope.subprocess, "Popen", return_value=process), + self.assertRaises(KeyboardInterrupt), + ): + gamescope.run_gamescope(["gamescope", "--", "game"], 1280, 960) + process.terminate.assert_called_once_with() + process.wait.assert_called_once_with(timeout=gamescope.GAMESCOPE_SHUTDOWN_TIMEOUT_SECONDS) + mapper.close.assert_called_once_with() + self.assertEqual(list(Path(temporary).iterdir()), []) + + def test_child_report_failure_does_not_leave_an_empty_compositor_alive(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + fake_gamescope = root / "gamescope" + fake_gamescope.write_text( + f"#!{sys.executable}\n" + textwrap.dedent("""\ + import subprocess, sys, time + separator = sys.argv.index('--') + subprocess.run(sys.argv[separator + 1:], check=False) + # Gamescope's default exits when the primary child finishes; + # --keep-alive disables that behavior, even if the child failed. + if '--keep-alive' in sys.argv[1:separator]: + time.sleep(15) + """), encoding="utf-8", + ) + fake_gamescope.chmod(0o700) + runner = root / "supervisor.py" + runner.write_text(textwrap.dedent("""\ + from pathlib import Path + import sys + from unittest.mock import patch + from ptlinux.presentation import gamescope + status = Path(sys.argv[2]) + with ( + patch.object(gamescope, '_select_host_backend', return_value='wayland'), + patch.object(gamescope, '_select_wayland_gamescope', return_value=(sys.argv[1], True, False)), + patch.object(gamescope, '_create_child_status_file', return_value=status), + ): + raise SystemExit(gamescope.run_gamescope( + [sys.argv[1], '--', sys.executable, '-c', 'raise SystemExit(7)'], 1280, 960, + )) + """), encoding="utf-8") + for status, expected in ((root / "status", 7), (root / "removed-directory" / "status", 1)): + with self.subTest(status=status): + process = subprocess.Popen( + [sys.executable, str(runner), str(fake_gamescope), str(status)], + env={**os.environ, "PYTHONPATH": str(SOURCE_ROOT)}, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True, + ) + try: + _stdout, stderr = process.communicate(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.communicate(timeout=5) + raise + self.assertEqual(process.returncode, expected, stderr) + if expected == 1: + self.assertIn("Could not report the nested game status", stderr) + self.assertIn("without a recorded game status", stderr) + else: + self.assertNotIn("without a recorded game status", stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launcher_resilience.py b/tests/test_launcher_resilience.py new file mode 100644 index 0000000..61f58ae --- /dev/null +++ b/tests/test_launcher_resilience.py @@ -0,0 +1,690 @@ +from __future__ import annotations + +from contextlib import ExitStack, redirect_stderr, redirect_stdout +from dataclasses import replace +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import io +import json +import os +from pathlib import Path +import resource +import runpy +import subprocess +import sys +import tempfile +import threading +from types import SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +from scripts.repository_paths import SOURCE_ROOT + +from ptlinux.command import CommandOutcome, CommandRunner +from ptlinux.application.commands.play import ( + game_exit_metadata, + game_failure_reason, + play_locked, + print_game_outcome, + recommended_runtime_comparison, + runtime_issue_summary, +) +from ptlinux.depot import DepotClient, DepotError, DepotFile, DepotStartOption +from ptlinux.diagnostics.native_logs import parse_native_log_lines +from ptlinux.doctor import inspect_host +from ptlinux.game.ptr_compat import PtrCompatibilityError, ptr_bcrypt_dll_overrides, stage_ptr_bcrypt_compatibility +from ptlinux.game.runtime import RuntimeErrorPT, RuntimeSelection, prepare_game_runtime, run_game, run_runtime_prefix_command +from ptlinux.game.runtime_view import prepare_runtime_view +from ptlinux.gamebox import build_gamebox_command +from ptlinux.gui_bootstrap import recover_gui_python, tkinter_path +from ptlinux.locking import managed_game_process_is_active +from ptlinux.paths import AppPaths +from ptlinux.presentation.gamescope import _read_gamescope_version, _supervised_command, _wait_for_optimized_gamescope +from ptlinux.settings import Settings +from ptlinux.telemetry.dependencies import _dependency_health +from ptlinux.telemetry.runtime import _selected_prefix +from ptlinux.telemetry import _resource_usage + + +class NativeCrashLogTests(unittest.TestCase): + def test_current_client_collision_and_minimap_allocation_failures(self) -> None: + log = r""" +[18:16:32.2832206] [CRIT] - Stage 0x3d1d0020 (not in the map list) has no scene/collision: stage without new stage at X: -10 Z: -110. 0 more lookups in the last minute. +[18:16:38.7429594] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory +[18:16:38.7429983] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc +[18:16:38.7517353] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory +[18:16:38.7517827] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc +[18:16:39.2249996] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory +[18:16:39.2250202] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc +[18:16:39.2271872] [CRIT] - DX::EffectFactory::CreateEffectFromFile : 115 : E_OUTOFMEMORY : Ran out of memory +[18:16:39.2271993] [CRIT] - Failed to create effect: game\scripts\shaders\UI\Minimap.fx 0x11a73ffc +""" + analysis = parse_native_log_lines(log.splitlines()) + summary = analysis.to_summary() + self.assertTrue(summary["stage_without_new_stage"]) + self.assertTrue(summary["runtime_out_of_memory"]) + self.assertEqual(summary["out_of_memory_count"], 4) + self.assertEqual( + summary["out_of_memory_effect_paths"], + [r"game\scripts\shaders\UI\Minimap.fx"], + ) + self.assertEqual( + len([event for event in analysis.events if event.kind == "stage_without_new_stage"]), + 1, + ) + + def test_collision_warning_alone_does_not_imply_resource_exhaustion(self) -> None: + analysis = parse_native_log_lines([ + "[21:14:40.2199865] [CRIT] - Stage 0x43635c38 (not in the map list) " + "has no scene/collision: stage without new stage at X: -10 Z: -110. " + "0 more lookups in the last minute." + ]) + self.assertTrue(analysis.stage_without_new_stage) + self.assertFalse(analysis.runtime_out_of_memory) + self.assertFalse(analysis.import_scene_threw) + self.assertFalse(analysis.asset_pack_health_failed) + + +class ResourceExhaustionGuidanceTests(unittest.TestCase): + def setUp(self) -> None: + self.native_log = { + "runtime_out_of_memory": True, + "out_of_memory_count": 4, + "out_of_memory_effect_paths": [r"game\scripts\shaders\UI\Minimap.fx"], + "stage_without_new_stage": True, + "managed_asset_integrity_failed": False, + } + self.runtime_issues = runtime_issue_summary( + "Game window ready: PristonTale EU V23.86.96\n" + "plugin.so: wrong ELF class: ELFCLASS64\n" + "libavfilter.so.7: cannot open shared object file: No such file or directory\n" + ) + + def test_proton7_report_keeps_oom_classification_and_suggests_ge(self) -> None: + outcome = CommandOutcome([], 241, "", "", 138.0, False, False) + self.assertEqual( + game_failure_reason(outcome, self.native_log, None, game_exit_metadata(241, "proton7")), + "direct3d_resource_exhaustion", + ) + for kind in ("proton7", "proton7-container", "umu"): + with self.subTest(runtime=kind): + self.assertEqual( + recommended_runtime_comparison( + kind, self.runtime_issues, current_game_log=self.native_log, realm="Babel", + ), + "ge-proton", + ) + + def test_oom_evidence_takes_priority_over_secondary_runtime_warnings(self) -> None: + issues = { + **self.runtime_issues, + "gstreamer_stream_error": True, + "dbghelp_symdeferred_assertion_count": 1, + "host_futex_fatal_count": 1, + } + for kind, expected in (("proton7", "ge-proton"), ("proton7-container", "ge-proton"), ("ge-proton", None)): + with self.subTest(runtime=kind): + self.assertEqual( + recommended_runtime_comparison(kind, issues, current_game_log=self.native_log, realm="Babel"), + expected, + ) + + def test_ptr_and_publisher_realm_alias_keep_the_required_runtime(self) -> None: + for realm in ("PTR Babel", "ptr babel", "Test Babel", "test babel"): + with self.subTest(realm=realm): + self.assertIsNone(recommended_runtime_comparison( + "proton7", self.runtime_issues, current_game_log=self.native_log, realm=realm, + )) + + def test_plugin_and_collision_warnings_alone_do_not_suggest_a_runtime_switch(self) -> None: + for log in (None, {**self.native_log, "runtime_out_of_memory": False}): + with self.subTest(log=log): + self.assertIsNone(recommended_runtime_comparison( + "proton7", self.runtime_issues, current_game_log=log, realm="Babel", + )) + + def test_completed_play_reports_the_current_log_recommendation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths(*(root / name for name in ("data", "config", "state", "cache"))) + paths.runtime_view.mkdir(parents=True) + api = MagicMock() + api.game_import_requires_verification.return_value = False + api._load_or_default_settings.return_value = Settings(runtime="proton7") + api.is_running_inside_steam_runtime.return_value = False + api.read_cached_depot_launch_catalog.return_value = None + api.read_current_depot_file_catalog.return_value = None + api._current_game_log.return_value = self.native_log + api._current_game_crash.return_value = None + api.select_runtime.return_value = RuntimeSelection("proton7", "proton", "proton", None) + api.run_game.return_value = CommandOutcome([], 241, "", "", 138.0, False, False) + api._runtime_issue_summary.return_value = dict(self.runtime_issues) + api._game_readiness_signal.return_value = "window_ready" + api._game_exit_metadata.return_value = game_exit_metadata(241, "proton7") + for realm, expected in (("Babel", "ge-proton"), ("PTR Babel", None)): + with self.subTest(realm=realm): + api.current_telemetry.return_value.reset_mock() + api.select_depot_start_option.return_value = DepotStartOption(realm, "Game.exe", "PTEU_REBORN") + self.assertEqual(play_locked(api, paths, None, False, False, False, True, reuse_prepared=True), 1) + report = api._emit.call_args.args[0] + self.assertFalse(report["ok"]) + self.assertTrue(report["degraded"]) + self.assertEqual(report["runtime_issues"].get("recommended_one_variable_comparison"), expected) + exit_event = next(call for call in api.current_telemetry.return_value.send_event.call_args_list if call.args[0] == "game_exited") + self.assertEqual(exit_event.kwargs["metadata"]["failure_reason"], "direct3d_resource_exhaustion") + self.assertEqual(exit_event.kwargs["metadata"]["runtime_issues"].get("recommended_one_variable_comparison"), expected) + api._runtime_issue_summary.return_value = dict(self.runtime_issues) + + def test_runtime_advice_is_visible_in_the_launcher_output(self) -> None: + output = io.StringIO() + with redirect_stderr(output): + print_game_outcome( + CommandOutcome([], 241, "", "", 138.0, False, False), None, + {**self.runtime_issues, "recommended_one_variable_comparison": "ge-proton"}, + ) + self.assertIn("runtime comparison: ge-proton", output.getvalue()) + + +class ResourceUsageScopeTests(unittest.TestCase): + def test_peak_memory_is_explicitly_launcher_rss_and_cpu_is_launcher_time(self) -> None: + with ( + patch("ptlinux.telemetry.sys.platform", "linux"), + patch("ptlinux.telemetry.resource.getrusage", return_value=SimpleNamespace( + ru_maxrss=305398, ru_utime=40.0, ru_stime=0.891, + )) as read_usage, + ): + self.assertEqual(_resource_usage(), { + "resource_usage_scope": "launcher_process", + "peak_memory_mb": 298.24, + "cpu_time_seconds": 40.891, + }) + read_usage.assert_called_once_with(resource.RUSAGE_SELF) + + def test_unavailable_process_usage_does_not_emit_a_scope_without_measurements(self) -> None: + with patch("ptlinux.telemetry.resource.getrusage", side_effect=OSError("unavailable")): + self.assertEqual(_resource_usage(), {}) + + +class GuiRecoveryTests(unittest.TestCase): + def test_missing_python_package_is_not_hidden_by_a_present_extension(self) -> None: + def imports(name: str) -> object: + if name == "tkinter": + raise ModuleNotFoundError("No module named 'tkinter'") + return MagicMock() + with patch("ptlinux.gui_bootstrap.import_module", side_effect=imports): + self.assertIsNone(tkinter_path()) + + def test_broken_tcl_data_is_reported_as_missing_gui_support(self) -> None: + class TclError(Exception): + pass + tk = MagicMock() + tk.Tcl.side_effect = TclError("Can't find a usable init.tcl") + with patch("ptlinux.gui_bootstrap.import_module", return_value=tk): + self.assertIsNone(tkinter_path()) + + def test_fallback_restarts_the_same_release_and_arguments_with_system_python(self) -> None: + with ( + patch("ptlinux.gui_bootstrap.tkinter_path", return_value=None), + patch("ptlinux.gui_bootstrap.Path.is_file", return_value=True), + patch.object(sys, "executable", "/custom/python3"), + patch.dict(os.environ, {"PYTHONHOME": "/custom", "PYTHONPATH": "/old", "DISPLAY": ":0"}, clear=True), + patch("ptlinux.gui_bootstrap.subprocess.run", return_value=SimpleNamespace(returncode=0)) as probe, + patch("ptlinux.gui_bootstrap.os.execve", side_effect=SystemExit(0)) as restart, + self.assertRaises(SystemExit), + ): + recover_gui_python(["gui"]) + self.assertEqual(probe.call_args.args[0][0], "/usr/bin/python3") + self.assertEqual(restart.call_args.args[1], ["/usr/bin/python3", "-B", "-P", "-m", "ptlinux", "gui"]) + env = restart.call_args.args[2] + self.assertNotIn("PYTHONHOME", env) + self.assertEqual(env["DISPLAY"], ":0") + self.assertTrue((Path(env["PYTHONPATH"]) / "ptlinux/__main__.py").is_file()) + + def test_explicit_interpreter_and_retry_guard_do_not_loop(self) -> None: + for env in ({"PTLINUX_PYTHON": "/custom/python"}, {"PTLINUX_GUI_PYTHON_RETRIED": "1"}): + with ( + patch.dict(os.environ, env, clear=True), + patch("ptlinux.gui_bootstrap.tkinter_path", return_value=None), + patch("ptlinux.gui_bootstrap.subprocess.run") as probe, + ): + self.assertFalse(recover_gui_python([])) + probe.assert_not_called() + + def test_missing_tk_reports_dependency_and_shows_desktop_repair_before_bootstrap(self) -> None: + telemetry = MagicMock() + with ( + patch.object(sys, "argv", ["pristontale-eu", "gui"]), + patch("ptlinux.gui_bootstrap.recover_gui_python", return_value=False), + patch("ptlinux.gui_bootstrap.show_gui_dependency_error") as show, + patch("ptlinux.telemetry.initialize_shared_telemetry", return_value=(telemetry, True)), + patch("ptlinux.telemetry.shutdown_shared_telemetry") as shutdown, + patch("ptlinux.umu.provision_umu_for_startup") as bootstrap, + patch("sys.stderr", new_callable=io.StringIO), + self.assertRaises(SystemExit) as stopped, + ): + runpy.run_module("ptlinux.__main__", run_name="__main__") + self.assertEqual(stopped.exception.code, 1) + self.assertEqual(telemetry.send_event.call_args.args[0], "dependency_check_failed") + self.assertEqual(telemetry.send_event.call_args.kwargs["metadata"]["dependencies"], ["python-tk"]) + telemetry.report_exception.assert_not_called() + bootstrap.assert_not_called() + show.assert_called_once() + shutdown.assert_called_once_with(telemetry) + + +class RuntimeDependencyTests(unittest.TestCase): + def setUp(self) -> None: + self.stack = ExitStack() + self.addCleanup(self.stack.close) + for target, value in ( + ("_read_os_release", {"ID": "steamos", "VERSION_ID": "3.8"}), + ("_python_tk_path", "tkinter"), + ("find_wine_tools", None), + ("_umu_path", "/usr/bin/umu-run"), + ("inspect_container_host", SimpleNamespace(ready=True, bwrap_path="/usr/bin/bwrap", broken_umu_profile_link=False)), + ): + self.stack.enter_context(patch(f"ptlinux.doctor.{target}", return_value=value)) + self.stack.enter_context(patch("ptlinux.doctor.shutil.which", side_effect=lambda name, **kw: "/usr/bin/openssl" if name == "openssl" else None)) + self.support = {"vulkan_available": True, "vulkan_64bit": True, "vulkan_32bit": True} + + def test_steamos_umu_does_not_require_host_wine_or_installation_tools(self) -> None: + for kind in ("umu", "ge-proton"): + with self.subTest(kind=kind): + health = _dependency_health({}, None, {**self.support, "kind": kind}) + self.assertTrue(health["healthy"]) + self.assertEqual(health["missing_required"], []) + self.assertFalse(health["tools"]["wine"]) + self.assertFalse(health["archive_tools_present"]) + + def test_legacy_prefix_still_requires_tools_until_directx_is_prepared(self) -> None: + for ready in (False, True): + with patch("ptlinux.doctor.native_d3d_ready", return_value=ready): + report = inspect_host({}, vulkan_support=self.support, + game_runtime={"kind": "proton7"}, runtime_prefix=Path("/test/prefix")) + self.assertEqual(report.ok, ready) + missing = {tool.name for tool in report.tools if tool.required and tool.path is None} + self.assertEqual(missing, set() if ready else {"winetricks", "cabextract"}) + + def test_modern_wow64_uses_64bit_driver_while_umu_keeps_multilib_check(self) -> None: + for kind, expected in (("ge-proton", True), ("umu", False)): + health = _dependency_health({}, None, {**self.support, "kind": kind, "vulkan_32bit": False}) + self.assertEqual(health["healthy"], expected) + + +class LaunchIsolationTests(unittest.TestCase): + def setUp(self) -> None: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.paths = AppPaths(*(self.root / name for name in ("data", "config", "state", "cache"))) + self.paths.runtime_view.mkdir(parents=True) + (self.paths.runtime_view / "Game.exe").touch() + self.selection = RuntimeSelection("ge-proton", "/fake/umu-run", None, None) + self.runner = MagicMock(dry_run=False) + self.host = patch("ptlinux.game.runtime._require_container_host") + self.host.start() + self.addCleanup(self.host.stop) + + def _bundled_proton_launcher(self, kind: str) -> RuntimeSelection: + proton_directory = self.root / "bundled Proton" + proton_directory.mkdir(exist_ok=True) + (proton_directory / "ptlinux_proton_test_dependency.py").write_text("VALUE = 'bundled'\n") + proton = proton_directory / "proton" + proton.write_text( + "import json, sys\n" + "from ptlinux_proton_test_dependency import VALUE\n" + "print(json.dumps({'dependency': VALUE, 'safe_path': sys.flags.safe_path, " + "'argv': sys.argv[1:]}))\n" + ) + umu = self.root / "umu-run" + umu.write_text( + f"#!{sys.executable}\n" + "import os, sys\n" + f"os.execv(sys.executable, [sys.executable, {str(proton)!r}, *sys.argv[1:]])\n" + ) + umu.chmod(0o755) + return RuntimeSelection(kind, str(umu), str(proton), str(self.root / "Steam")) + + def test_bundled_proton_imports_survive_launcher_safe_path_during_setup(self) -> None: + for kind in ("umu", "ge-proton"): + with self.subTest(kind=kind), patch.dict(os.environ, {"PYTHONSAFEPATH": "1"}): + selection = self._bundled_proton_launcher(kind) + runner = CommandRunner(self.root / "commands.jsonl") + with patch("ptlinux.game.runtime.native_d3d_ready", side_effect=[False, True]): + outcomes = prepare_game_runtime(self.paths, selection, runner) + child = json.loads(outcomes[0].stdout) + self.assertEqual(child["dependency"], "bundled") + self.assertFalse(child["safe_path"]) + self.assertEqual(child["argv"], ["winetricks", "-q", "d3dx9_43", "d3dcompiler_43"]) + self.assertEqual(os.environ["PYTHONSAFEPATH"], "1") + + def test_bundled_proton_imports_survive_safe_path_for_games_and_helpers(self) -> None: + for kind in ("umu", "ge-proton", "proton7-container"): + with self.subTest(kind=kind), patch.dict(os.environ, {"PYTHONSAFEPATH": "1"}): + selection = self._bundled_proton_launcher(kind) + runner = CommandRunner(self.root / "commands.jsonl") + helper = run_runtime_prefix_command(self.paths, selection, runner, + ["reg.exe", "query"], timeout_seconds=5) + with redirect_stdout(io.StringIO()): + game = run_game(self.paths, Settings(), selection, runner, + use_gamebox=False, timeout_seconds=5) + for outcome in (helper, game): + child = json.loads(outcome.stdout) + self.assertEqual(child["dependency"], "bundled") + self.assertFalse(child["safe_path"]) + self.assertEqual(os.environ["PYTHONSAFEPATH"], "1") + + def test_launcher_wrappers_ignore_python_modules_in_the_game_directory(self) -> None: + shadow = self.paths.runtime_view / "ptlinux" + shadow.mkdir() + (shadow / "__init__.py").write_text("raise RuntimeError('game directory shadowed launcher')\n") + command = build_gamebox_command(["umu-run", "Game.exe"], + Settings(allow_window_resizing=True), game_size=(1280, 960), gamescope_path="gamescope") + separator = command.index("--") + command[separator + 1:] = _supervised_command( + command[separator + 1:], self.root / "status", "wayland") + environment = { + **os.environ, + "PYTHONSAFEPATH": "", + "PYTHONPATH": str(SOURCE_ROOT), + } + for module in ("ptlinux.gamescope_runtime", "ptlinux.gamescope_child_runtime", "ptlinux.gamebox_runtime"): + with self.subTest(module=module): + index = command.index(module) + wrapper = command[command.index(sys.executable, index - 3):index + 1] + result = subprocess.run([*wrapper, "--help"], cwd=self.paths.runtime_view, + env=environment, capture_output=True, text=True, timeout=5) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("usage:", result.stdout) + + def test_gamescope_can_finish_its_shutdown_without_sigkill(self) -> None: + cleanup = self.root / "display-closed" + status = self.root / "status" + status.write_text("0\n") + process = subprocess.Popen( + [sys.executable, "-c", + "import signal\nfrom pathlib import Path\n" + "def stop(*args):\n" + f" Path({str(cleanup)!r}).touch()\n" + " raise SystemExit(0)\n" + "signal.signal(signal.SIGTERM, stop)\n" + "print('ready', flush=True)\n" + "signal.pause()\n"], + stdout=subprocess.PIPE, text=True, + ) + try: + assert process.stdout is not None + self.assertEqual(process.stdout.readline().strip(), "ready") + self.assertEqual(_wait_for_optimized_gamescope(process, status), 0) + self.assertEqual(process.returncode, 0) + self.assertTrue(cleanup.exists()) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + if process.stdout is not None: + process.stdout.close() + + def test_version_detection_recognizes_the_bundled_gamescope_filename(self) -> None: + banner = ( + "[gamescope-3.16.20-ubuntu26.04-amd64] [\x1b[0;34mInfo\x1b[0m] " + "\x1b[0;37mconsole:\x1b[0m gamescope-3.16.20-ubuntu26.04-amd64 " + "version 3.16.20+ds-1 (gcc 15.2.0)\n" + ) + with patch("ptlinux.presentation.gamescope.subprocess.run", + return_value=SimpleNamespace(returncode=0, stdout=banner)): + self.assertEqual(_read_gamescope_version("gamescope-custom"), (3, 16, 20)) + + def test_gamescope_cleanup_timeout_preserves_the_game_failure(self) -> None: + status = self.root / "status" + status.write_text("-11\n") + process = MagicMock() + process.poll.return_value = None + process.wait.side_effect = [subprocess.TimeoutExpired("gamescope", 2), -9] + output = io.StringIO() + with patch("sys.stderr", output): + self.assertEqual(_wait_for_optimized_gamescope(process, status), 139) + process.terminate.assert_called_once_with() + process.kill.assert_called_once_with() + self.assertTrue(runtime_issue_summary(output.getvalue())["gamescope_forced_cleanup"]) + + def test_gamescope_exit_without_game_status_is_not_reported_as_clean(self) -> None: + status = self.root / "status" + for contents in (None, "", "invalid", "999"): + for compositor_status, expected in ((0, 1), (-11, 139), (247, 247)): + with self.subTest(contents=contents, status=compositor_status): + status.unlink(missing_ok=True) + if contents is not None: + status.write_text(contents) + process = MagicMock() + process.poll.return_value = compositor_status + output = io.StringIO() + with patch("sys.stderr", output): + self.assertEqual(_wait_for_optimized_gamescope(process, status), expected) + issues = runtime_issue_summary(output.getvalue()) + self.assertTrue(issues["gamescope_child_status_missing"]) + self.assertEqual(issues["gamescope_exit_code"], compositor_status) + process.terminate.assert_not_called() + + def test_optional_runtime_warnings_are_distinct_from_presentation_failures(self) -> None: + output = ( + "sd_bus_call(g_dbus, message, UINT64_MAX, NULL, &reply)failed with ret -30\n" * 2 + + "No CAP_SYS_NICE, falling back to regular-priority compute and threads.\n" + + "Proton: Error: unable to use parent for game drive, path \n" + + "Game window ready: PristonTale EU V23.86.96\n" + ) + report = runtime_issue_summary(output) + self.assertEqual(report["gamescope_memory_protection_warning_count"], 2) + self.assertTrue(report["gamescope_priority_fallback"]) + self.assertTrue(report["proton_game_drive_warning"]) + self.assertTrue(report["game_window_ready"]) + self.assertFalse(report["gamescope_child_status_missing"]) + self.assertFalse(report["gamescope_forced_cleanup"]) + self.assertEqual(report["gamescope_vulkan_output_failed_count"], 0) + + def test_actual_child_receives_literal_arguments_prefix_and_renderer(self) -> None: + fake_umu = self.root / "fake umu-run" + fake_umu.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "print(json.dumps({'argv': sys.argv[1:], 'cwd': os.getcwd(), " + "'prefix': os.environ['WINEPREFIX'], 'renderer': os.environ.get('PROTON_USE_WINED3D'), " + "'verb': os.environ['PROTON_VERB']}))\n" + ) + fake_umu.chmod(0o755) + selection = replace(self.selection, executable=str(fake_umu)) + arguments = ("NEW_TOKEN", "", r"C:\game data\assets", "$(touch should-not-exist)") + option = DepotStartOption("Babel", "Game.exe", subprocess.list2cmdline(arguments)) + runner = CommandRunner(self.root / "commands.jsonl") + with redirect_stdout(io.StringIO()): + outcome = run_game(self.paths, Settings(renderer="wined3d"), selection, runner, + start_option=option, use_gamebox=False, timeout_seconds=5) + child = json.loads(outcome.stdout) + self.assertEqual(child["argv"], [str(self.paths.runtime_view / "Game.exe"), *arguments]) + self.assertEqual(child["cwd"], str(self.paths.runtime_view)) + self.assertEqual(child["prefix"], str(self.paths.ge_proton_prefix)) + self.assertEqual(child["renderer"], "1") + self.assertEqual(child["verb"], "waitforexitandrun") + self.assertFalse((self.paths.runtime_view / "should-not-exist").exists()) + + def test_changed_renderer_or_sync_cannot_join_an_active_prefix(self) -> None: + run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False) + marker = (self.paths.state / "game-launch.json").read_bytes() + for settings in (Settings(renderer="wined3d"), Settings(proton_sync="esync")): + self.runner.run.reset_mock() + shared = MagicMock() + with self.assertRaisesRegex(RuntimeErrorPT, "close every client"): + run_game(self.paths, settings, self.selection, self.runner, + use_gamebox=False, concurrent_prefix_launch=True, before_start=shared) + self.runner.run.assert_not_called() + shared.assert_not_called() + self.assertEqual((self.paths.state / "game-launch.json").read_bytes(), marker) + + def test_preparation_finishes_before_shared_lease_and_spawn(self) -> None: + order = [] + def share() -> None: + self.assertTrue((self.paths.state / "game-launch.json").is_file()) + order.append("shared") + self.runner.run.side_effect = lambda *a, **kw: order.append("spawn") + run_game(self.paths, Settings(), self.selection, self.runner, use_gamebox=False, before_start=share) + self.assertEqual(order, ["shared", "spawn"]) + + def test_helpers_join_umu_prefix_without_waiting_for_game_exit(self) -> None: + for kind in ("umu", "ge-proton", "proton7-container"): + selection = RuntimeSelection(kind, "/fake/umu-run", "/Steam/proton", "/Steam") + run_runtime_prefix_command(self.paths, selection, self.runner, ["reg.exe", "query"]) + self.assertEqual(self.runner.run.call_args.kwargs["env_overrides"]["PROTON_VERB"], "runinprefix") + + def test_missing_selected_prefix_does_not_report_another_prefixs_components(self) -> None: + (self.paths.proton_prefix / "drive_c/windows").mkdir(parents=True) + self.assertIsNone(_selected_prefix(self.paths, {"runtime": {"kind": "ge-proton"}})) + + def test_windows_paths_and_long_game_names_are_detected_but_services_are_not(self) -> None: + process = self.root / "proc/1000" + process.mkdir(parents=True) + (process / "environ").write_bytes(f"WINEPREFIX={self.paths.proton_prefix}\0".encode()) + (process / "comm").write_text("FutureGameClien\n") + for command, expected in ( + (r"C:\Program Files (x86)\Pristontale EU\FutureGameClient.exe", True), + (str(self.paths.runtime_view / "FutureGameClient.exe"), True), + (r"C:\windows\system32\services.exe", False), + (str(self.paths.runtime_view) + "-other/Game.exe", False), + ): + (process / "cmdline").write_bytes(command.encode() + b"\0") + self.assertEqual(managed_game_process_is_active(self.paths.runtime_view, + (self.paths.proton_prefix,), process_root=self.root / "proc"), expected) + + def test_ptr_dlls_are_removed_before_preparing_a_new_stable_session(self) -> None: + source = self.paths.source_game + for name in ("Game.exe", "game.dll", "game.ini", "game/maps/WhisperingVale.rovug"): + path = source / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"future publisher data") + prepare_runtime_view(self.paths, source) + for name in ("bcrypt.dll", "bcrypt_orig.dll"): + (self.paths.runtime_view / name).write_bytes(b"PTR specific DLL") + prepare_runtime_view(self.paths, source) + self.assertFalse((self.paths.runtime_view / "bcrypt.dll").exists()) + self.assertFalse((self.paths.runtime_view / "bcrypt_orig.dll").exists()) + self.assertEqual((source / "game.dll").read_bytes(), b"future publisher data") + + def test_shared_ptr_preparation_checks_bytes_without_replacing_them(self) -> None: + proton = self.root / "proton/proton" + original = proton.parent / "dist/lib/wine/i386-windows/bcrypt.dll" + original.parent.mkdir(parents=True) + proton.touch() + pe = bytearray(128) + pe[:2] = b"MZ" + pe[60:64] = (64).to_bytes(4, "little") + pe[64:68] = b"PE\0\0" + pe[68:70] = (0x14c).to_bytes(2, "little") + pe[88:90] = (0x10b).to_bytes(2, "little") + original.write_bytes(pe) + proxy, target = stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton) + before = (proxy.stat().st_mtime_ns, target.stat().st_mtime_ns) + stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton, read_only=True) + self.assertEqual(before, (proxy.stat().st_mtime_ns, target.stat().st_mtime_ns)) + target.write_bytes(b"wrong version") + with self.assertRaises(PtrCompatibilityError): + stage_ptr_bcrypt_compatibility(self.paths.runtime_view, proton, read_only=True) + self.assertEqual(target.read_bytes(), b"wrong version") + self.assertEqual(ptr_bcrypt_dll_overrides("*bcrypt,foo=b"), "foo=b;bcrypt=n") + + +class Response: + def __init__(self, body: bytes) -> None: + self.body = io.BytesIO(body) + self.headers = {"Content-Length": str(len(body))} + self.status = 200 + def read(self, size: int = -1) -> bytes: + return self.body.read(size) + def close(self) -> None: + self.body.close() + + +class DepotRecoveryTests(unittest.TestCase): + def test_manifest_read_timeout_retries_and_closes_failed_response(self) -> None: + failed = MagicMock(headers={}) + failed.read.side_effect = TimeoutError("The read operation timed out") + opener = MagicMock(side_effect=[failed, Response(b'{"Build": 42}')]) + client = DepotClient(opener=opener, retry_delays=(0,), sleeper=lambda _: None) + self.assertEqual(client._get_json("https://cdn.example/manifest.json"), {"Build": 42}) + failed.close.assert_called_once() + self.assertEqual(opener.call_count, 2) + + def test_permanent_manifest_error_falls_back_without_repeated_requests(self) -> None: + opener = MagicMock(side_effect=HTTPError("https://cdn.example/manifest.json", 404, "missing", {}, None)) + client = DepotClient(opener=opener, retry_delays=(0, 0), sleeper=lambda _: None) + with self.assertRaises(DepotError): + client._get_json("https://cdn.example/manifest.json") + self.assertEqual(opener.call_count, 1) + + def test_timeout_cooldown_avoids_failed_mirror_on_following_files_then_expires(self) -> None: + body = b"verified data" + file = DepotFile("file.bin", 42, len(body), hashlib.sha1(body).hexdigest()) + urls = [] + def open_response(request: Request, *, timeout: float) -> Response: + urls.append(request.full_url) + if len(urls) == 1: + raise TimeoutError("The read operation timed out") + return Response(body) + client = DepotClient(opener=open_response, retry_delays=(0,), sleeper=lambda _: None) + client._candidate_servers = ("primary.example", "secondary.example") + with tempfile.TemporaryDirectory() as temporary, patch("ptlinux.depot.time.monotonic", return_value=100) as clock: + for name in ("first", "second"): + client.download_file("primary.example", file, Path(temporary) / name) + clock.return_value = 161 + client.download_file("primary.example", file, Path(temporary) / "third") + self.assertEqual([url.split("/")[2] for url in urls], + ["primary.example", "secondary.example", "secondary.example", "primary.example"]) + + def test_real_http_read_timeout_retains_small_chunk_and_resumes_verified_bytes(self) -> None: + body = b"a small initial block followed by the remaining verified data" + cut = 12 + requests = [] + recovered = threading.Event() + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args: object) -> None: + pass + def do_GET(self) -> None: + requests.append(self.headers.get("Range")) + if len(requests) == 1: + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body[:cut]) + self.wfile.flush() + recovered.wait(5) + else: + self.send_response(206) + self.send_header("Content-Length", str(len(body) - cut)) + self.send_header("Content-Range", f"bytes {cut}-{len(body)-1}/{len(body)}") + self.end_headers() + self.wfile.write(body[cut:]) + recovered.set() + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + def local_open(request: Request, *, timeout: float): + return urlopen(Request(f"http://127.0.0.1:{server.server_port}/data", + headers=dict(request.header_items())), timeout=timeout) + client = DepotClient(opener=local_open, timeout_seconds=0.2, retry_delays=(0,), sleeper=lambda _: None) + file = DepotFile("wind_a.TGA", 42, len(body), hashlib.sha1(body).hexdigest()) + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "wind_a.TGA" + destination.write_bytes(b"previous verified version") + client.download_file("cdn.example", file, destination) + self.assertEqual(destination.read_bytes(), body) + self.assertEqual(requests, [None, f"bytes={cut}-"]) + finally: + recovered.set() + server.shutdown() + server.server_close() + thread.join(5) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ptlinux_cli.py b/tests/test_ptlinux_cli.py new file mode 100644 index 0000000..c63a9fb --- /dev/null +++ b/tests/test_ptlinux_cli.py @@ -0,0 +1,1425 @@ +import contextlib +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from ptlinux.cli import ( + _configure_launcher_runtime, + _configured_download_options, + _launcher_update, + _native_update, + _doctor, + _current_game_crash, + _ensure_portable_umu_for_play, + _game_exit_metadata, + _play, + _play_locked, + _print_game_outcome, + _print_interrupted_game_log, + _runtime_issue_summary, + _source_game, + build_parser, + main, +) +from ptlinux.command import CommandOutcome +from ptlinux.application.commands.play import ( + game_log_telemetry_summary, + recommended_runtime_comparison, +) +from ptlinux.depot import ( + DepotIntegrityIssue, + DepotPreflightResult, + DepotSnapshot, + DepotStartOption, + DepotSyncResult, + cache_depot_launch_catalog, +) +from ptlinux.launcher import LauncherError +from ptlinux.doctor import ToolStatus +from ptlinux.paths import AppPaths +from ptlinux.runtime import RuntimeErrorPT, RuntimeSelection +from ptlinux.self_update import LauncherUpdateResult +from ptlinux.settings import Settings +from ptlinux.umu import UmuBootstrapError, UmuBootstrapResult +from ptlinux.window_diagnostics import ( + WINDOW_DIAGNOSTIC_PREFIX, + WINDOW_DIAGNOSTICS_SESSION_ENV, +) + + +class CliInterruptTests(unittest.TestCase): + def test_doctor_separates_optional_tools_from_launch_blockers(self) -> None: + report = MagicMock( + ok=False, + distribution="linuxmint", + version="22", + architecture="x86_64", + display_backend="x11", + tools=[ + ToolStatus("python-tk", None, True, "native dashboard"), + ToolStatus("gamescope", None, False, "optional scaling"), + ], + blockers=("python-tk is missing (native dashboard).",), + warnings=[], + package_hint="Install Python/Tk with APT.", + ) + stdout = io.StringIO() + with ( + patch("ptlinux.cli.inspect_host", return_value=report), + contextlib.redirect_stdout(stdout), + ): + returncode = _doctor(False) + + output = stdout.getvalue() + self.assertEqual(returncode, 1) + self.assertIn( + "Optional tools missing (these do not block launch): gamescope", + output, + ) + self.assertIn("Host readiness: BLOCKED", output) + self.assertIn("Blocking diagnostics:", output) + self.assertIn("python-tk is missing (native dashboard).", output) + + def test_interprets_umu_status_247_as_wrapped_sigkill(self) -> None: + self.assertEqual( + _game_exit_metadata(247, "umu"), + { + "exit_code": 137, + "raw_exit_code": 247, + "signal": 9, + "signal_name": "SIGKILL", + "signal_inferred": True, + "signal_inference": "umu_python_system_exit", + }, + ) + + def test_interprets_direct_proton_status_247_as_wrapped_sigkill(self) -> None: + self.assertEqual( + _game_exit_metadata(247, "proton7"), + { + "exit_code": 137, + "raw_exit_code": 247, + "signal": 9, + "signal_name": "SIGKILL", + "signal_inferred": True, + "signal_inference": "proton_python_system_exit", + }, + ) + + def test_launcher_update_accepts_a_non_installing_full_verification(self) -> None: + arguments = build_parser().parse_args(["launcher-update", "--verify"]) + + self.assertTrue(arguments.verify) + + def test_runtime_bootstrap_has_a_dedicated_install_command(self) -> None: + arguments = build_parser().parse_args(["runtime-bootstrap"]) + + self.assertEqual(arguments.command, "runtime-bootstrap") + + def test_container_host_has_an_explicit_repair_command(self) -> None: + arguments = build_parser().parse_args(["container-host", "--repair"]) + + self.assertEqual(arguments.command, "container-host") + self.assertTrue(arguments.repair) + + def test_forced_runtime_setup_emits_recovery_request_and_outcome(self) -> None: + telemetry = MagicMock() + runner = MagicMock(dry_run=False) + with ( + patch("ptlinux.cli.current_telemetry", return_value=telemetry), + patch("ptlinux.cli.configure_launcher_prefix", return_value=[]) as configure, + ): + result = _configure_launcher_runtime( + MagicMock(), + MagicMock(), + runner, + force_runtime=True, + ) + + self.assertEqual(result, []) + configure.assert_called_once() + events = [call.args[0] for call in telemetry.send_event.call_args_list] + self.assertEqual( + events, + [ + "runtime_reinstall_requested", + "runtime_repair_started", + "runtime_repair_success", + ], + ) + + def test_explicit_game_repair_emits_requested_started_and_success(self) -> None: + telemetry = MagicMock() + paths = MagicMock() + paths.source_game.exists.return_value = True + with ( + patch("ptlinux.cli.AppPaths.from_environment", return_value=paths), + patch( + "ptlinux.cli.initialize_shared_telemetry", + return_value=(telemetry, False), + ) as initialize_telemetry, + patch("ptlinux.cli._dispatch_command", return_value=0), + ): + returncode = main( + ["sync", "--repair"], + _manage_telemetry=True, + ) + + self.assertEqual(returncode, 0) + self.assertFalse( + initialize_telemetry.call_args.kwargs["report_launcher_lifecycle"] + ) + telemetry.mark_ready.assert_not_called() + events = [call.args[0] for call in telemetry.send_event.call_args_list] + self.assertEqual( + events, + ["repair_requested", "repair_started", "repair_success"], + ) + + def test_forced_runtime_setup_emits_failed_recovery_outcome(self) -> None: + telemetry = MagicMock() + runner = MagicMock(dry_run=False) + with ( + patch("ptlinux.cli.current_telemetry", return_value=telemetry), + patch( + "ptlinux.cli.configure_launcher_prefix", + side_effect=LauncherError("runtime setup failed"), + ), + self.assertRaisesRegex(LauncherError, "runtime setup failed"), + ): + _configure_launcher_runtime( + MagicMock(), + MagicMock(), + runner, + force_runtime=True, + ) + + events = [call.args[0] for call in telemetry.send_event.call_args_list] + self.assertEqual(events[-1], "runtime_repair_failed") + + def test_launcher_update_still_restarts_when_umu_must_retry(self) -> None: + update = LauncherUpdateResult( + changed=True, + current_version="0.8.3", + installed_version="0.8.4", + release_directory="/release/0.8.4", + previous_release="/release/0.8.3", + archive="/cache/update.tar.gz", + archive_sha256="a" * 64, + restart_required=True, + state_recorded=True, + warning=None, + ) + stderr = io.StringIO() + container_host = MagicMock( + ready=True, + broken_umu_profile_link=False, + repair_error=None, + recommendation=None, + ) + container_host.to_dict.return_value = {"ready": True} + with ( + patch("ptlinux.cli.install_launcher_update", return_value=update), + patch( + "ptlinux.cli.provision_umu", + side_effect=UmuBootstrapError("network unavailable"), + ), + patch("ptlinux.cli.find_umu_launcher", return_value=None), + patch("ptlinux.cli.repair_container_host", return_value=container_host), + patch("ptlinux.cli._print_operation_status"), + patch("ptlinux.cli._emit") as emit, + contextlib.redirect_stderr(stderr), + ): + returncode = _launcher_update( + MagicMock(), + False, + False, + False, + None, + None, + False, + ) + + self.assertEqual(returncode, 0) + self.assertIn("network unavailable", stderr.getvalue()) + payload = emit.call_args.args[0] + self.assertTrue(payload["ok"]) + self.assertIn("network unavailable", payload["runtime_bootstrap"]["warning"]) + + def test_login_diagnostics_accepts_a_read_only_runtime_override(self) -> None: + arguments = build_parser().parse_args( + ["login-diagnostics", "--runtime", "proton7"] + ) + + self.assertEqual(arguments.runtime, "proton7") + + def test_play_accepts_an_official_realm_name(self) -> None: + arguments = build_parser().parse_args(["play", "--realm", "Seasonal"]) + + self.assertEqual(arguments.realm, "Seasonal") + + def test_keyboard_interrupt_returns_130_without_a_traceback(self) -> None: + stderr = io.StringIO() + with ( + patch("ptlinux.cli._play", side_effect=KeyboardInterrupt), + contextlib.redirect_stderr(stderr), + ): + returncode = main(["play"]) + + self.assertEqual(returncode, 130) + self.assertIn("compatibility runtime were stopped", stderr.getvalue()) + self.assertNotIn("Traceback", stderr.getvalue()) + + def test_json_interrupt_is_machine_readable(self) -> None: + stdout = io.StringIO() + with ( + patch("ptlinux.cli._play", side_effect=KeyboardInterrupt), + contextlib.redirect_stdout(stdout), + ): + returncode = main(["--json", "play"]) + + self.assertEqual(returncode, 130) + self.assertEqual(json.loads(stdout.getvalue())["interrupted"], True) + + def test_play_preflight_interrupt_is_telemetry_cancellation(self) -> None: + telemetry = MagicMock() + stderr = io.StringIO() + with ( + patch( + "ptlinux.cli.initialize_shared_telemetry", + return_value=(telemetry, False), + ), + patch("ptlinux.cli._play", side_effect=KeyboardInterrupt), + contextlib.redirect_stderr(stderr), + ): + returncode = main(["play"], _manage_telemetry=True) + + self.assertEqual(returncode, 130) + events = [call.args[0] for call in telemetry.send_event.call_args_list] + self.assertEqual(events, ["game_launch_cancelled"]) + cancelled = telemetry.send_event.call_args + self.assertEqual(cancelled.kwargs["severity"], "info") + self.assertTrue(cancelled.kwargs["metadata"]["interrupted"]) + self.assertEqual(cancelled.kwargs["metadata"]["launcher_exit_code"], 130) + self.assertEqual( + cancelled.kwargs["context"], + {"operation": "launch_game", "stage": "preflight"}, + ) + + def test_genuine_play_failure_keeps_its_runtime_stage(self) -> None: + telemetry = MagicMock() + stderr = io.StringIO() + with ( + patch( + "ptlinux.cli.initialize_shared_telemetry", + return_value=(telemetry, False), + ), + patch( + "ptlinux.cli._play", + side_effect=RuntimeErrorPT("Proton runtime is unavailable"), + ), + contextlib.redirect_stderr(stderr), + ): + returncode = main(["play"], _manage_telemetry=True) + + self.assertEqual(returncode, 1) + failure = next( + call + for call in telemetry.send_event.call_args_list + if call.args[0] == "game_launch_failed" + ) + self.assertEqual(failure.kwargs["severity"], "error") + self.assertEqual( + failure.kwargs["context"], + {"operation": "launch_game", "stage": "runtime_check"}, + ) + + def test_launcher_update_writable_release_uses_filesystem_stage(self) -> None: + telemetry = MagicMock() + stderr = io.StringIO() + with ( + patch( + "ptlinux.cli.initialize_shared_telemetry", + return_value=(telemetry, False), + ), + patch( + "ptlinux.cli._launcher_update", + side_effect=RuntimeError( + "launcher release path is group/world writable: release/__pycache__" + ), + ), + contextlib.redirect_stderr(stderr), + ): + returncode = main(["launcher-update"], _manage_telemetry=True) + + self.assertEqual(returncode, 1) + failure = next( + call + for call in telemetry.send_event.call_args_list + if call.args[0] == "update_failed" + ) + self.assertEqual( + failure.kwargs["context"], + {"operation": "launcher_update", "stage": "filesystem"}, + ) + + +class CliGameSourceTests(unittest.TestCase): + def test_persisted_import_source_does_not_redirect_play(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + settings = Settings(game_source=str(root / "publisher-game")) + + self.assertEqual(_source_game(paths, settings, None), paths.source_game) + + def test_explicit_diagnostic_game_directory_still_takes_precedence(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + explicit = root / "diagnostic-game" + + self.assertEqual( + _source_game(paths, Settings(), explicit), + explicit.resolve(), + ) + + +class CliDownloadSettingsTests(unittest.TestCase): + def test_default_download_settings_need_no_override(self) -> None: + self.assertIsNone(_configured_download_options(Settings())) + + def test_saved_download_controls_are_forwarded_to_the_depot(self) -> None: + options = _configured_download_options( + Settings( + download_speed_limit_mbps=10.0, + low_bandwidth_mode=True, + ) + ) + + self.assertIsNotNone(options) + assert options is not None + self.assertEqual(options.bandwidth_limit_bytes_per_second, 10_000_000) + self.assertEqual(options.effective_bandwidth_limit, 5_000_000) + self.assertTrue(options.low_bandwidth) + + +class CliPlayLockTests(unittest.TestCase): + def test_play_passes_its_preparation_lease_to_the_game_launch(self) -> None: + paths = MagicMock() + lease = MagicMock() + lease.preparation_allowed = True + guard = MagicMock() + guard.__enter__.return_value = lease + guard.__exit__.return_value = False + with ( + patch("ptlinux.cli._play_guard", return_value=guard) as play_guard, + patch("ptlinux.cli._play_locked", return_value=7) as play_locked, + ): + result = _play( + paths, + None, + False, + False, + False, + False, + ) + + play_guard.assert_called_once_with(paths, False) + guard.__enter__.assert_called_once_with() + guard.__exit__.assert_called_once() + play_locked.assert_called_once_with( + paths, + None, + False, + False, + False, + False, + realm=None, + reuse_prepared=False, + before_game=lease.allow_shared_clients, + game_lease_fds=lease.inherited_fds, + ) + self.assertEqual(result, 7) + + def test_running_game_interrupt_emits_one_cancellation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + paths.runtime_view.mkdir(parents=True) + selection = RuntimeSelection("umu", "umu-run", None, None) + telemetry = MagicMock() + + def interrupt_after_window_ready( + *_args: object, + **kwargs: object, + ) -> CommandOutcome: + output_filter = kwargs["output_line_filter"] + assert callable(output_filter) + output_filter("Game window ready: PristonTale EU\n") + raise KeyboardInterrupt + + def play_until_interrupted(*_args: object, **_kwargs: object) -> int: + return _play_locked( + paths, + None, + False, + False, + False, + False, + reuse_prepared=True, + ) + + stderr = io.StringIO() + with ( + patch("ptlinux.cli.AppPaths.from_environment", return_value=paths), + patch( + "ptlinux.cli.initialize_shared_telemetry", + return_value=(telemetry, False), + ), + patch("ptlinux.cli.current_telemetry", return_value=telemetry), + patch("ptlinux.cli._play", side_effect=play_until_interrupted), + patch( + "ptlinux.cli.game_import_requires_verification", + return_value=False, + ), + patch("ptlinux.cli._load_or_default_settings", return_value=Settings()), + patch("ptlinux.cli.select_runtime", return_value=selection), + patch("ptlinux.cli._runner", return_value=MagicMock()), + patch("ptlinux.cli.game_runtime_ready", return_value=True), + patch("ptlinux.cli.snapshot_native_logs", return_value={}), + patch("ptlinux.cli.run_game", side_effect=interrupt_after_window_ready), + patch("ptlinux.cli._current_game_log", return_value=None), + patch("ptlinux.cli._print_operation_status"), + contextlib.redirect_stderr(stderr), + ): + returncode = main(["play"], _manage_telemetry=True) + + self.assertEqual(returncode, 130) + events = [call.args[0] for call in telemetry.send_event.call_args_list] + self.assertEqual(events.count("game_launch_cancelled"), 1) + self.assertNotIn("game_launch_failed", events) + self.assertNotIn("game_exited", events) + cancelled = next( + call + for call in telemetry.send_event.call_args_list + if call.args[0] == "game_launch_cancelled" + ) + self.assertEqual(cancelled.kwargs["severity"], "info") + self.assertEqual(cancelled.kwargs["context"]["stage"], "running") + self.assertTrue(cancelled.kwargs["metadata"]["launch_succeeded"]) + self.assertTrue(cancelled.kwargs["metadata"]["game_window_ready"]) + self.assertEqual( + cancelled.kwargs["metadata"]["readiness_signal"], + "window_ready", + ) + + def test_play_resolves_ptr_babel_from_the_official_catalog(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + paths.runtime_view.mkdir(parents=True) + cache_depot_launch_catalog( + paths.depot_launch_catalog_cache, + DepotSnapshot( + region="Europe", + server="cdn.example/pristontale-eu", + name="PristonTale EU", + build=10, + version="10.0.0", + files=(), + deleted_files=(), + create_if_missing=(), + start_options=( + DepotStartOption("Babel", "Game.exe", "PTEU_REBORN"), + DepotStartOption( + "Test Babel", + "GameSe.exe", + "PTEU_REBORN", + ), + ), + default_start_option="Babel", + ), + ) + selection = RuntimeSelection("umu", "umu-run", None, None) + outcome = CommandOutcome([], 0, "", "", 0.0, False, False) + + with ( + patch("ptlinux.cli.game_import_requires_verification", return_value=False), + patch("ptlinux.cli._load_or_default_settings", return_value=Settings()), + patch("ptlinux.cli.prepare_runtime_view", return_value=None), + patch( + "ptlinux.cli._ensure_portable_umu_for_play", + return_value=None, + ) as ensure_umu, + patch( + "ptlinux.cli.select_runtime", + return_value=selection, + ) as select_runtime_mock, + patch("ptlinux.cli._runner", return_value=MagicMock()), + patch("ptlinux.cli.prepare_game_runtime"), + patch("ptlinux.cli.snapshot_native_logs", return_value={}), + patch("ptlinux.cli.run_game", return_value=outcome) as run_game, + patch("ptlinux.cli._current_game_log", return_value=None), + patch("ptlinux.cli._emit"), + ): + result = _play_locked( + paths, + None, + False, + False, + False, + False, + realm="PTR Babel", + ) + + self.assertEqual(result, 0) + self.assertEqual( + run_game.call_args.kwargs["start_option"], + DepotStartOption("PTR Babel", "GameSe.exe", "PTEU_REBORN"), + ) + effective_settings = run_game.call_args.args[1] + self.assertEqual(effective_settings.realm, "PTR Babel") + self.assertEqual(effective_settings.runtime, "proton7") + ensure_umu.assert_called_once_with( + paths, + effective_settings, + dry_run=False, + ) + self.assertEqual(select_runtime_mock.call_args.args[0], effective_settings) + self.assertFalse(run_game.call_args.kwargs["check"]) + + def test_additional_client_reuses_runtime_without_mutating_it(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + paths.runtime_view.mkdir(parents=True) + selection = RuntimeSelection("umu", "umu-run", None, None) + outcome = CommandOutcome([], 0, "", "", 0.0, False, False) + launch_order: list[str] = [] + + def before_game() -> None: + launch_order.append("shared lease") + + def run_game(*_args: object, **_kwargs: object) -> CommandOutcome: + _kwargs["before_start"]() + launch_order.append("game") + return outcome + + with ( + patch("ptlinux.cli.game_import_requires_verification", return_value=False), + patch("ptlinux.cli._load_or_default_settings", return_value=Settings()), + patch("ptlinux.cli._ensure_portable_umu_for_play", return_value=None), + patch("ptlinux.cli.select_runtime", return_value=selection), + patch("ptlinux.cli._runner", return_value=MagicMock()), + patch("ptlinux.cli.game_runtime_ready", return_value=True), + patch("ptlinux.cli.prepare_runtime_view") as prepare_view, + patch("ptlinux.cli.prepare_game_runtime") as prepare_runtime, + patch("ptlinux.cli.snapshot_native_logs", return_value={}), + patch("ptlinux.cli.run_game", side_effect=run_game) as run_game_mock, + patch("ptlinux.cli._current_game_log", return_value=None), + patch("ptlinux.cli._emit"), + ): + result = _play_locked( + paths, + None, + False, + False, + False, + False, + reuse_prepared=True, + before_game=before_game, + game_lease_fds=(17,), + ) + + prepare_view.assert_not_called() + prepare_runtime.assert_not_called() + self.assertEqual(launch_order, ["shared lease", "game"]) + self.assertTrue( + run_game_mock.call_args.kwargs["concurrent_prefix_launch"] + ) + self.assertEqual(run_game_mock.call_args.kwargs["pass_fds"], (17,)) + self.assertEqual(result, 0) + + def test_live_ready_window_survives_truncated_nonzero_process_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + paths.runtime_view.mkdir(parents=True) + selection = RuntimeSelection("umu", "umu-run", None, None) + outcome = CommandOutcome( + [], + 247, + "", + "GStreamer-WARNING: final retained output after the readiness marker\n", + 221.0, + False, + False, + ) + telemetry = MagicMock() + events_before_process_exit: list[str] = [] + + def run_game_with_ready_signal( + *_args: object, + **kwargs: object, + ) -> CommandOutcome: + output_filter = kwargs["output_line_filter"] + assert callable(output_filter) + extra_environment = kwargs["extra_environment"] + assert isinstance(extra_environment, dict) + diagnostic_session = extra_environment[ + WINDOW_DIAGNOSTICS_SESSION_ENV + ] + diagnostic_line = WINDOW_DIAGNOSTIC_PREFIX + json.dumps( + { + "schema": 1, + "component": "gamescope_host", + "diagnostic": "host_inactive_remap", + "sequence": 3, + "elapsed_ms": 1200, + "session_id": diagnostic_session, + "details": { + "wm_state": "normal", + "minimize_request_accepted": True, + }, + } + ) + "\n" + spoofed_diagnostic_line = diagnostic_line.replace( + diagnostic_session, + "0" * 32, + ) + self.assertEqual( + output_filter(spoofed_diagnostic_line), + spoofed_diagnostic_line, + ) + self.assertIsNone(output_filter(diagnostic_line)) + self.assertEqual( + output_filter( + "vulkan: physical device queue doesn't support " + "presenting on our surface\n" + ), + "vulkan: physical device queue doesn't support " + "presenting on our surface\n", + ) + output_filter( + "vulkan: physical device queue doesn't support " + "presenting on our surface\n" + ) + self.assertEqual( + output_filter("Game window ready: PristonTale EU\n"), + "Game window ready: PristonTale EU\n", + ) + output_filter( + "Game window resizing is disabled to prevent repeated " + "Direct3D device resets.\n" + ) + events_before_process_exit.extend( + call.args[0] for call in telemetry.send_event.call_args_list + ) + return outcome + + with ( + patch("ptlinux.cli.current_telemetry", return_value=telemetry), + patch("ptlinux.cli.game_import_requires_verification", return_value=False), + patch("ptlinux.cli._load_or_default_settings", return_value=Settings()), + patch("ptlinux.cli.select_runtime", return_value=selection), + patch("ptlinux.cli._runner", return_value=MagicMock()), + patch("ptlinux.cli.game_runtime_ready", return_value=True), + patch("ptlinux.cli.snapshot_native_logs", return_value={}), + patch("ptlinux.cli.run_game", side_effect=run_game_with_ready_signal), + patch("ptlinux.cli._current_game_log", return_value=None), + patch("ptlinux.cli._current_game_crash", return_value=None), + patch("ptlinux.cli._emit"), + ): + result = _play_locked( + paths, + None, + False, + False, + False, + True, + reuse_prepared=True, + ) + + diagnostic_files = list(paths.logs.glob("windows-*.jsonl")) + self.assertEqual(len(diagnostic_files), 1) + diagnostics = [json.loads(line) for line in diagnostic_files[0].read_text().splitlines()] + self.assertEqual(len(diagnostics), 1) + self.assertEqual(diagnostics[0]["diagnostic"], "host_inactive_remap") + self.assertNotEqual(diagnostics[0]["session_id"], "0" * 32) + + self.assertEqual(result, 1) + events = [call.args[0] for call in telemetry.send_event.call_args_list] + self.assertIn("game_launch_success", events_before_process_exit) + self.assertIn("game_window_diagnostic", events_before_process_exit) + self.assertNotIn("game_exited", events_before_process_exit) + self.assertIn("game_launch_success", events) + self.assertIn("game_exited", events) + self.assertNotIn("game_launch_failed", events) + self.assertEqual(events.count("game_launch_success"), 1) + self.assertEqual(events.count("game_window_diagnostic"), 2) + self.assertLess(events.index("game_launch_success"), events.index("game_exited")) + launch_call = next( + call + for call in telemetry.send_event.call_args_list + if call.args[0] == "game_launch_success" + ) + self.assertEqual(launch_call.kwargs["context"]["stage"], "ready") + self.assertEqual( + launch_call.kwargs["metadata"]["readiness_signal"], + "window_ready", + ) + exit_call = next( + call + for call in telemetry.send_event.call_args_list + if call.args[0] == "game_exited" + ) + self.assertEqual(exit_call.kwargs["severity"], "warning") + self.assertEqual( + exit_call.kwargs["message"], + "Game session ended with status 247, consistent with SIGKILL wrapped by UMU", + ) + self.assertEqual(exit_call.kwargs["metadata"]["exit_code"], 137) + self.assertEqual(exit_call.kwargs["metadata"]["raw_exit_code"], 247) + self.assertEqual(exit_call.kwargs["metadata"]["signal"], 9) + self.assertTrue(exit_call.kwargs["metadata"]["game_window_ready"]) + self.assertEqual( + exit_call.kwargs["metadata"]["readiness_signal"], + "window_ready", + ) + self.assertNotIn( + "Game window resizing", + exit_call.kwargs["metadata"]["stderr"], + ) + diagnostic_call = next( + call + for call in telemetry.send_event.call_args_list + if call.args[0] == "game_window_diagnostic" + and call.kwargs["metadata"]["diagnostic"] == "host_inactive_remap" + ) + self.assertEqual(diagnostic_call.kwargs["severity"], "warning") + self.assertEqual( + diagnostic_call.kwargs["metadata"]["diagnostic"], + "host_inactive_remap", + ) + self.assertEqual( + diagnostic_call.kwargs["metadata"]["session_id"], + exit_call.kwargs["metadata"]["launch_session_id"], + ) + presentation_call = next( + call + for call in telemetry.send_event.call_args_list + if call.args[0] == "game_window_diagnostic" + and call.kwargs["metadata"]["diagnostic"] + == "gamescope_present_queue_unsupported" + ) + self.assertEqual(presentation_call.kwargs["severity"], "warning") + self.assertEqual( + presentation_call.kwargs["context"]["stage"], + "presentation", + ) + + def test_new_crash_dump_makes_a_zero_status_launch_fail(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + paths.runtime_view.mkdir(parents=True) + selection = RuntimeSelection("umu", "umu-run", None, None) + start_option = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN") + outcome = CommandOutcome([], 0, "", "", 1.0, False, False) + crash = { + "path": str(root / "crash" / "crash_game.dat"), + "modified_at": "2026-09-05T11:07:07+02:00", + "size": 231_320, + } + native_log = { + "path": str(root / "logs" / "log_current.txt"), + "size": 4096, + "runtime_out_of_memory": True, + "out_of_memory_count": 2, + "out_of_memory_effect_paths": [ + r"game\scripts\shaders\UI\Minimap.fx", + ], + } + crash_attachment = { + "schema": 1, + "kind": "windows_minidump", + "encoding": "gzip+base64url", + "original_size": 231_320, + "compressed_size": 24_000, + "sha256": "a" * 64, + "data_base64url": "A" * 32_000, + } + telemetry = MagicMock() + telemetry.enabled = True + + with ( + patch("ptlinux.cli.current_telemetry", return_value=telemetry), + patch("ptlinux.cli.game_import_requires_verification", return_value=False), + patch( + "ptlinux.cli._load_or_default_settings", + return_value=Settings(game_crash_dump_upload_enabled=True), + ), + patch("ptlinux.cli.select_depot_start_option", return_value=start_option), + patch("ptlinux.cli.read_current_depot_file_catalog", return_value=None), + patch("ptlinux.cli.select_runtime", return_value=selection), + patch("ptlinux.cli._runner", return_value=MagicMock()), + patch("ptlinux.cli.game_runtime_ready", return_value=True), + patch("ptlinux.cli.snapshot_native_logs", return_value={}), + patch("ptlinux.cli.run_game", return_value=outcome), + patch("ptlinux.cli._current_game_log", return_value=native_log), + patch("ptlinux.cli._current_game_crash", return_value=crash), + patch( + "ptlinux.application.commands.play.prepare_minidump_attachment", + return_value=("included", crash_attachment), + ), + patch("ptlinux.cli._emit") as emit, + ): + result = _play_locked( + paths, + None, + False, + False, + False, + True, + reuse_prepared=True, + ) + + self.assertEqual(result, 1) + payload = emit.call_args.args[0] + self.assertFalse(payload["ok"]) + self.assertEqual(payload["current_game_crash"], crash) + exit_call = next( + call + for call in telemetry.send_event.call_args_list + if call.args[0] == "game_exited" + ) + self.assertEqual( + exit_call.kwargs["metadata"]["crash_dump_info"], + { + "modified_at": "2026-09-05T11:07:07+02:00", + "size": 231_320, + }, + ) + self.assertEqual( + exit_call.kwargs["metadata"]["failure_reason"], + "direct3d_resource_exhaustion", + ) + self.assertEqual( + exit_call.kwargs["metadata"]["crash_dump_upload_status"], + "included", + ) + self.assertEqual(exit_call.kwargs["attachment"], crash_attachment) + self.assertEqual( + exit_call.kwargs["message"], + "Game client logged Direct3D resource exhaustion and wrote a crash dump", + ) + self.assertEqual( + exit_call.kwargs["metadata"]["native_game_log"], + { + "size": 4096, + "runtime_out_of_memory": True, + "out_of_memory_count": 2, + "out_of_memory_effect_paths": [ + r"game\scripts\shaders\UI\Minimap.fx", + ], + }, + ) + + def test_zero_status_resource_exhaustion_is_reported_as_degraded(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + paths.runtime_view.mkdir(parents=True) + selection = RuntimeSelection("umu", "umu-run", None, None) + start_option = DepotStartOption("Babel", "Game.exe", "PTEU_REBORN") + outcome = CommandOutcome([], 0, "", "", 1.0, False, False) + native_log = { + "path": str(root / "logs" / "log_current.txt"), + "size": 4096, + "runtime_out_of_memory": True, + "out_of_memory_count": 4, + "out_of_memory_effect_paths": [ + r"game\scripts\shaders\UI\Minimap.fx", + ], + } + telemetry = MagicMock() + + with ( + patch("ptlinux.cli.current_telemetry", return_value=telemetry), + patch("ptlinux.cli.game_import_requires_verification", return_value=False), + patch("ptlinux.cli._load_or_default_settings", return_value=Settings()), + patch("ptlinux.cli.select_depot_start_option", return_value=start_option), + patch("ptlinux.cli.read_current_depot_file_catalog", return_value=None), + patch("ptlinux.cli.select_runtime", return_value=selection), + patch("ptlinux.cli._runner", return_value=MagicMock()), + patch("ptlinux.cli.game_runtime_ready", return_value=True), + patch("ptlinux.cli.snapshot_native_logs", return_value={}), + patch("ptlinux.cli.run_game", return_value=outcome), + patch("ptlinux.cli._current_game_log", return_value=native_log), + patch("ptlinux.cli._current_game_crash", return_value=None), + patch("ptlinux.cli._emit") as emit, + ): + result = _play_locked( + paths, + None, + False, + False, + False, + True, + reuse_prepared=True, + ) + + self.assertEqual(result, 0) + payload = emit.call_args.args[0] + self.assertTrue(payload["ok"]) + self.assertTrue(payload["degraded"]) + self.assertEqual( + payload["session_issue"], + "direct3d_resource_exhaustion", + ) + exit_call = next( + call + for call in telemetry.send_event.call_args_list + if call.args[0] == "game_exited" + ) + self.assertEqual(exit_call.kwargs["severity"], "warning") + self.assertEqual( + exit_call.kwargs["message"], + "Game client exited after logging Direct3D resource exhaustion", + ) + self.assertTrue(exit_call.kwargs["metadata"]["clean_exit"]) + self.assertTrue(exit_call.kwargs["metadata"]["degraded"]) + self.assertEqual( + exit_call.kwargs["metadata"]["failure_reason"], + "direct3d_resource_exhaustion", + ) + self.assertEqual( + exit_call.kwargs["metadata"]["native_game_log"]["out_of_memory_count"], + 4, + ) + + def test_managed_play_stops_before_prepare_when_preflight_finds_damage(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + catalog = MagicMock(files=()) + preflight = DepotPreflightResult( + build=10, + files_total=1, + verified_files=0, + unverified_files=0, + changed_files_hashed=1, + issue_count=1, + issues=(DepotIntegrityIssue("game/data.bin", "hash mismatch"),), + duration_ms=2, + ) + + with ( + patch("ptlinux.cli.game_import_requires_verification", return_value=False), + patch("ptlinux.cli._load_or_default_settings", return_value=Settings()), + patch("ptlinux.cli.read_current_depot_file_catalog", return_value=catalog), + patch("ptlinux.cli.preflight_managed_game", return_value=preflight), + patch("ptlinux.cli.prepare_runtime_view") as prepare, + self.assertRaisesRegex(LauncherError, "game/data.bin: hash mismatch"), + ): + _play_locked( + paths, + None, + False, + False, + False, + False, + ) + + prepare.assert_not_called() + + +class CliImportVerificationTests(unittest.TestCase): + def test_pending_import_forces_full_repair_and_records_completion(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths( + data=root / "data", + config=root / "config", + state=root / "state", + cache=root / "cache", + ) + sync_result = DepotSyncResult( + region="Europe", + server="cdn.example", + name="PristonTale EU", + build=330, + version="330", + files_total=1, + files_planned=0, + bytes_planned=0, + files_downloaded=0, + bytes_downloaded=0, + files_deleted=0, + files_created=0, + files_quarantined=2, + quarantine_directory="/quarantine/import-1", + repair=True, + dry_run=False, + completed_at="2026-08-30T12:00:00+02:00", + ) + with ( + patch("ptlinux.cli._operation_guard", return_value=contextlib.nullcontext()), + patch("ptlinux.cli._load_or_default_settings", return_value=Settings()), + patch("ptlinux.cli._runner", return_value=MagicMock()), + patch("ptlinux.cli._maybe_import_game", return_value=None), + patch("ptlinux.cli.game_import_requires_verification", return_value=True), + patch("ptlinux.cli.sync_managed_game", return_value=sync_result) as sync, + patch("ptlinux.cli.mark_game_import_verified") as mark_verified, + patch("ptlinux.cli._emit"), + ): + result = _native_update( + paths, + False, + False, + None, + False, + True, + ) + + self.assertEqual(result, 0) + sync.assert_called_once_with( + paths, + repair=True, + quarantine_unmanaged=True, + dry_run=False, + progress=None, + ) + mark_verified.assert_called_once_with( + paths, + build=330, + files_quarantined=2, + quarantine_directory="/quarantine/import-1", + ) + + +class CliRuntimeIssueTests(unittest.TestCase): + def test_discovers_a_crash_dump_created_by_the_current_launch(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + crash_path = directory / "crash_Game_20260905_110706.dat" + crash_path.write_bytes(b"MDMP") + + report = _current_game_crash(directory, {}, 0) + + self.assertIsNotNone(report) + assert report is not None + self.assertEqual(report["path"], str(crash_path.absolute())) + self.assertEqual(report["size"], 4) + + def test_prints_resource_exhaustion_before_asset_repair_advice(self) -> None: + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + _print_interrupted_game_log( + { + "path": "/tmp/game.log", + "module_code_sync_error": False, + "runtime_out_of_memory": True, + "out_of_memory_effect_paths": [ + r"game\scripts\shaders\UI\Minimap.fx" + ], + } + ) + + output = stderr.getvalue() + self.assertIn("Direct3D resource exhaustion", output) + self.assertIn("Minimap.fx", output) + self.assertIn("depot repair will not fix", output) + + def test_human_failure_summary_does_not_replay_streamed_process_output(self) -> None: + outcome = CommandOutcome( + ["game"], + 9, + "already streamed stdout\n", + "already streamed stderr\n", + 1.0, + False, + False, + ) + issues = _runtime_issue_summary(outcome.stderr) + stderr = io.StringIO() + + with contextlib.redirect_stderr(stderr): + _print_game_outcome(outcome, None, issues) + + self.assertIn("exited with status 9", stderr.getvalue()) + self.assertNotIn("already streamed stderr", stderr.getvalue()) + + def test_crash_dump_overrides_a_zero_process_status(self) -> None: + outcome = CommandOutcome(["game"], 0, "", "", 1.0, False, False) + stderr = io.StringIO() + + with contextlib.redirect_stderr(stderr): + _print_game_outcome( + outcome, + None, + _runtime_issue_summary(""), + {"path": "/tmp/crash_game.dat"}, + ) + + self.assertIn("crashed and wrote a dump", stderr.getvalue()) + self.assertNotIn("exited normally", stderr.getvalue()) + + def test_resource_exhaustion_overrides_the_normal_exit_summary(self) -> None: + outcome = CommandOutcome(["game"], 0, "", "", 1.0, False, False) + stdout = io.StringIO() + stderr = io.StringIO() + native_log = { + "path": "/tmp/game.log", + "module_code_sync_error": False, + "runtime_out_of_memory": True, + "out_of_memory_effect_paths": [ + r"game\scripts\shaders\UI\Minimap.fx" + ], + } + + with ( + contextlib.redirect_stdout(stdout), + contextlib.redirect_stderr(stderr), + ): + _print_game_outcome( + outcome, + native_log, + _runtime_issue_summary(""), + ) + + combined = stdout.getvalue() + stderr.getvalue() + self.assertNotIn("exited normally", combined) + self.assertIn( + "status 0 after logging Direct3D resource exhaustion", + combined, + ) + self.assertIn("condition can recur", combined) + + def test_default_installs_the_verified_portable_umu_helper(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + paths = AppPaths(root / "data", root / "config", root / "state", root / "cache") + installed = UmuBootstrapResult( + path=str(paths.managed_umu_executable), + version="1.4.4", + source_url="https://example.invalid/umu.tar", + archive_sha256="a" * 64, + executable_sha256="b" * 64, + changed=True, + ) + with ( + patch("ptlinux.cli.provision_umu", return_value=installed) as ensure, + ): + result = _ensure_portable_umu_for_play( + paths, + Settings(), + dry_run=False, + ) + + self.assertEqual(result, installed) + ensure.assert_called_once_with(paths) + + def test_auto_falls_back_to_direct_proton_if_the_umu_download_fails(self) -> None: + paths = MagicMock() + with ( + patch( + "ptlinux.cli.provision_umu", + side_effect=UmuBootstrapError("network unavailable"), + ), + patch( + "ptlinux.cli.select_runtime", + return_value=RuntimeSelection("proton7", "python", "/proton", "/steam"), + ) as select, + contextlib.redirect_stderr(io.StringIO()), + ): + result = _ensure_portable_umu_for_play( + paths, + Settings(runtime="auto"), + dry_run=False, + ) + + self.assertIsNone(result) + self.assertEqual(select.call_args.args[0].runtime, "proton7") + + def test_auto_reports_when_neither_umu_nor_direct_proton_is_available(self) -> None: + paths = MagicMock() + with ( + patch( + "ptlinux.cli.provision_umu", + side_effect=UmuBootstrapError("network unavailable"), + ), + patch( + "ptlinux.cli.select_runtime", + side_effect=RuntimeErrorPT("Proton 7 missing"), + ), + self.assertRaisesRegex(RuntimeError, "no usable game runtime"), + ): + _ensure_portable_umu_for_play( + paths, + Settings(runtime="auto"), + dry_run=False, + ) + + def test_summarizes_arch_runtime_noise_without_calling_it_authentication(self) -> None: + report = _runtime_issue_summary( + "The futex facility returned an unexpected error code.\n" + "plugin.so: wrong ELF class: ELFCLASS64\n" + "winegstreamer error: Internal data stream error.\n" + "GStreamer-WARNING: Failed to load plugin 'libgstlibav.so': " + "libavfilter.so.7: cannot open shared object file: No such file or directory\n" + "GStreamer-WARNING: Failed to load plugin 'libgstsoup.so': " + "libsoup-2.4.so.1: cannot open shared object file: No such file or directory\n" + "egl: failed to create dri2 screen\n" + "vulkan: physical device queue doesn't support presenting on our surface\n" + "vulkan_make_output failed\n" + "SteamAPI_Init() failed; no appID found.\n" + "Assertion failed: module->module.SymType != SymDeferred, " + "file ../src-wine/dlls/dbghelp/module.c, line 383\n" + ) + + self.assertEqual(report["host_futex_fatal_count"], 1) + self.assertEqual(report["gstreamer_wrong_elf_class_count"], 1) + self.assertEqual(report["gstreamer_missing_library_count"], 2) + self.assertEqual( + report["gstreamer_missing_libraries"], + ["libavfilter.so.7", "libsoup-2.4.so.1"], + ) + self.assertTrue(report["gstreamer_stream_error"]) + self.assertTrue(report["mesa_egl_initialization_failed"]) + self.assertTrue(report["gamescope_present_queue_unsupported"]) + self.assertEqual(report["gamescope_vulkan_output_failed_count"], 1) + self.assertTrue(report["steam_api_app_id_missing"]) + self.assertEqual(report["dbghelp_symdeferred_assertion_count"], 1) + self.assertFalse(report["bubblewrap_uid_map_permission_denied"]) + self.assertFalse(report["game_window_ready"]) + + def test_recognizes_a_ready_game_window_after_runtime_noise(self) -> None: + report = _runtime_issue_summary( + "plugin.so: wrong ELF class: ELFCLASS64\n" + "Game window resizing is disabled to prevent repeated Direct3D device resets.\n" + ) + + self.assertTrue(report["game_window_ready"]) + + def test_summarizes_bubblewrap_uid_map_denial(self) -> None: + report = _runtime_issue_summary( + "pressure-vessel-wrap: bwrap: setting up uid map: Permission denied\n" + ) + + self.assertTrue(report["bubblewrap_uid_map_permission_denied"]) + + def test_native_log_telemetry_counts_asset_classifications_without_paths(self) -> None: + report = game_log_telemetry_summary( + { + "failed_texture_file_count": 1, + "asset_references": { + "total_events": 1, + "unique_valid_references": 1, + "invalid_reference_events": 0, + "catalog_available": True, + "managed_integrity_failed": False, + "managed_present_paths": [], + "managed_missing_paths": [], + "publisher_unmanaged_paths": [ + "game/images/UI/combobox/drop_big.png" + ], + "unclassified_paths": [], + }, + } + ) + + self.assertEqual(report["failed_texture_file_count"], 1) + self.assertEqual( + report["asset_reference_summary"], + { + "total_events": 1, + "unique_valid_references": 1, + "invalid_reference_events": 0, + "catalog_available": True, + "managed_integrity_failed": False, + "managed_present_count": 0, + "managed_missing_count": 0, + "publisher_unmanaged_count": 1, + "unclassified_count": 0, + }, + ) + self.assertNotIn("drop_big.png", json.dumps(report)) + + def test_successful_window_does_not_treat_plugin_scan_noise_as_a_runtime_fix(self) -> None: + self.assertIsNone( + recommended_runtime_comparison( + "proton7", + { + "game_window_ready": True, + "gstreamer_missing_libraries": ["libsoup-2.4.so.1"], + "gstreamer_stream_error": False, + }, + ) + ) + + def test_stream_failure_still_recommends_the_container_runtime(self) -> None: + self.assertEqual( + recommended_runtime_comparison( + "proton7", + { + "game_window_ready": True, + "gstreamer_missing_libraries": ["libsoup-2.4.so.1"], + "gstreamer_stream_error": True, + }, + ), + "proton7-container", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ptlinux_gamebox_runtime.py b/tests/test_ptlinux_gamebox_runtime.py new file mode 100644 index 0000000..30542ca --- /dev/null +++ b/tests/test_ptlinux_gamebox_runtime.py @@ -0,0 +1,2554 @@ +import contextlib +import ctypes +import io +import os +from pathlib import Path +import tempfile +import unittest +from collections.abc import Collection +from typing import Any +from unittest import mock + +# noinspection PyProtectedMember +from ptlinux.gamebox_runtime import ( + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS, + GAMESCOPE_HOST_FOCUS_STATE_ENV, + GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS, + GameboxRuntimeError, + WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS, + WINDOW_ACTIVATION_DELAY_SECONDS, + WINDOW_FOCUS_DELAY_SECONDS, + WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS, + WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS, + WINDOW_SCALING_REPAIR_LIMIT, + WINDOW_STARTUP_POLL_SECONDS, + WINDOW_SURFACE_REFRESH_DELAY_SECONDS, + WINDOW_SURFACE_SETTLE_SECONDS, + WINDOW_TITLE, + _X11, + _X_PROPERTY_CHANGE_MASK, + _X_PROPERTY_NOTIFY, + _enable_game_window_scaling, + _focus_game_window, + _gamescope_host_focus_is_allowed, + _guard_game_window_startup, + _maintain_game_window_focus, + _maintain_gamescope_game_window_focus, + _maintain_game_window_scaling, + _matches_game_window_title, + _motif_hints_with_scaling, + _normal_hints_without_fixed_size, + _normal_hints_with_resize_increments, + _process_exit_status, + _prepare_game_window, + _protocols_without, + _repair_game_window_focus, + _repair_gamescope_game_window_focus, + _resolve_gamescope_game_window, + _select_game_window, + run_game_window, +) + + +class GameboxRuntimeTests(unittest.TestCase): + def test_maps_signals_before_returning_through_python(self) -> None: + self.assertEqual(_process_exit_status(-9), 137) + self.assertEqual(_process_exit_status(7), 7) + + def test_rejects_empty_game_command(self) -> None: + with self.assertRaisesRegex(GameboxRuntimeError, "empty"): + run_game_window([]) + + def test_game_title_match_accepts_ptr_prefix_and_rejects_dialogs(self) -> None: + self.assertFalse( + _matches_game_window_title( + "Choose an existing PristonTale EU game directory", + "PristonTale EU", + ) + ) + self.assertFalse( + _matches_game_window_title("Launching PristonTale EU", "PristonTale EU") + ) + self.assertTrue( + _matches_game_window_title("PristonTale EU V21.75.83", "PristonTale EU") + ) + self.assertTrue( + _matches_game_window_title( + "PTR - PristonTale EU V21.78.89", + "PristonTale EU", + ) + ) + self.assertFalse( + _matches_game_window_title( + "PTR launcher for PristonTale EU", + "PristonTale EU", + ) + ) + + def test_selects_only_a_new_game_window(self) -> None: + windows = [ + (10, "PristonTale EU V21.75.83"), + (11, "Choose an existing PristonTale EU game directory"), + (12, "PristonTale EU V21.75.83"), + (13, "PristonTale EU V21.75.83"), + (14, "PristonTale EU V21.75.83"), + ] + self.assertEqual( + _select_game_window(windows, "PristonTale EU", {10, 12, 13}), + (14, "PristonTale EU V21.75.83"), + ) + + def test_resolves_a_window_manager_frame_to_its_client(self) -> None: + mapper = object.__new__(_X11) + children = { + 20: [21, 22], + 21: [], + 22: [23], + 23: [], + } + + with ( + mock.patch.object( + mapper, + "_has_wm_state", + side_effect=lambda window: window == 23, + ), + mock.patch.object( + mapper, + "_child_windows", + side_effect=lambda window: children[window], + ), + ): + self.assertEqual(mapper._client_window(20), 23) + + def test_retains_the_frame_when_no_client_marker_exists(self) -> None: + mapper = object.__new__(_X11) + + with ( + mock.patch.object(mapper, "_has_wm_state", return_value=False), + mock.patch.object(mapper, "_child_windows", return_value=[]), + ): + self.assertEqual(mapper._client_window(20), 20) + + def test_removes_only_wm_ping_from_window_protocols(self) -> None: + self.assertEqual(_protocols_without((10, 20, 30, 20), 20), (10, 30)) + + def test_adds_scaling_without_changing_other_explicit_motif_hints(self) -> None: + self.assertEqual( + _motif_hints_with_scaling((0x03, 0x0C, 0x3A, 7, 8)), + (0x03, 0x1E, 0x7E, 7, 8), + ) + + def test_adds_scaling_to_exclusive_motif_hints(self) -> None: + self.assertEqual( + _motif_hints_with_scaling((0x03, 0x11, 0x41, 0, 0)), + (0x03, 0x01, 0x01, 0, 0), + ) + + def test_relaxes_only_an_exact_fixed_size_pair(self) -> None: + hints = (0x30, 0, 0, 1600, 900, 1600, 900, 1600, 900, 1, 1) + + self.assertEqual( + _normal_hints_without_fixed_size(hints, (1600, 900)), + (0x00, *hints[1:]), + ) + self.assertEqual( + _normal_hints_without_fixed_size(hints, (1280, 720)), + (0x00, *hints[1:]), + ) + unrelated_range = (0x30, 0, 0, 800, 600, 640, 480, 1920, 1080) + self.assertEqual( + _normal_hints_without_fixed_size(unrelated_range, (1280, 720)), + unrelated_range, + ) + + def test_adds_resize_increments_without_losing_other_size_hints(self) -> None: + hints = (0x01, 1, 2, 3, 4, 320, 240, 0, 0, 0, 0) + + updated = _normal_hints_with_resize_increments( + hints, + (1280, 960), + (64, 48), + ) + + self.assertEqual(updated[0], 0x141) + self.assertEqual(updated[1:9], hints[1:9]) + self.assertEqual(updated[9:11], (64, 48)) + self.assertEqual(updated[15:17], (0, 0)) + self.assertEqual(len(updated), 18) + + def test_keeps_matching_resize_increments_without_rewriting_x11_hints(self) -> None: + hints = [0] * 18 + hints[0] = 0x140 + hints[9:11] = (64, 48) + hints[15:17] = (0, 0) + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.display = ctypes.c_void_p(1) + + with ( + mock.patch.object(mapper, "_long_property", return_value=tuple(hints)), + mock.patch.object(mapper, "_replace_long_property") as replace_property, + ): + self.assertTrue( + mapper.set_resize_increments(12, (1280, 960), (64, 48)) + ) + + replace_property.assert_not_called() + mapper.library.XSync.assert_not_called() + + def test_caches_successful_x11_atom_lookups(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.calls: list[tuple[bytes, int]] = [] + + def XInternAtom( + self, + _display: object, + name: bytes, + only_if_exists: int, + ) -> int: + self.calls.append((name, only_if_exists)) + return 41 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + self.assertEqual( + mapper._intern_atom(b"WM_STATE", only_if_exists=True), + 41, + ) + self.assertEqual( + mapper._intern_atom(b"WM_STATE", only_if_exists=False), + 41, + ) + self.assertEqual(library.calls, [(b"WM_STATE", 1)]) + + def test_retries_a_missing_x11_atom_lookup(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.calls: list[tuple[bytes, int]] = [] + + def XInternAtom( + self, + _display: object, + name: bytes, + only_if_exists: int, + ) -> int: + self.calls.append((name, only_if_exists)) + return 0 if len(self.calls) == 1 else 77 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + self.assertEqual( + mapper._intern_atom(b"_NET_ACTIVE_WINDOW", only_if_exists=True), + 0, + ) + self.assertEqual( + mapper._intern_atom(b"_NET_ACTIVE_WINDOW", only_if_exists=False), + 77, + ) + self.assertEqual( + mapper._intern_atom(b"_NET_ACTIVE_WINDOW", only_if_exists=True), + 77, + ) + self.assertEqual( + library.calls, + [(b"_NET_ACTIVE_WINDOW", 1), (b"_NET_ACTIVE_WINDOW", 0)], + ) + + def test_replaces_a_32_bit_window_property_without_losing_values(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.request: tuple[object, ...] | None = None + + def XInternAtom( + self, + _display: object, + name: bytes, + _only_if_exists: int, + ) -> int: + return { + b"WM_NORMAL_HINTS": 31, + b"WM_SIZE_HINTS": 32, + }[name] + + def XChangeProperty( + self, + _display: object, + window: int, + atom: int, + type_atom: int, + format_bits: int, + mode: int, + data: Any, + item_count: int, + ) -> int: + values = ctypes.cast(data, ctypes.POINTER(ctypes.c_ulong)) + self.request = ( + window, + atom, + type_atom, + format_bits, + mode, + tuple(values[index] for index in range(item_count)), + ) + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + mapper._replace_long_property( + 12, + b"WM_NORMAL_HINTS", + b"WM_SIZE_HINTS", + (0x10, 1600, 900), + ) + + self.assertEqual( + library.request, + (12, 31, 32, 32, 0, (0x10, 1600, 900)), + ) + + def test_requests_composited_presentation_for_a_host_window(self) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.display = ctypes.c_void_p(1) + + with ( + mock.patch.object(mapper, "_replace_long_property") as replace_property, + mock.patch.object(mapper, "_long_property", return_value=(2,)), + ): + self.assertTrue(mapper.prefer_composited_presentation(12)) + + replace_property.assert_not_called() + mapper.library.XSync.assert_not_called() + + def test_updates_a_noncomposited_host_window_once(self) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.display = ctypes.c_void_p(1) + + with ( + mock.patch.object(mapper, "_replace_long_property") as replace_property, + mock.patch.object(mapper, "_long_property", side_effect=[(1,), (2,)]), + ): + self.assertTrue(mapper.prefer_composited_presentation(12)) + + replace_property.assert_called_once_with( + 12, + b"_NET_WM_BYPASS_COMPOSITOR", + b"CARDINAL", + (2,), + ) + mapper.library.XSync.assert_called_once_with(mapper.display, 0) + + def test_wm_ping_suppression_preserves_every_other_protocol(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.protocols = (10, 20, 30) + self.buffers: list[ctypes.Array[ctypes.c_ulong]] = [] + self.assert_atom_name = b"" + + def XInternAtom(self, _display: object, name: bytes, _only_if_exists: int) -> int: + self.assert_atom_name = name + return 20 + + def XGetWMProtocols( + self, + _display: object, + _window: int, + protocols_return: Any, + count_return: Any, + ) -> int: + buffer = (ctypes.c_ulong * len(self.protocols))(*self.protocols) + self.buffers.append(buffer) + protocols_pointer = ctypes.cast( + protocols_return, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)), + ) + protocols_pointer[0] = ctypes.cast( + buffer, + ctypes.POINTER(ctypes.c_ulong), + ) + ctypes.cast( + count_return, + ctypes.POINTER(ctypes.c_int), + )[0] = len(self.protocols) + return 1 + + def XSetWMProtocols( + self, + _display: object, + _window: int, + protocols: Any, + count: int, + ) -> int: + protocol_pointer = ctypes.cast( + protocols, + ctypes.POINTER(ctypes.c_ulong), + ) + self.protocols = tuple( + int(protocol_pointer[index]) for index in range(count) + ) + return 1 + + def XSync(self, _display: object, _discard: int) -> int: + return 0 + + def XFree(self, _value: object) -> int: + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + self.assertTrue(mapper.suppress_wm_ping(12)) + self.assertEqual(library.assert_atom_name, b"_NET_WM_PING") + self.assertEqual(library.protocols, (10, 30)) + + def test_replaces_protons_generic_game_window_identity(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.identity: tuple[bytes, bytes] | None = None + + def XSetClassHint( + self, + _display: object, + _window: int, + hints_pointer: Any, + ) -> int: + hints = hints_pointer._obj + self.identity = (hints.res_name, hints.res_class) + return 1 + + def XSync(self, _display: object, _discard: int) -> int: + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + mapper.set_window_identity(12) + + self.assertEqual(library.identity, (b"pristontale-eu", b"PristonTaleEU")) + + def test_reads_icccm_state_and_motif_decorations(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.buffers: list[ctypes.Array[ctypes.c_ulong]] = [] + self.atoms = {b"WM_STATE": 10, b"_MOTIF_WM_HINTS": 20} + self.properties = {10: (3, 0), 20: (2, 0, 0, 0, 0)} + + def XInternAtom( + self, + _display: object, + name: bytes, + _only_if_exists: int, + ) -> int: + return self.atoms.get(name, 0) + + def XGetWindowProperty( + self, + _display: object, + _window: int, + property_atom: int, + _offset: int, + _length: int, + _delete: int, + _requested_type: int, + actual_type_return: Any, + actual_format_return: Any, + item_count_return: Any, + bytes_after_return: Any, + value_return: Any, + ) -> int: + values = self.properties[property_atom] + buffer = (ctypes.c_ulong * len(values))(*values) + self.buffers.append(buffer) + ctypes.cast( + actual_type_return, + ctypes.POINTER(ctypes.c_ulong), + )[0] = property_atom + ctypes.cast( + actual_format_return, + ctypes.POINTER(ctypes.c_int), + )[0] = 32 + ctypes.cast( + item_count_return, + ctypes.POINTER(ctypes.c_ulong), + )[0] = len(values) + ctypes.cast( + bytes_after_return, + ctypes.POINTER(ctypes.c_ulong), + )[0] = 0 + value_pointer = ctypes.cast( + value_return, + ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)), + ) + value_pointer[0] = ctypes.cast( + buffer, + ctypes.POINTER(ctypes.c_ubyte), + ) + return 0 + + def XFree(self, _value: object) -> int: + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + self.assertEqual(mapper.window_state(12), 3) + self.assertTrue(mapper.window_is_borderless(12)) + + library.properties[20] = (3, 0x3C, 0x7A, 0, 0) + self.assertFalse(mapper.window_is_borderless(12)) + + def test_detects_the_desktop_active_window(self) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.library.XDefaultRootWindow.return_value = 99 + mapper.display = ctypes.c_void_p(1) + + with mock.patch.object( + mapper, + "_long_property", + return_value=(12,), + ) as read_property: + self.assertTrue(mapper.window_is_active(12)) + + read_property.assert_called_once_with(99, b"_NET_ACTIVE_WINDOW", 1) + + def test_detects_fullscreen_or_maximized_ewmh_state(self) -> None: + mapper = object.__new__(_X11) + + with ( + mock.patch.object( + mapper, + "_long_property", + return_value=(17, 102), + ), + mock.patch.object( + mapper, + "_screen_filling_state_atoms", + return_value=(101, 102, 103), + ), + ): + self.assertTrue(mapper.window_is_fullscreen_or_maximized(12)) + + def test_force_windowed_state_removes_maximized_before_fullscreen(self) -> None: + mapper = object.__new__(_X11) + + with ( + mock.patch.object( + mapper, + "_long_property", + return_value=(101, 102, 103), + ), + mock.patch.object( + mapper, + "_screen_filling_state_atoms", + return_value=(101, 102, 103), + ), + mock.patch.object(mapper, "_intern_atom", return_value=100), + mock.patch.object( + mapper, + "_send_root_client_message", + return_value=True, + ) as send_message, + mock.patch.object( + mapper, + "_wait_for_windowed_state", + return_value=True, + ), + ): + self.assertTrue(mapper.force_windowed_state(12)) + + send_message.assert_has_calls( + [ + mock.call(12, 100, (0, 102, 103, 1, 0)), + mock.call(12, 100, (0, 101, 0, 1, 0)), + ] + ) + + def test_force_windowed_state_rejects_an_unconfirmed_request(self) -> None: + mapper = object.__new__(_X11) + + with ( + mock.patch.object( + mapper, + "_long_property", + return_value=(101,), + ), + mock.patch.object( + mapper, + "_screen_filling_state_atoms", + return_value=(101, 102, 103), + ), + mock.patch.object(mapper, "_intern_atom", return_value=100), + mock.patch.object( + mapper, + "_send_root_client_message", + return_value=True, + ), + mock.patch.object( + mapper, + "_wait_for_windowed_state", + return_value=False, + ), + self.assertRaisesRegex(GameboxRuntimeError, "did not confirm"), + ): + mapper.force_windowed_state(12) + + def test_enables_scaling_only_after_the_wm_confirms_all_actions(self) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.display = ctypes.c_void_p(1) + normal_hints = ( + 0x30, + 0, + 0, + 1600, + 900, + 1600, + 900, + 1600, + 900, + 1, + 1, + ) + motif_hints = (0x03, 0x0C, 0x3A, 0, 0) + + with ( + mock.patch.object(mapper, "window_allows_scaling", return_value=False), + mock.patch.object(mapper, "window_size", return_value=(1600, 900)), + mock.patch.object( + mapper, + "_long_property", + side_effect=[normal_hints, motif_hints], + ), + mock.patch.object(mapper, "_replace_long_property") as replace_property, + mock.patch.object( + mapper, + "_wait_for_scaling_actions", + return_value=True, + ), + ): + self.assertTrue(mapper.enable_window_scaling(12)) + + replace_property.assert_has_calls( + [ + mock.call( + 12, + b"WM_NORMAL_HINTS", + b"WM_SIZE_HINTS", + (0x00, *normal_hints[1:]), + ), + mock.call( + 12, + b"_MOTIF_WM_HINTS", + b"_MOTIF_WM_HINTS", + (0x03, 0x1E, 0x7E, 0, 0), + ), + ] + ) + self.assertNotIn( + b"_NET_WM_ALLOWED_ACTIONS", + (call.args[1] for call in replace_property.call_args_list), + ) + + def test_reapplies_scaling_hints_without_repeating_wm_confirmation(self) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.display = ctypes.c_void_p(1) + normal_hints = (0x30, 0, 0, 1600, 900, 1600, 900, 1600, 900) + motif_hints = (0x03, 0x0C, 0x3A, 0, 0) + + with ( + mock.patch.object(mapper, "window_size", return_value=(1600, 900)), + mock.patch.object( + mapper, + "_long_property", + side_effect=[normal_hints, motif_hints], + ), + mock.patch.object(mapper, "_replace_long_property") as replace_property, + mock.patch.object(mapper, "_wait_for_scaling_actions") as confirm_actions, + ): + self.assertTrue(mapper.enable_window_scaling(12, confirm=False)) + + self.assertEqual(replace_property.call_count, 2) + confirm_actions.assert_not_called() + mapper.library.XFlush.assert_called_once_with(mapper.display) + mapper.library.XSync.assert_not_called() + + def test_restores_original_hints_when_scaling_is_not_confirmed(self) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.display = ctypes.c_void_p(1) + normal_hints = (0x20, 0, 0, 1600, 900, 0, 0, 1600, 900) + motif_hints = (0x03, 0x0C, 0x3A, 0, 0) + + with ( + mock.patch.object(mapper, "window_allows_scaling", return_value=False), + mock.patch.object(mapper, "window_size", return_value=(1600, 900)), + mock.patch.object( + mapper, + "_long_property", + side_effect=[normal_hints, motif_hints], + ), + mock.patch.object(mapper, "_replace_long_property") as replace_property, + mock.patch.object( + mapper, + "_wait_for_scaling_actions", + return_value=False, + ), + ): + self.assertFalse(mapper.enable_window_scaling(12)) + + self.assertEqual(replace_property.call_count, 4) + self.assertEqual( + replace_property.call_args_list[-2:], + [ + mock.call( + 12, + b"_MOTIF_WM_HINTS", + b"_MOTIF_WM_HINTS", + motif_hints, + ), + mock.call( + 12, + b"WM_NORMAL_HINTS", + b"WM_SIZE_HINTS", + normal_hints, + ), + ] + ) + + def test_waits_for_wm_actions_when_client_hints_are_already_resizable( + self, + ) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.display = ctypes.c_void_p(1) + normal_hints = (0x04, 0, 0, 1280, 960, 0, 0, 0, 0) + + with ( + mock.patch.object(mapper, "window_size", return_value=(1280, 960)), + mock.patch.object( + mapper, + "_long_property", + side_effect=[normal_hints, None], + ), + mock.patch.object(mapper, "_replace_long_property") as replace_property, + mock.patch.object( + mapper, + "_wait_for_scaling_actions", + return_value=True, + ) as wait_for_actions, + ): + self.assertTrue(mapper.enable_window_scaling(12)) + + replace_property.assert_not_called() + mapper.library.XSync.assert_called_once_with(mapper.display, 0) + wait_for_actions.assert_called_once_with(12) + + def test_detects_native_resize_and_both_maximize_actions(self) -> None: + mapper = object.__new__(_X11) + + with ( + mock.patch.object(mapper, "_long_property", return_value=(7, 31, 32, 33)), + mock.patch.object(mapper, "_intern_atom", side_effect=[31, 32, 33]), + ): + self.assertTrue(mapper.window_allows_scaling(12)) + + def test_window_diagnostic_snapshot_explains_missing_maximize_actions(self) -> None: + mapper = object.__new__(_X11) + normal_hints = [0] * 18 + normal_hints[0] = (1 << 4) | (1 << 5) + normal_hints[5:9] = [1280, 960, 1280, 960] + + def read_property( + _window: int, + name: bytes, + _maximum_items: int, + ) -> tuple[int, ...] | None: + return { + b"_NET_WM_STATE": (102,), + b"_NET_WM_ALLOWED_ACTIONS": (31,), + b"WM_NORMAL_HINTS": tuple(normal_hints), + b"_MOTIF_WM_HINTS": (3, 2, 4, 0, 0), + }.get(name) + + with ( + mock.patch.object(mapper, "window_state", return_value=1), + mock.patch.object(mapper, "window_size", return_value=(1280, 960)), + mock.patch.object( + mapper, + "window_net_wm_state_flags", + return_value={ + "net_wm_state_present": True, + "fullscreen": False, + "maximized_horizontal": True, + "maximized_vertical": False, + "hidden": False, + "above": False, + "demands_attention": False, + "skip_taskbar": False, + }, + ), + mock.patch.object(mapper, "window_is_active", return_value=True), + mock.patch.object(mapper, "window_is_focused", return_value=True), + mock.patch.object(mapper, "window_is_borderless", return_value=False), + mock.patch.object(mapper, "_long_property", side_effect=read_property), + mock.patch.object( + mapper, + "_screen_filling_state_atoms", + return_value=(101, 102, 103), + ), + mock.patch.object( + mapper, + "_intern_atom", + side_effect=lambda name, **_kwargs: { + b"_NET_WM_ACTION_RESIZE": 31, + b"_NET_WM_ACTION_MAXIMIZE_HORZ": 32, + b"_NET_WM_ACTION_MAXIMIZE_VERT": 33, + b"_NET_WM_ACTION_MINIMIZE": 34, + }[name], + ), + ): + snapshot = mapper.window_diagnostic_snapshot(12) + + self.assertEqual(snapshot["wm_state"], "normal") + self.assertTrue(snapshot["normal_fixed_size"]) + self.assertTrue(snapshot["allows_resize"]) + self.assertFalse(snapshot["allows_maximize_horizontal"]) + self.assertFalse(snapshot["allows_maximize_vertical"]) + self.assertTrue(snapshot["maximized_horizontal"]) + self.assertFalse(snapshot["hidden"]) + self.assertTrue(snapshot["motif_allows_resize"]) + self.assertFalse(snapshot["motif_allows_maximize"]) + + def test_wait_for_scaling_actions_has_a_short_bounded_timeout(self) -> None: + mapper = object.__new__(_X11) + + with ( + mock.patch.object(mapper, "window_allows_scaling", return_value=False), + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + side_effect=[10.0, 10.0 + WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS], + ), + mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep, + ): + self.assertFalse(mapper._wait_for_scaling_actions(12)) + + sleep.assert_not_called() + + def test_subscribes_only_to_property_changes_for_scaling_maintenance(self) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.library.XSelectInput.return_value = 1 + mapper.display = ctypes.c_void_p(1) + + mapper.select_scaling_hint_events(12) + + mapper.library.XSelectInput.assert_called_once_with( + mapper.display, + 12, + _X_PROPERTY_CHANGE_MASK, + ) + mapper.library.XSync.assert_called_once_with(mapper.display, 0) + + def test_wait_for_scaling_hint_change_uses_the_x11_event_socket(self) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.library.XPending.side_effect = [0, 1, 0] + mapper.library.XConnectionNumber.return_value = 9 + mapper.display = ctypes.c_void_p(1) + + def publish_property_event(_display: object, event_pointer: Any) -> int: + event = event_pointer._obj + event.property.type = _X_PROPERTY_NOTIFY + event.property.window = 12 + event.property.atom = 31 + return 0 + + mapper.library.XNextEvent.side_effect = publish_property_event + with ( + mock.patch.object(mapper, "_intern_atom", side_effect=[31, 32, 33]), + mock.patch( + "ptlinux.gamebox_runtime.select.select", + return_value=([9], [], []), + ) as wait_for_event, + ): + self.assertTrue(mapper.wait_for_scaling_hint_change(12)) + + wait_for_event.assert_called_once() + mapper.library.XNextEvent.assert_called_once() + + def test_wait_for_scaling_hint_change_times_out_without_polling_properties( + self, + ) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.library.XPending.return_value = 0 + mapper.library.XConnectionNumber.return_value = 9 + mapper.display = ctypes.c_void_p(1) + + with mock.patch( + "ptlinux.gamebox_runtime.select.select", + return_value=([], [], []), + ): + self.assertFalse(mapper.wait_for_scaling_hint_change(12)) + + mapper.library.XNextEvent.assert_not_called() + + def test_activate_window_sends_an_ewmh_restore_request(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.request: tuple[int, ...] | None = None + + def XInternAtom( + self, + _display: object, + name: bytes, + _only_if_exists: int, + ) -> int: + self.assert_name = name + return 77 + + def XDefaultRootWindow(self, _display: object) -> int: + return 99 + + def XSendEvent( + self, + _display: object, + root: int, + _propagate: int, + mask: int, + event_pointer: Any, + ) -> int: + event = event_pointer._obj.client_message + self.request = ( + root, + mask, + event.type, + event.window, + event.message_type, + event.format, + event.data.longs[0], + event.data.longs[1], + event.data.longs[2], + event.data.longs[3], + event.data.longs[4], + ) + return 1 + + def XSync(self, _display: object, _discard: int) -> int: + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + with mock.patch.object(mapper, "_long_property", return_value=None): + self.assertTrue(mapper.activate_window(12)) + + self.assertEqual(library.assert_name, b"_NET_ACTIVE_WINDOW") + self.assertEqual( + library.request, + (99, (1 << 19) | (1 << 20), 33, 12, 77, 32, 1, 0, 0, 0, 0), + ) + + def test_borderless_mapping_captures_client_size_before_reframing(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.events: list[object] = [] + + def XMapRaised(self, _display: object, window: int) -> int: + self.events.append(("map", window)) + return 0 + + def XFlush(self, _display: object) -> int: + self.events.append("flush") + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + with ( + mock.patch.object( + mapper, + "window_size", + side_effect=lambda window: ( + library.events.append(("size", window)) or (800, 450) + ), + ), + mock.patch.object( + mapper, + "_remove_decorations", + side_effect=lambda window: library.events.append( + ("borderless", window) + ), + ), + ): + target_size = mapper.map_window(12, borderless=True) + + self.assertEqual(target_size, (800, 450)) + self.assertEqual( + library.events, + [("size", 12), ("borderless", 12), ("map", 12), "flush"], + ) + + def test_decorated_mapping_also_captures_the_client_size(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.events: list[object] = [] + + def XMapRaised(self, _display: object, window: int) -> int: + self.events.append(("map", window)) + return 0 + + def XFlush(self, _display: object) -> int: + self.events.append("flush") + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + with mock.patch.object( + mapper, + "window_size", + side_effect=lambda window: ( + library.events.append(("size", window)) or (1280, 960) + ), + ): + target_size = mapper.map_window(12, borderless=False) + + self.assertEqual(target_size, (1280, 960)) + self.assertEqual( + library.events, + [("size", 12), ("map", 12), "flush"], + ) + + def test_wait_for_window_size_observes_delayed_wm_acknowledgement(self) -> None: + mapper = object.__new__(_X11) + + with ( + mock.patch.object( + mapper, + "window_size", + side_effect=[(1280, 960), (1279, 960)], + ), + mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep, + ): + self.assertTrue(mapper._wait_for_window_size(12, (1279, 960))) + + sleep.assert_called_once() + + def test_wait_for_window_size_reports_a_wm_timeout(self) -> None: + mapper = object.__new__(_X11) + + with ( + mock.patch.object(mapper, "window_size", return_value=(1280, 960)), + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + side_effect=[10.0, 10.5], + ), + ): + self.assertFalse(mapper._wait_for_window_size(12, (1279, 960))) + + def test_render_surface_refresh_restores_a_pre_reframe_target_size(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.sizes: list[tuple[int, int]] = [] + + def XResizeWindow( + self, + _display: object, + _window: int, + width: int, + height: int, + ) -> int: + self.sizes.append((width, height)) + return 0 + + def XSync(self, _display: object, _discard: int) -> int: + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + with ( + mock.patch.object(mapper, "window_size") as window_size, + mock.patch.object( + mapper, + "_wait_for_window_size", + return_value=True, + ) as wait_for_size, + mock.patch("ptlinux.gamebox_runtime.time.sleep"), + ): + self.assertTrue( + mapper.refresh_render_surface(12, target_size=(800, 450)) + ) + + window_size.assert_not_called() + wait_for_size.assert_has_calls( + [ + mock.call( + 12, + (799, 450), + timeout_seconds=WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS, + ), + mock.call( + 12, + (800, 450), + timeout_seconds=WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS, + ), + ] + ) + self.assertEqual(library.sizes, [(799, 450), (800, 450)]) + + def test_render_surface_refresh_nudges_then_restores_the_mapped_window(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.events: list[object] = [] + + def XGetGeometry( + self, + _display: object, + window: int, + _root_return: Any, + _x_return: Any, + _y_return: Any, + width_return: Any, + height_return: Any, + _border_width_return: Any, + _depth_return: Any, + ) -> int: + self.events.append(("geometry", window)) + ctypes.cast(width_return, ctypes.POINTER(ctypes.c_uint))[0] = 1600 + ctypes.cast(height_return, ctypes.POINTER(ctypes.c_uint))[0] = 900 + return 1 + + def XSync(self, _display: object, discard: int) -> int: + self.events.append(("sync", discard)) + return 0 + + def XResizeWindow( + self, + _display: object, + window: int, + width: int, + height: int, + ) -> int: + self.events.append(("resize", window, width, height)) + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + with ( + mock.patch.object( + mapper, + "_wait_for_window_size", + return_value=True, + ) as wait_for_size, + mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep, + ): + self.assertTrue(mapper.refresh_render_surface(12)) + + sleep.assert_called_once_with(WINDOW_SURFACE_REFRESH_DELAY_SECONDS) + wait_for_size.assert_has_calls( + [ + mock.call( + 12, + (1599, 900), + timeout_seconds=WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS, + ), + mock.call( + 12, + (1600, 900), + timeout_seconds=WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS, + ), + ] + ) + self.assertEqual( + library.events, + [ + ("geometry", 12), + ("resize", 12, 1599, 900), + ("sync", 0), + ("resize", 12, 1600, 900), + ("sync", 0), + ], + ) + + def test_render_surface_refresh_restores_size_after_an_interrupted_wait(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.events: list[object] = [] + + def XGetGeometry( + self, + _display: object, + _window: int, + _root_return: Any, + _x_return: Any, + _y_return: Any, + width_return: Any, + height_return: Any, + _border_width_return: Any, + _depth_return: Any, + ) -> int: + ctypes.cast(width_return, ctypes.POINTER(ctypes.c_uint))[0] = 1024 + ctypes.cast(height_return, ctypes.POINTER(ctypes.c_uint))[0] = 768 + return 1 + + def XResizeWindow( + self, + _display: object, + window: int, + width: int, + height: int, + ) -> int: + self.events.append(("resize", window, width, height)) + return 0 + + def XSync(self, _display: object, discard: int) -> int: + self.events.append(("sync", discard)) + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + with ( + mock.patch.object( + mapper, + "_wait_for_window_size", + return_value=True, + ), + mock.patch( + "ptlinux.gamebox_runtime.time.sleep", + side_effect=RuntimeError("interrupted"), + ), + self.assertRaisesRegex(RuntimeError, "interrupted"), + ): + mapper.refresh_render_surface(12) + + self.assertEqual( + library.events, + [ + ("resize", 12, 1023, 768), + ("sync", 0), + ("resize", 12, 1024, 768), + ("sync", 0), + ], + ) + + def test_render_surface_refresh_declines_when_geometry_is_unavailable(self) -> None: + library = mock.Mock() + library.XGetGeometry.return_value = 0 + mapper = object.__new__(_X11) + mapper.library = library + mapper.display = ctypes.c_void_p(1) + + self.assertFalse(mapper.refresh_render_surface(12)) + + library.XResizeWindow.assert_not_called() + library.XSync.assert_not_called() + + def test_render_surface_refresh_reports_a_declined_nudge(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.sizes: list[tuple[int, int]] = [] + + def XResizeWindow( + self, + _display: object, + _window: int, + width: int, + height: int, + ) -> int: + self.sizes.append((width, height)) + return 0 + + def XSync(self, _display: object, _discard: int) -> int: + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + with mock.patch.object( + mapper, + "_wait_for_window_size", + side_effect=[False, True], + ): + self.assertFalse( + mapper.refresh_render_surface(12, target_size=(1280, 960)) + ) + + self.assertEqual(library.sizes, [(1279, 960), (1280, 960)]) + + def test_render_surface_refresh_nudges_height_for_a_one_pixel_width(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.sizes: list[tuple[int, int]] = [] + + def XGetGeometry( + self, + _display: object, + _window: int, + _root_return: Any, + _x_return: Any, + _y_return: Any, + width_return: Any, + height_return: Any, + _border_width_return: Any, + _depth_return: Any, + ) -> int: + ctypes.cast(width_return, ctypes.POINTER(ctypes.c_uint))[0] = 1 + ctypes.cast(height_return, ctypes.POINTER(ctypes.c_uint))[0] = 2 + return 1 + + def XResizeWindow( + self, + _display: object, + _window: int, + width: int, + height: int, + ) -> int: + self.sizes.append((width, height)) + return 0 + + def XSync(self, _display: object, _discard: int) -> int: + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + with ( + mock.patch.object( + mapper, + "_wait_for_window_size", + return_value=True, + ), + mock.patch("ptlinux.gamebox_runtime.time.sleep"), + ): + self.assertTrue(mapper.refresh_render_surface(12)) + + self.assertEqual(library.sizes, [(1, 1), (1, 2)]) + + def test_render_surface_refresh_declines_an_unnudgeable_window(self) -> None: + def get_one_by_one_geometry( + _display: object, + _window: int, + _root_return: Any, + _x_return: Any, + _y_return: Any, + width_return: Any, + height_return: Any, + _border_width_return: Any, + _depth_return: Any, + ) -> int: + ctypes.cast(width_return, ctypes.POINTER(ctypes.c_uint))[0] = 1 + ctypes.cast(height_return, ctypes.POINTER(ctypes.c_uint))[0] = 1 + return 1 + + library = mock.Mock() + library.XGetGeometry.side_effect = get_one_by_one_geometry + mapper = object.__new__(_X11) + mapper.library = library + mapper.display = ctypes.c_void_p(1) + + self.assertFalse(mapper.refresh_render_surface(12)) + + library.XResizeWindow.assert_not_called() + library.XSync.assert_not_called() + + def test_focus_window_raises_then_focuses_the_exact_client(self) -> None: + class FakeX11Library: + def __init__(self) -> None: + self.events: list[object] = [] + + def XMapRaised(self, _display: object, window: int) -> int: + self.events.append(("raise", window)) + return 0 + + def XSetInputFocus( + self, + _display: object, + window: int, + revert_to: int, + timestamp: int, + ) -> int: + self.events.append(("focus", window, revert_to, timestamp)) + return 0 + + def XSync(self, _display: object, discard: int) -> int: + self.events.append(("sync", discard)) + return 0 + + library = FakeX11Library() + mapper = object.__new__(_X11) + mapper.library = library # type: ignore[assignment] + mapper.display = ctypes.c_void_p(1) + + mapper.focus_window(12) + + self.assertEqual( + library.events, + [("raise", 12), ("focus", 12, 2, 0), ("sync", 0)], + ) + + def test_restore_input_focus_does_not_raise_the_client(self) -> None: + library = mock.Mock() + mapper = object.__new__(_X11) + mapper.library = library + mapper.display = ctypes.c_void_p(1) + + mapper.restore_input_focus(12) + + library.XMapRaised.assert_not_called() + library.XSetInputFocus.assert_called_once_with( + mapper.display, + 12, + 2, + 0, + ) + library.XSync.assert_called_once_with(mapper.display, 0) + + def test_game_focus_waits_for_the_dashboard_handoff(self) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = 1 + mapper.window_is_active.return_value = True + mapper.window_is_focused.side_effect = [False, True] + + with mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep: + _focus_game_window(mapper, 12) + + sleep.assert_called_once_with(WINDOW_FOCUS_DELAY_SECONDS) + mapper.activate_window.assert_not_called() + mapper.restore_input_focus.assert_called_once_with(12) + + def test_host_minimize_request_never_raises_the_window(self) -> None: + mapper = object.__new__(_X11) + mapper.library = mock.Mock() + mapper.library.XDefaultScreen.return_value = 2 + mapper.library.XIconifyWindow.return_value = 1 + mapper.display = ctypes.c_void_p(1) + + self.assertTrue(mapper.minimize_window(12)) + + mapper.library.XIconifyWindow.assert_called_once_with( + mapper.display, + 12, + 2, + ) + mapper.library.XMapRaised.assert_not_called() + mapper.library.XSync.assert_called_once_with(mapper.display, 0) + + def test_game_focus_failure_is_nonfatal(self) -> None: + mapper = mock.Mock() + mapper.window_state.side_effect = RuntimeError("test failure") + output = io.StringIO() + + with ( + mock.patch("ptlinux.gamebox_runtime.time.sleep"), + contextlib.redirect_stderr(output), + ): + _focus_game_window(mapper, 12) + + self.assertIn("continuing normally", output.getvalue()) + + def test_focus_repair_does_not_reactivate_a_background_window(self) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = 1 + mapper.window_is_active.return_value = False + + self.assertFalse(_repair_game_window_focus(mapper, 12)) + + mapper.restore_input_focus.assert_not_called() + mapper.activate_window.assert_not_called() + mapper.focus_window.assert_not_called() + + def test_focus_repair_restores_input_without_raising_the_active_window(self) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = 1 + mapper.window_is_active.return_value = True + mapper.window_is_focused.side_effect = [False, True] + + self.assertTrue(_repair_game_window_focus(mapper, 12)) + + mapper.restore_input_focus.assert_called_once_with(12) + mapper.activate_window.assert_not_called() + mapper.focus_window.assert_not_called() + + def test_window_scaling_failure_is_nonfatal(self) -> None: + mapper = mock.Mock() + mapper.enable_window_scaling.side_effect = RuntimeError("test failure") + output = io.StringIO() + + with contextlib.redirect_stderr(output): + self.assertFalse(_enable_game_window_scaling(mapper, 12)) + + self.assertIn("Warning", output.getvalue()) + self.assertIn("continue normally", output.getvalue()) + + def test_scaling_monitor_failure_leaves_a_consistent_fixed_window(self) -> None: + mapper = mock.Mock() + mapper.select_scaling_hint_events.side_effect = RuntimeError("test failure") + process = mock.Mock() + process.wait.return_value = 0 + output = io.StringIO() + + with contextlib.redirect_stderr(output): + self.assertEqual( + _maintain_game_window_scaling(mapper, process, 12), + 0, + ) + + mapper.enable_window_scaling.assert_not_called() + self.assertIn("fixed-size for consistency", output.getvalue()) + process.wait.assert_called_once_with() + + def test_scaling_maintenance_reapplies_hints_after_wine_replaces_them( + self, + ) -> None: + mapper = mock.Mock() + mapper.enable_window_scaling.return_value = True + mapper.wait_for_scaling_hint_change.side_effect = [True, False] + process = mock.Mock() + process.poll.side_effect = [None, None, 0] + process.wait.return_value = 0 + + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual( + _maintain_game_window_scaling(mapper, process, 12), + 0, + ) + + mapper.select_scaling_hint_events.assert_called_once_with(12) + self.assertEqual( + mapper.enable_window_scaling.call_args_list, + [ + mock.call(12), + mock.call(12, confirm=False), + ], + ) + process.wait.assert_called_once_with() + + def test_scaling_maintenance_stops_after_repeated_repair_failures(self) -> None: + mapper = mock.Mock() + mapper.enable_window_scaling.side_effect = [ + True, + *[RuntimeError("test failure")] * WINDOW_SCALING_REPAIR_LIMIT, + ] + mapper.wait_for_scaling_hint_change.return_value = True + process = mock.Mock() + process.poll.return_value = None + process.wait.return_value = 0 + output = io.StringIO() + + with ( + contextlib.redirect_stderr(output), + mock.patch("ptlinux.gamebox_runtime._maintain_game_window_focus", + side_effect=lambda _mapper, child, _window, **_kwargs: child.wait()), + ): + self.assertEqual( + _maintain_game_window_scaling(mapper, process, 12), + 0, + ) + + self.assertEqual( + mapper.enable_window_scaling.call_count, + 1 + WINDOW_SCALING_REPAIR_LIMIT, + ) + self.assertIn("disabled after repeated failures", output.getvalue()) + process.wait.assert_called_once_with() + + def test_fixed_window_maintenance_preserves_the_focus_repair(self) -> None: + mapper = mock.Mock() + process = mock.Mock() + process.poll.side_effect = [None, None, 0] + process.wait.return_value = 0 + output = io.StringIO() + + with ( + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + side_effect=[0.0, 0.5, 1.0, 1.0], + ), + mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep, + mock.patch( + "ptlinux.gamebox_runtime._repair_game_window_focus" + ) as repair_focus, + contextlib.redirect_stderr(output), + ): + self.assertEqual( + _maintain_game_window_focus(mapper, process, 12), + 0, + ) + + sleep.assert_has_calls([mock.call(0.5), mock.call(0.5)]) + repair_focus.assert_called_once_with(mapper, 12) + mapper.enable_window_scaling.assert_not_called() + mapper.select_scaling_hint_events.assert_not_called() + process.wait.assert_called_once_with() + self.assertIn("focus monitoring active", output.getvalue()) + + def test_gamescope_maintenance_repairs_focus_without_changing_geometry( + self, + ) -> None: + mapper = mock.Mock() + process = mock.Mock() + process.poll.side_effect = [None, None, 0] + process.wait.return_value = 0 + + with ( + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + side_effect=[0.0, 0.1, 0.25, 0.25], + ), + mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep, + mock.patch( + "ptlinux.gamebox_runtime._repair_gamescope_game_window_focus" + ) as repair_focus, + ): + self.assertEqual( + _maintain_gamescope_game_window_focus(mapper, process, 12), + 0, + ) + + sleep.assert_has_calls( + [ + mock.call(GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS), + mock.call(GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS), + ] + ) + repair_focus.assert_called_once_with(mapper, 12) + mapper.enable_window_scaling.assert_not_called() + mapper.select_scaling_hint_events.assert_not_called() + process.wait.assert_called_once_with() + + def test_gamescope_focus_repair_never_activates_or_raises_the_host(self) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = 1 + mapper.window_is_focused.side_effect = [False, True] + + self.assertTrue(_repair_gamescope_game_window_focus(mapper, 12)) + + mapper.restore_input_focus.assert_called_once_with(12) + mapper.activate_window.assert_not_called() + mapper.focus_window.assert_not_called() + + def test_gamescope_focus_repair_stops_while_the_host_is_inactive(self) -> None: + mapper = mock.Mock() + with tempfile.TemporaryDirectory() as temporary: + state_file = Path(temporary) / "focus" + state_file.write_text("0 100.0\n", encoding="ascii") + with mock.patch.dict( + os.environ, + {GAMESCOPE_HOST_FOCUS_STATE_ENV: str(state_file)}, + ): + self.assertFalse(_repair_gamescope_game_window_focus(mapper, 12)) + + mapper.window_state.assert_not_called() + mapper.restore_input_focus.assert_not_called() + + def test_gamescope_host_focus_state_must_be_active_and_fresh(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + state_file = Path(temporary) / "focus" + environment = {GAMESCOPE_HOST_FOCUS_STATE_ENV: str(state_file)} + state_file.write_text("1 100.0\n", encoding="ascii") + self.assertTrue( + _gamescope_host_focus_is_allowed( + environment=environment, + now=100.1, + ) + ) + self.assertFalse( + _gamescope_host_focus_is_allowed( + environment=environment, + now=100.0 + GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS + 0.01, + ) + ) + state_file.write_text("0 101.0\n", encoding="ascii") + self.assertFalse( + _gamescope_host_focus_is_allowed( + environment=environment, + now=101.0, + ) + ) + + def test_gamescope_host_focus_defaults_to_legacy_behavior_without_bridge( + self, + ) -> None: + self.assertTrue(_gamescope_host_focus_is_allowed(environment={}, now=10.0)) + + def test_gamescope_resolver_keeps_a_normal_window_on_the_fast_path(self) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = 1 + + self.assertEqual( + _resolve_gamescope_game_window( + mapper, + 12, + window_title="PristonTale EU", + excluded_window_ids={4}, + ), + 12, + ) + + mapper.find_first_window.assert_not_called() + + def test_gamescope_resolver_recovers_a_nested_login_iconify(self) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = 3 + mapper.restore_normal_window_state.return_value = True + output = io.StringIO() + + with contextlib.redirect_stderr(output): + self.assertEqual( + _resolve_gamescope_game_window( + mapper, + 12, + window_title="PristonTale EU", + excluded_window_ids={4}, + ), + 12, + ) + + mapper.restore_normal_window_state.assert_called_once_with(12) + mapper.find_first_window.assert_not_called() + mapper.activate_window.assert_not_called() + mapper.focus_window.assert_not_called() + self.assertIn("surface visible inside Gamescope", output.getvalue()) + + def test_gamescope_resolver_follows_replacement_after_iconify_recovery_fails( + self, + ) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = 3 + mapper.restore_normal_window_state.return_value = False + mapper.find_first_window.return_value = (21, "PristonTale EU V21.77.86") + + with mock.patch( + "ptlinux.gamebox_runtime._apply_game_window_identity" + ) as apply_identity, contextlib.redirect_stderr(io.StringIO()): + self.assertEqual( + _resolve_gamescope_game_window( + mapper, + 12, + window_title="PristonTale EU", + excluded_window_ids={4}, + ), + 21, + ) + + mapper.find_first_window.assert_called_once_with( + "PristonTale EU", + excluded_window_ids={4, 12}, + ) + apply_identity.assert_called_once_with(mapper, 21) + + def test_gamescope_resolver_follows_a_replacement_login_window(self) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = None + mapper.find_first_window.return_value = ( + 21, + "PristonTale EU V21.77.86", + ) + output = io.StringIO() + + with ( + mock.patch( + "ptlinux.gamebox_runtime._apply_game_window_identity" + ) as apply_identity, + contextlib.redirect_stderr(output), + ): + self.assertEqual( + _resolve_gamescope_game_window( + mapper, + 12, + window_title="PristonTale EU", + excluded_window_ids={4}, + ), + 21, + ) + + mapper.find_first_window.assert_called_once_with( + "PristonTale EU", + excluded_window_ids={4, 12}, + ) + apply_identity.assert_called_once_with(mapper, 21) + self.assertIn("following it", output.getvalue()) + + def test_gamescope_resolver_never_focuses_a_stale_window(self) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = None + mapper.window_size.return_value = None + mapper.find_first_window.return_value = None + + self.assertIsNone( + _resolve_gamescope_game_window( + mapper, + 12, + window_title="PristonTale EU", + excluded_window_ids=set(), + ) + ) + + mapper.restore_input_focus.assert_not_called() + mapper.focus_window.assert_not_called() + + def test_startup_guard_confirms_stability_only_after_its_deadline(self) -> None: + mapper = mock.Mock() + process = mock.Mock() + process.poll.return_value = None + + with mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + side_effect=[0.0, 12.0], + ): + self.assertTrue( + _guard_game_window_startup( + mapper, + process, + 12, + borderless=False, + ) + ) + + mapper.window_state.assert_not_called() + + def test_startup_guard_respects_a_minimized_window(self) -> None: + mapper = mock.Mock() + mapper.window_is_borderless.return_value = True + mapper.window_state.return_value = 3 + mapper.window_is_fullscreen_or_maximized.return_value = False + process = mock.Mock() + process.poll.return_value = None + output = io.StringIO() + + with ( + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + side_effect=[0.0, 0.1], + ), + mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep, + mock.patch("ptlinux.gamebox_runtime._prepare_game_window") as prepare, + contextlib.redirect_stderr(output), + ): + self.assertFalse( + _guard_game_window_startup( + mapper, + process, + 12, + borderless=True, + ) + ) + + mapper.activate_window.assert_not_called() + prepare.assert_not_called() + mapper.focus_window.assert_not_called() + sleep.assert_not_called() + self.assertIn("leaving it minimized", output.getvalue()) + + def test_startup_guard_recovers_an_early_login_style_reset_while_normal(self) -> None: + mapper = mock.Mock() + mapper.window_is_borderless.side_effect = [False, False, True] + mapper.window_state.return_value = 1 + mapper.window_is_fullscreen_or_maximized.return_value = False + process = mock.Mock() + process.poll.side_effect = [None, 0] + output = io.StringIO() + + with ( + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + side_effect=[0.0, 0.1], + ), + mock.patch("ptlinux.gamebox_runtime.time.sleep"), + mock.patch("ptlinux.gamebox_runtime._prepare_game_window") as prepare, + contextlib.redirect_stderr(output), + ): + _guard_game_window_startup( + mapper, + process, + 12, + borderless=True, + ) + + prepare.assert_called_once() + self.assertIn("changed its window layout", output.getvalue()) + + def test_startup_guard_leaves_a_stable_window_untouched(self) -> None: + mapper = mock.Mock() + mapper.window_is_borderless.return_value = True + mapper.window_state.return_value = 1 + mapper.window_is_fullscreen_or_maximized.return_value = False + process = mock.Mock() + process.poll.side_effect = [None, 0] + + with ( + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + side_effect=[0.0, 0.1], + ), + mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep, + ): + _guard_game_window_startup( + mapper, + process, + 12, + borderless=True, + ) + + mapper.activate_window.assert_not_called() + mapper.focus_window.assert_not_called() + sleep.assert_called_once_with(WINDOW_STARTUP_POLL_SECONDS) + + def test_startup_guard_recovers_reapplied_fullscreen_in_decorated_mode(self) -> None: + mapper = mock.Mock() + mapper.window_state.return_value = 1 + mapper.window_is_fullscreen_or_maximized.return_value = True + process = mock.Mock() + process.poll.side_effect = [None, 0] + output = io.StringIO() + + with ( + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + side_effect=[0.0, 0.1], + ), + mock.patch("ptlinux.gamebox_runtime.time.sleep"), + mock.patch("ptlinux.gamebox_runtime._prepare_game_window") as prepare, + contextlib.redirect_stderr(output), + ): + _guard_game_window_startup( + mapper, + process, + 12, + borderless=False, + ) + + prepare.assert_called_once_with( + mapper, + 12, + borderless=False, + suppress_wm_ping=False, + raise_window=False, + ) + self.assertIn("entered fullscreen/maximized mode", output.getvalue()) + + def test_wm_ping_failure_does_not_skip_normal_window_mapping(self) -> None: + events: list[object] = [] + output = io.StringIO() + + class FakeMapper: + def suppress_wm_ping(self, window: int) -> bool: + events.append(("suppress", window)) + raise RuntimeError("test failure") + + def map_window(self, window: int, *, borderless: bool) -> None: + events.append(("map", window, borderless)) + + def force_windowed_state(self, window: int) -> bool: + events.append(("windowed", window)) + return False + + def refresh_render_surface(self, window: int) -> bool: + events.append(("refresh", window)) + return True + + with contextlib.redirect_stderr(output): + _prepare_game_window( + FakeMapper(), # type: ignore[arg-type] + 12, + borderless=True, + suppress_wm_ping=True, + ) + + self.assertEqual( + events, + [ + ("suppress", 12), + ("map", 12, True), + ("windowed", 12), + ("refresh", 12), + ], + ) + self.assertIn("continuing normally", output.getvalue()) + + def test_borderless_preparation_restores_the_pre_reframe_size(self) -> None: + events: list[object] = [] + + class FakeMapper: + def map_window( + self, + window: int, + *, + borderless: bool, + ) -> tuple[int, int]: + events.append(("map", window, borderless)) + return 1024, 768 + + def force_windowed_state(self, window: int) -> bool: + events.append(("windowed", window)) + return False + + def refresh_render_surface( + self, + window: int, + *, + target_size: tuple[int, int] | None = None, + ) -> bool: + events.append(("refresh", window, target_size)) + return True + + with mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep: + _prepare_game_window( + FakeMapper(), # type: ignore[arg-type] + 12, + borderless=True, + suppress_wm_ping=False, + ) + + sleep.assert_called_once_with(WINDOW_SURFACE_SETTLE_SECONDS) + self.assertEqual( + events, + [ + ("map", 12, True), + ("windowed", 12), + ("refresh", 12, (1024, 768)), + ], + ) + + def test_decorated_window_resynchronizes_its_render_surface(self) -> None: + events: list[object] = [] + + class FakeMapper: + def map_window( + self, + window: int, + *, + borderless: bool, + ) -> tuple[int, int]: + events.append(("map", window, borderless)) + return 1280, 960 + + def force_windowed_state(self, window: int) -> bool: + events.append(("windowed", window)) + return False + + def refresh_render_surface( + self, + window: int, + *, + target_size: tuple[int, int] | None = None, + ) -> bool: + events.append(("refresh", window, target_size)) + return True + + with mock.patch("ptlinux.gamebox_runtime.time.sleep") as sleep: + _prepare_game_window( + FakeMapper(), # type: ignore[arg-type] + 12, + borderless=False, + suppress_wm_ping=False, + ) + + sleep.assert_called_once_with(WINDOW_SURFACE_SETTLE_SECONDS) + self.assertEqual( + events, + [ + ("map", 12, False), + ("windowed", 12), + ("refresh", 12, (1280, 960)), + ], + ) + + def test_windowed_preparation_uses_the_geometry_restored_by_the_wm(self) -> None: + events: list[object] = [] + + class FakeMapper: + def map_window( + self, + window: int, + *, + borderless: bool, + ) -> tuple[int, int]: + events.append(("map", window, borderless)) + return 1920, 1080 + + def force_windowed_state(self, window: int) -> bool: + events.append(("windowed", window)) + return True + + def window_size(self, window: int) -> tuple[int, int]: + events.append(("size", window)) + return 1280, 960 + + def refresh_render_surface( + self, + window: int, + *, + target_size: tuple[int, int] | None = None, + ) -> bool: + events.append(("refresh", window, target_size)) + return True + + with mock.patch("ptlinux.gamebox_runtime.time.sleep"): + _prepare_game_window( + FakeMapper(), # type: ignore[arg-type] + 12, + borderless=False, + suppress_wm_ping=False, + ) + + self.assertEqual( + events, + [ + ("map", 12, False), + ("windowed", 12), + ("size", 12), + ("refresh", 12, (1280, 960)), + ], + ) + + def test_surface_refresh_failure_does_not_abort_window_mapping(self) -> None: + events: list[object] = [] + output = io.StringIO() + + class FakeMapper: + def map_window(self, window: int, *, borderless: bool) -> None: + events.append(("map", window, borderless)) + + def force_windowed_state(self, window: int) -> bool: + events.append(("windowed", window)) + return False + + def refresh_render_surface(self, window: int) -> bool: + events.append(("refresh", window)) + raise RuntimeError("test failure") + + with contextlib.redirect_stderr(output): + _prepare_game_window( + FakeMapper(), # type: ignore[arg-type] + 12, + borderless=True, + suppress_wm_ping=False, + ) + + self.assertEqual( + events, + [("map", 12, True), ("windowed", 12), ("refresh", 12)], + ) + self.assertIn("continuing normally", output.getvalue()) + + def test_snapshots_existing_windows_before_starting_game(self) -> None: + events: list[object] = [] + + class FakeMapper: + def __init__(self) -> None: + self.events = events + self.events.append("mapper") + self.active = False + + def root_window_ids(self) -> set[int]: + self.events.append("snapshot") + return {10} + + def find_first_window( + self, + _title: str, + *, + excluded_window_ids: Collection[int], + ) -> tuple[int, str]: + self.events.append(("find", set(excluded_window_ids))) + return 12, "PristonTale EU V21.75.83" + + def set_window_identity(self, window: int) -> None: + self.events.append(("identity", window)) + + def map_window(self, window: int, *, borderless: bool, raise_window: bool = True) -> None: + assert not raise_window + self.events.append(("map", window, borderless)) + + def force_windowed_state(self, window: int) -> bool: + self.events.append(("windowed", window)) + return False + + def refresh_render_surface(self, window: int) -> bool: + self.events.append(("refresh", window)) + return True + + def focus_window(self, window: int) -> None: + self.events.append(("focus", window)) + + def window_state(self, _window: int) -> int: + return 1 + + def activate_window(self, window: int) -> bool: + self.events.append(("activate", window)) + self.active = True + return True + + def window_is_active(self, _window: int) -> bool: + return self.active + + def window_is_focused(self, _window: int) -> bool: + return False + + def restore_input_focus(self, window: int) -> None: + self.events.append(("focus", window)) + + def suppress_wm_ping(self, window: int) -> bool: + self.events.append(("suppress", window)) + return True + + def close(self) -> None: + self.events.append("close") + + class FakeProcess: + def __init__(self) -> None: + self.events = events + self.finished = False + + def poll(self) -> int | None: + self.events.append("poll") + return 0 if self.finished else None + + def wait(self) -> int: + self.events.append("wait") + return 0 + + def start_process(_command: list[str]) -> FakeProcess: + events.append("process") + return FakeProcess() + + def guard_startup( + _mapper: object, + _process: FakeProcess, + window: int, + *, + borderless: bool, + ) -> None: + events.append(("guard", window, borderless)) + _process.finished = True + + with ( + mock.patch("ptlinux.gamebox_runtime._X11", FakeMapper), + mock.patch("ptlinux.gamebox_runtime.subprocess.Popen", start_process), + mock.patch( + "ptlinux.gamebox_runtime._guard_game_window_startup", + guard_startup, + ), + mock.patch("ptlinux.gamebox_runtime.WINDOW_READY_DELAY_SECONDS", 0.0), + mock.patch("ptlinux.gamebox_runtime.WINDOW_FOCUS_DELAY_SECONDS", 0.0), + ): + self.assertEqual( + run_game_window( + ["game"], + borderless=True, + suppress_wm_ping=True, + ), + 0, + ) + + self.assertLess(events.index("snapshot"), events.index("process")) + self.assertIn(("find", {10}), events) + self.assertLess(events.index(("identity", 12)), events.index(("map", 12, True))) + self.assertLess(events.index(("suppress", 12)), events.index(("map", 12, True))) + self.assertIn(("map", 12, True), events) + self.assertLess( + events.index(("map", 12, True)), + events.index(("windowed", 12)), + ) + self.assertLess(events.index(("windowed", 12)), events.index(("refresh", 12))) + self.assertLess(events.index(("refresh", 12)), events.index(("focus", 12))) + self.assertLess(events.index(("activate", 12)), events.index(("focus", 12))) + self.assertLess( + events.index(("focus", 12)), + events.index(("guard", 12, True)), + ) + self.assertLess(events.index(("guard", 12, True)), events.index("wait")) + self.assertEqual(events[-1], "close") + + def test_keeps_a_decorated_window_fixed_by_default(self) -> None: + mapper = mock.Mock() + mapper.root_window_ids.return_value = set() + mapper.find_first_window.return_value = ( + 12, + "PristonTale EU V21.75.83", + ) + process = mock.Mock() + process.poll.return_value = None + process.wait.return_value = 0 + + with ( + mock.patch("ptlinux.gamebox_runtime._X11", return_value=mapper), + mock.patch( + "ptlinux.gamebox_runtime.subprocess.Popen", + return_value=process, + ), + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + return_value=0.0, + ), + mock.patch("ptlinux.gamebox_runtime.WINDOW_READY_DELAY_SECONDS", 0.0), + mock.patch("ptlinux.gamebox_runtime._prepare_game_window"), + mock.patch("ptlinux.gamebox_runtime._focus_game_window"), + mock.patch( + "ptlinux.gamebox_runtime._guard_game_window_startup", + return_value=True, + ) as guard, + mock.patch( + "ptlinux.gamebox_runtime._maintain_game_window_scaling", + return_value=0, + ) as maintain_scaling, + mock.patch( + "ptlinux.gamebox_runtime._maintain_game_window_focus", + return_value=0, + ) as maintain_focus, + ): + self.assertEqual( + run_game_window(["game"]), + 0, + ) + + guard.assert_called_once_with(mapper, process, 12, borderless=False) + maintain_scaling.assert_not_called() + maintain_focus.assert_called_once_with( + mapper, + process, + 12, + window_title=WINDOW_TITLE, + existing_window_ids=set(), + expected_pid=mapper.window_pid.return_value, + ) + mapper.close.assert_called_once_with() + + def test_enables_scaling_only_for_an_explicit_opt_in_after_stable_startup( + self, + ) -> None: + mapper = mock.Mock() + mapper.root_window_ids.return_value = set() + mapper.find_first_window.return_value = ( + 12, + "PristonTale EU V21.75.83", + ) + process = mock.Mock() + process.poll.return_value = None + process.wait.return_value = 0 + + with ( + mock.patch("ptlinux.gamebox_runtime._X11", return_value=mapper), + mock.patch( + "ptlinux.gamebox_runtime.subprocess.Popen", + return_value=process, + ), + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + return_value=0.0, + ), + mock.patch("ptlinux.gamebox_runtime.WINDOW_READY_DELAY_SECONDS", 0.0), + mock.patch("ptlinux.gamebox_runtime._prepare_game_window"), + mock.patch("ptlinux.gamebox_runtime._focus_game_window"), + mock.patch( + "ptlinux.gamebox_runtime._guard_game_window_startup", + return_value=True, + ), + mock.patch( + "ptlinux.gamebox_runtime._maintain_game_window_scaling", + return_value=0, + ) as maintain_scaling, + mock.patch( + "ptlinux.gamebox_runtime._maintain_game_window_focus", + return_value=0, + ) as maintain_focus, + ): + self.assertEqual( + run_game_window(["game"], allow_window_resizing=True), + 0, + ) + + maintain_scaling.assert_called_once_with( + mapper, process, 12, window_title=WINDOW_TITLE, + existing_window_ids=set(), expected_pid=mapper.window_pid.return_value, + ) + maintain_focus.assert_not_called() + mapper.close.assert_called_once_with() + + def test_gamescope_scaling_leaves_geometry_to_gamescope_and_repairs_focus( + self, + ) -> None: + mapper = mock.Mock() + mapper.root_window_ids.return_value = set() + mapper.find_first_window.return_value = ( + 12, + "PristonTale EU V21.75.83", + ) + process = mock.Mock() + process.poll.return_value = None + process.wait.return_value = 0 + + with ( + mock.patch("ptlinux.gamebox_runtime._X11", return_value=mapper), + mock.patch( + "ptlinux.gamebox_runtime.subprocess.Popen", + return_value=process, + ), + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + return_value=0.0, + ), + mock.patch("ptlinux.gamebox_runtime.WINDOW_READY_DELAY_SECONDS", 0.0), + mock.patch( + "ptlinux.gamebox_runtime._prepare_game_window" + ) as prepare, + mock.patch( + "ptlinux.gamebox_runtime._focus_game_window" + ) as focus, + mock.patch( + "ptlinux.gamebox_runtime._guard_game_window_startup", + return_value=True, + ) as guard, + mock.patch( + "ptlinux.gamebox_runtime._maintain_game_window_scaling", + return_value=0, + ) as maintain_scaling, + mock.patch( + "ptlinux.gamebox_runtime._maintain_game_window_focus", + return_value=0, + ) as maintain_focus, + mock.patch( + "ptlinux.gamebox_runtime._maintain_gamescope_game_window_focus", + return_value=0, + ) as maintain_gamescope_focus, + ): + self.assertEqual( + run_game_window( + ["game"], + allow_window_resizing=True, + gamescope_scaling=True, + ), + 0, + ) + + maintain_scaling.assert_not_called() + maintain_focus.assert_not_called() + maintain_gamescope_focus.assert_called_once_with( + mapper, + process, + 12, + window_title="PristonTale EU", + existing_window_ids=set(), + ) + prepare.assert_not_called() + guard.assert_not_called() + focus.assert_called_once_with(mapper, 12, activate=False) + process.wait.assert_not_called() + mapper.close.assert_called_once_with() + + def test_does_not_enable_host_scaling_for_a_borderless_window(self) -> None: + mapper = mock.Mock() + mapper.root_window_ids.return_value = set() + mapper.find_first_window.return_value = ( + 12, + "PristonTale EU V21.75.83", + ) + process = mock.Mock() + process.poll.return_value = None + process.wait.return_value = 0 + + with ( + mock.patch("ptlinux.gamebox_runtime._X11", return_value=mapper), + mock.patch( + "ptlinux.gamebox_runtime.subprocess.Popen", + return_value=process, + ), + mock.patch( + "ptlinux.gamebox_runtime.time.monotonic", + return_value=0.0, + ), + mock.patch("ptlinux.gamebox_runtime.WINDOW_READY_DELAY_SECONDS", 0.0), + mock.patch("ptlinux.gamebox_runtime._prepare_game_window"), + mock.patch("ptlinux.gamebox_runtime._focus_game_window"), + mock.patch( + "ptlinux.gamebox_runtime._guard_game_window_startup", + return_value=True, + ) as guard, + mock.patch( + "ptlinux.gamebox_runtime._maintain_game_window_scaling" + ) as maintain_scaling, + mock.patch( + "ptlinux.gamebox_runtime._maintain_game_window_focus", + return_value=0, + ) as maintain_focus, + ): + self.assertEqual( + run_game_window(["game"], borderless=True), + 0, + ) + + guard.assert_called_once_with(mapper, process, 12, borderless=True) + maintain_scaling.assert_not_called() + maintain_focus.assert_called_once_with( + mapper, process, 12, window_title=WINDOW_TITLE, + existing_window_ids=set(), expected_pid=mapper.window_pid.return_value, + ) + mapper.close.assert_called_once_with() + + def test_unavailable_x11_does_not_delay_or_abort_the_game(self) -> None: + process = mock.Mock() + process.wait.return_value = 0 + output = io.StringIO() + with ( + mock.patch( + "ptlinux.gamebox_runtime._X11", + side_effect=GameboxRuntimeError("could not open the host X11 display"), + ), + mock.patch( + "ptlinux.gamebox_runtime.subprocess.Popen", + return_value=process, + ) as start_process, + contextlib.redirect_stderr(output), + ): + self.assertEqual(run_game_window(["game"]), 0) + + start_process.assert_called_once_with(["game"]) + process.wait.assert_called_once_with() + self.assertIn("continuing without X11 window styling", output.getvalue()) + self.assertIn("Game window monitoring unavailable; process started:", output.getvalue()) + self.assertNotIn("Game window ready:", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ptlinux_gamescope_runtime.py b/tests/test_ptlinux_gamescope_runtime.py new file mode 100644 index 0000000..8753769 --- /dev/null +++ b/tests/test_ptlinux_gamescope_runtime.py @@ -0,0 +1,830 @@ +import contextlib +import hashlib +import io +import os +from pathlib import Path +import socket +import subprocess +import tempfile +import unittest +from unittest import mock + +# noinspection PyProtectedMember +from ptlinux.gamescope_runtime import ( + _HostFocusPublisher, + _bundled_gamescope_is_usable, + _is_cinnamon_session, + _is_kde_session, + _process_exit_status, + _read_gamescope_version, + _select_host_backend, + _select_wayland_gamescope, + _supervised_command, + _tune_process_window, + _wait_for_optimized_gamescope, + main, + resize_increments, + run_gamescope, +) +from ptlinux.gamebox_runtime import GAMESCOPE_HOST_FOCUS_STATE_ENV +from ptlinux.window_diagnostics import ( + WINDOW_DIAGNOSTICS_ENV, + WINDOW_DIAGNOSTICS_SESSION_ENV, + WindowDiagnosticRecorder, + parse_window_diagnostic, +) + + +class GamescopeRuntimeTests(unittest.TestCase): + def test_uses_five_percent_resize_steps_with_sensible_bounds(self) -> None: + self.assertEqual(resize_increments(1280, 960), (64, 48)) + self.assertEqual(resize_increments(100, 100), (8, 8)) + self.assertEqual(resize_increments(7680, 4320), (96, 96)) + + def test_rejects_invalid_canvas_dimensions(self) -> None: + with self.assertRaisesRegex(ValueError, "positive"): + resize_increments(0, 960) + + def test_inserts_selected_backend_before_existing_gamescope_options(self) -> None: + self.assertEqual( + _supervised_command( + ["/usr/bin/gamescope", "-w", "1280", "--", "game"], + Path("/tmp/status"), + "wayland", + ), + [ + "/usr/bin/gamescope", + "--backend", + "wayland", + "-w", + "1280", + "--", + mock.ANY, + "-P", + "-m", + "ptlinux.gamescope_child_runtime", + "--status-file", + "/tmp/status", + "--", + "game", + ], + ) + + def test_can_force_composition_for_an_unsafe_wayland_build(self) -> None: + command = _supervised_command( + ["gamescope", "--", "game"], + Path("/tmp/status"), + "wayland", + force_composition=True, + ) + + self.assertEqual(command[:4], [ + "gamescope", + "--backend", + "wayland", + "--force-composition", + ]) + + def test_reads_the_gamescope_release_tuple(self) -> None: + completed = mock.Mock( + returncode=0, + stdout="[gamescope] gamescope version 3.16.20+ds-1 (gcc 15.2.0)\n" + ) + with mock.patch( + "ptlinux.gamescope_runtime.subprocess.run", + return_value=completed, + ) as run: + self.assertEqual( + _read_gamescope_version("/usr/games/gamescope"), + (3, 16, 20), + ) + + run.assert_called_once_with( + ["/usr/games/gamescope", "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + timeout=1.0, + check=False, + ) + + def test_selects_upstream_or_launcher_local_wayland_backing_fix(self) -> None: + with mock.patch( + "ptlinux.gamescope_runtime._read_gamescope_version", + side_effect=[(3, 16, 28), (3, 16, 20)], + ), mock.patch( + "ptlinux.gamescope_runtime._bundled_gamescope_is_usable", + return_value=True, + ), mock.patch( + "ptlinux.gamescope_runtime._bundled_gamescope_path", + return_value=Path("/opt/ptlinux/gamescope"), + ): + self.assertEqual( + _select_wayland_gamescope("/usr/games/gamescope"), + ("/usr/games/gamescope", False, False), + ) + self.assertEqual( + _select_wayland_gamescope("/usr/games/gamescope"), + ("/opt/ptlinux/gamescope", False, True), + ) + + def test_unsafe_wayland_build_uses_composed_fallback(self) -> None: + with ( + mock.patch( + "ptlinux.gamescope_runtime._read_gamescope_version", + return_value=(3, 16, 20), + ), + mock.patch( + "ptlinux.gamescope_runtime._bundled_gamescope_is_usable", + return_value=False, + ), + ): + self.assertEqual( + _select_wayland_gamescope("gamescope"), + ("gamescope", True, False), + ) + + def test_kubuntu_keeps_every_native_wayland_build_fully_composed(self) -> None: + environment = { + "XDG_CURRENT_DESKTOP": "KDE", + "XDG_SESSION_DESKTOP": "KDE", + "DESKTOP_SESSION": "plasmawayland", + } + with ( + mock.patch( + "ptlinux.gamescope_runtime._read_gamescope_version", + return_value=(3, 16, 28), + ), + mock.patch( + "ptlinux.gamescope_runtime._bundled_gamescope_is_usable", + return_value=True, + ) as bundled_gamescope, + ): + self.assertEqual( + _select_wayland_gamescope( + "/usr/games/gamescope", + environment=environment, + ), + ("/usr/games/gamescope", True, False), + ) + + self.assertTrue(_is_kde_session(environment)) + bundled_gamescope.assert_not_called() + + def test_recognizes_cinnamon_session_names(self) -> None: + self.assertTrue( + _is_cinnamon_session( + { + "XDG_CURRENT_DESKTOP": "X-Cinnamon:GNOME", + "DESKTOP_SESSION": "cinnamon2d", + } + ) + ) + self.assertFalse(_is_cinnamon_session({"XDG_CURRENT_DESKTOP": "GNOME"})) + + def test_launcher_local_gamescope_matches_its_pinned_digest(self) -> None: + with mock.patch( + "ptlinux.gamescope_runtime._bundled_gamescope_host_supported", + return_value=True, + ): + self.assertTrue(_bundled_gamescope_is_usable()) + + def test_repairs_a_non_executable_verified_launcher_local_gamescope( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + executable = Path(temporary) / "gamescope" + payload = b"verified launcher-local gamescope" + executable.write_bytes(payload) + executable.chmod(0o644) + + with ( + mock.patch( + "ptlinux.gamescope_runtime._bundled_gamescope_host_supported", + return_value=True, + ), + mock.patch( + "ptlinux.gamescope_runtime._bundled_gamescope_path", + return_value=executable, + ), + mock.patch( + "ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256", + hashlib.sha256(payload).hexdigest(), + ), + ): + self.assertTrue(_bundled_gamescope_is_usable()) + + self.assertEqual(executable.stat().st_mode & 0o777, 0o755) + + def test_does_not_make_an_unverified_launcher_local_gamescope_executable( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + executable = Path(temporary) / "gamescope" + executable.write_bytes(b"unexpected contents") + executable.chmod(0o644) + + with ( + mock.patch( + "ptlinux.gamescope_runtime._bundled_gamescope_host_supported", + return_value=True, + ), + mock.patch( + "ptlinux.gamescope_runtime._bundled_gamescope_path", + return_value=executable, + ), + mock.patch( + "ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256", + "0" * 64, + ), + ): + self.assertFalse(_bundled_gamescope_is_usable()) + + self.assertEqual(executable.stat().st_mode & 0o777, 0o644) + + def test_selects_wayland_only_for_an_accessible_socket(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + socket_path = Path(temporary) / "wayland-test" + with socket.socket(socket.AF_UNIX) as wayland_socket: + wayland_socket.bind(str(socket_path)) + self.assertEqual( + _select_host_backend( + { + "XDG_RUNTIME_DIR": temporary, + "WAYLAND_DISPLAY": socket_path.name, + } + ), + "wayland", + ) + self.assertEqual( + _select_host_backend( + { + "XDG_RUNTIME_DIR": temporary, + "WAYLAND_DISPLAY": socket_path.name, + "DISPLAY": ":1", + "XDG_CURRENT_DESKTOP": "X-Cinnamon", + } + ), + "sdl", + ) + self.assertEqual( + _select_host_backend( + { + "XDG_RUNTIME_DIR": temporary, + "WAYLAND_DISPLAY": socket_path.name, + "XDG_CURRENT_DESKTOP": "X-Cinnamon", + } + ), + "wayland", + ) + self.assertEqual( + _select_host_backend( + { + "XDG_RUNTIME_DIR": temporary, + "WAYLAND_DISPLAY": socket_path.name, + "DISPLAY": ":1", + "XDG_CURRENT_DESKTOP": "KDE", + } + ), + "sdl", + ) + self.assertEqual( + _select_host_backend( + { + "XDG_RUNTIME_DIR": temporary, + "WAYLAND_DISPLAY": socket_path.name, + "XDG_CURRENT_DESKTOP": "KDE", + } + ), + "wayland", + ) + + self.assertEqual( + _select_host_backend( + { + "XDG_RUNTIME_DIR": temporary, + "WAYLAND_DISPLAY": "missing-wayland-socket", + } + ), + "sdl", + ) + + def test_maps_signals_to_a_shell_exit_status(self) -> None: + self.assertEqual(_process_exit_status(-6), 134) + self.assertEqual(_process_exit_status(7), 7) + + def test_tunes_resize_steps_and_keeps_the_host_window_composited(self) -> None: + mapper = mock.Mock() + mapper.find_window_by_pid.return_value = 41 + mapper.enable_window_scaling.return_value = True + mapper.window_allows_scaling.return_value = True + mapper.set_resize_increments.return_value = True + mapper.prefer_composited_presentation.return_value = True + process = mock.Mock() + process.pid = 1234 + process.poll.return_value = None + publisher = mock.Mock() + + self.assertEqual( + _tune_process_window( + mapper, + process, + 1280, + 960, + host_focus_publisher=publisher, + ), + (64, 48), + ) + self.assertEqual(publisher.update.call_args_list, [ + mock.call(mapper, 41), + mock.call(mapper, 41), + ]) + mapper.enable_window_scaling.assert_called_once_with(41, confirm=False) + mapper.window_allows_scaling.assert_called_once_with(41) + mapper.set_resize_increments.assert_called_once_with( + 41, + (1280, 960), + (64, 48), + ) + mapper.prefer_composited_presentation.assert_called_once_with(41) + + def test_tuning_retains_hints_while_muffin_refreshes_its_frame(self) -> None: + mapper = mock.Mock() + mapper.find_window_by_pid.return_value = 41 + mapper.enable_window_scaling.return_value = True + mapper.window_allows_scaling.side_effect = [False, True] + mapper.set_resize_increments.return_value = True + mapper.prefer_composited_presentation.return_value = True + process = mock.Mock(pid=1234) + process.poll.return_value = None + + with ( + mock.patch( + "ptlinux.gamescope_runtime.time.monotonic", + side_effect=[0.0, 0.0, 0.1], + ), + mock.patch("ptlinux.gamescope_runtime.time.sleep") as sleep, + ): + self.assertEqual( + _tune_process_window(mapper, process, 1280, 960), + (64, 48), + ) + + mapper.find_window_by_pid.assert_called_once_with(1234) + self.assertEqual( + mapper.enable_window_scaling.call_args_list, + [ + mock.call(41, confirm=False), + mock.call(41, confirm=False), + ], + ) + sleep.assert_called_once_with(0.05) + + def test_host_focus_publisher_tracks_active_and_minimized_state(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + with ( + mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}), + mock.patch( + "ptlinux.gamescope_runtime.time.monotonic", + side_effect=[10.0, 10.1, 10.2], + ), + ): + publisher = _HostFocusPublisher.create() + try: + mapper = mock.Mock() + mapper.window_state.return_value = 1 + mapper.window_is_active.return_value = True + publisher.update(mapper, 41) + self.assertEqual( + publisher.path.read_text(encoding="ascii").split()[0], + "1", + ) + + mapper.window_state.return_value = 3 + publisher.update(mapper, 41) + self.assertEqual( + publisher.path.read_text(encoding="ascii").split()[0], + "0", + ) + finally: + state_path = publisher.path + publisher.close() + + self.assertFalse(state_path.exists()) + + def test_host_focus_publisher_preserves_an_inactive_minimized_remap( + self, + ) -> None: + with tempfile.TemporaryDirectory() as temporary: + with ( + mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}), + mock.patch( + "ptlinux.gamescope_runtime.time.monotonic", + side_effect=[10.0, 10.1, 10.2, 10.3, 10.4], + ), + ): + publisher = _HostFocusPublisher.create() + try: + mapper = mock.Mock() + mapper.window_state.side_effect = [1, 3, 1, 1] + mapper.window_is_active.side_effect = [True, False, True] + + publisher.update(mapper, 41) + publisher.update(mapper, 41) + publisher.update(mapper, 41) + + mapper.minimize_window.assert_called_once_with(41) + self.assertEqual( + publisher.path.read_text(encoding="ascii").split()[0], + "0", + ) + + publisher.update(mapper, 41) + self.assertEqual( + publisher.path.read_text(encoding="ascii").split()[0], + "1", + ) + mapper.minimize_window.assert_called_once_with(41) + finally: + publisher.close() + + def test_host_focus_diagnostics_capture_the_minimize_remap_cycle(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + environment = { + "XDG_RUNTIME_DIR": temporary, + WINDOW_DIAGNOSTICS_ENV: "1", + WINDOW_DIAGNOSTICS_SESSION_ENV: "b" * 32, + } + output = io.StringIO() + with ( + mock.patch.dict(os.environ, environment, clear=True), + mock.patch( + "ptlinux.gamescope_runtime.time.monotonic", + side_effect=[10.0, 10.1, 10.2, 10.4], + ), + contextlib.redirect_stderr(output), + ): + diagnostics = WindowDiagnosticRecorder( + "gamescope_host", + environment=environment, + monotonic=lambda: 20.0, + ) + publisher = _HostFocusPublisher.create(diagnostics=diagnostics) + summary: dict[str, object] = {} + try: + mapper = mock.Mock() + mapper.window_state.side_effect = [1, 3, 1] + mapper.window_is_active.side_effect = [True, False] + mapper.minimize_window.return_value = True + mapper.window_diagnostic_snapshot.return_value = { + "wm_state": "normal", + "allows_resize": True, + "allows_maximize_horizontal": False, + "allows_maximize_vertical": False, + } + mapper.window_net_wm_state_flags.side_effect = [ + { + "hidden": False, + "above": False, + "demands_attention": False, + "maximized_horizontal": False, + "maximized_vertical": False, + }, + { + "hidden": True, + "above": False, + "demands_attention": False, + "maximized_horizontal": False, + "maximized_vertical": False, + }, + { + "hidden": False, + "above": False, + "demands_attention": False, + "maximized_horizontal": False, + "maximized_vertical": False, + }, + ] + + publisher.update(mapper, 41) + publisher.update(mapper, 41) + publisher.update(mapper, 41) + summary = publisher.summary_details() + finally: + publisher.close() + diagnostics.close(**summary) + + payloads = [ + parse_window_diagnostic(line) + for line in output.getvalue().splitlines() + ] + diagnostics_by_name = { + payload["diagnostic"]: payload + for payload in payloads + if payload is not None + } + self.assertIn("host_minimize_latched", diagnostics_by_name) + self.assertIn("host_hidden_state_cleared", diagnostics_by_name) + remap = diagnostics_by_name["host_inactive_remap"] + details = remap["details"] + assert isinstance(details, dict) + self.assertTrue(details["minimize_request_accepted"]) + self.assertFalse(details["allows_maximize_horizontal"]) + self.assertEqual(summary["inactive_remaps"], 1) + self.assertEqual(summary["hidden_state_clears"], 1) + self.assertEqual(summary["reiconify_accepted"], 1) + + def test_optimized_wait_refreshes_host_focus_before_returning(self) -> None: + process = mock.Mock(pid=1234) + process.poll.return_value = 0 + mapper = mock.Mock() + mapper.find_window_by_pid.return_value = 41 + publisher = mock.Mock() + + self.assertEqual( + _wait_for_optimized_gamescope( + process, + None, + host_focus_publisher=publisher, + host_mapper=mapper, + ), + 1, + ) + + publisher.update.assert_called_once_with(mapper, 41) + + def test_optimized_wait_returns_the_nested_child_status(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + status_file = Path(temporary) / "status" + status_file.write_text("0\n", encoding="ascii") + process = mock.Mock() + process.poll.return_value = None + + self.assertEqual(_wait_for_optimized_gamescope(process, status_file), 0) + + process.terminate.assert_called_once_with() + process.kill.assert_not_called() + process.wait.assert_called_once_with(timeout=2.0) + + @mock.patch("ptlinux.gamescope_runtime.subprocess.Popen") + @mock.patch( + "ptlinux.gamescope_runtime._tune_process_window", + return_value=(64, 48), + ) + @mock.patch("ptlinux.gamescope_runtime._open_host_x11") + @mock.patch( + "ptlinux.gamescope_runtime._select_host_backend", + return_value="sdl", + ) + def test_x11_path_tunes_the_window_and_preserves_child_status( + self, + _select_host_backend: mock.Mock, + open_host_x11: mock.Mock, + tune_process_window: mock.Mock, + popen: mock.Mock, + ) -> None: + mapper = open_host_x11.return_value + process = popen.return_value + process.poll.return_value = 7 + + stderr = io.StringIO() + with ( + mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True), + contextlib.redirect_stderr(stderr), + mock.patch( + "ptlinux.gamescope_runtime._read_child_status", + return_value=7, + ), + ): + status = run_gamescope(["gamescope", "--", "game"], 1280, 960) + + self.assertEqual(status, 7) + self.assertEqual( + popen.call_args.args[0], + [ + "gamescope", + "--backend", + "sdl", + "--force-composition", + "--", + mock.ANY, + "-P", + "-m", + "ptlinux.gamescope_child_runtime", + "--status-file", + mock.ANY, + "--", + "game", + ], + ) + self.assertEqual(popen.call_args.kwargs["env"]["SDL_VIDEODRIVER"], "x11") + self.assertEqual( + popen.call_args.kwargs["env"][ + "SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR" + ], + "0", + ) + self.assertIn( + GAMESCOPE_HOST_FOCUS_STATE_ENV, + popen.call_args.kwargs["env"], + ) + tune_process_window.assert_called_once_with( + mapper, + process, + 1280, + 960, + host_focus_publisher=mock.ANY, + ) + mapper.close.assert_called_once_with() + self.assertIn("64x48", stderr.getvalue()) + + @mock.patch("ptlinux.gamescope_runtime.subprocess.Popen") + @mock.patch("ptlinux.gamescope_runtime._open_host_x11", return_value=None) + @mock.patch( + "ptlinux.gamescope_runtime._select_host_backend", + return_value="sdl", + ) + def test_x11_path_disables_nested_focus_repair_without_host_monitor( + self, + _select_host_backend: mock.Mock, + _open_host_x11: mock.Mock, + popen: mock.Mock, + ) -> None: + process = popen.return_value + process.poll.return_value = 0 + + with ( + mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True), + contextlib.redirect_stderr(io.StringIO()), + mock.patch( + "ptlinux.gamescope_runtime._read_child_status", + return_value=0, + ), + ): + self.assertEqual( + run_gamescope(["gamescope", "--", "game"], 1280, 960), + 0, + ) + + self.assertEqual( + popen.call_args.kwargs["env"][GAMESCOPE_HOST_FOCUS_STATE_ENV], + "unavailable", + ) + + @mock.patch("ptlinux.gamescope_runtime.subprocess.Popen") + @mock.patch("ptlinux.gamescope_runtime._open_host_x11") + @mock.patch( + "ptlinux.gamescope_runtime._select_wayland_gamescope", + return_value=("/opt/ptlinux/gamescope", False, True), + ) + @mock.patch( + "ptlinux.gamescope_runtime._select_host_backend", + return_value="wayland", + ) + def test_wayland_path_avoids_sdl_and_preserves_child_status( + self, + _select_host_backend: mock.Mock, + select_wayland_gamescope: mock.Mock, + open_host_x11: mock.Mock, + popen: mock.Mock, + ) -> None: + process = popen.return_value + process.poll.return_value = 0 + + stderr = io.StringIO() + with ( + mock.patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=True), + contextlib.redirect_stderr(stderr), + mock.patch( + "ptlinux.gamescope_runtime._read_child_status", + return_value=0, + ), + ): + status = run_gamescope(["gamescope", "--", "game"], 1280, 960) + + self.assertEqual(status, 0) + self.assertEqual( + popen.call_args.args[0], + [ + "/opt/ptlinux/gamescope", + "--backend", + "wayland", + "--", + mock.ANY, + "-P", + "-m", + "ptlinux.gamescope_child_runtime", + "--status-file", + mock.ANY, + "--", + "game", + ], + ) + self.assertNotIn("SDL_VIDEODRIVER", popen.call_args.kwargs["env"]) + self.assertNotIn( + "SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR", + popen.call_args.kwargs["env"], + ) + self.assertEqual( + popen.call_args.kwargs["env"][ + "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE" + ], + "1", + ) + open_host_x11.assert_not_called() + select_wayland_gamescope.assert_called_once_with( + "gamescope", + environment=mock.ANY, + ) + self.assertIn("native Wayland", stderr.getvalue()) + self.assertIn("lazy-output", stderr.getvalue()) + self.assertIn("login-window visibility", stderr.getvalue()) + + @mock.patch("ptlinux.gamescope_runtime.subprocess.Popen") + @mock.patch( + "ptlinux.gamescope_runtime._tune_process_window", + return_value=(64, 48), + ) + @mock.patch("ptlinux.gamescope_runtime._open_host_x11") + @mock.patch( + "ptlinux.gamescope_runtime._select_host_backend", + return_value="sdl", + ) + def test_kubuntu_wayland_uses_xwayland_presentation_and_reports_policy( + self, + _select_host_backend: mock.Mock, + open_host_x11: mock.Mock, + tune_process_window: mock.Mock, + popen: mock.Mock, + ) -> None: + process = popen.return_value + process.poll.return_value = 0 + environment = { + "WAYLAND_DISPLAY": "wayland-0", + "DISPLAY": ":1", + "XDG_CURRENT_DESKTOP": "KDE", + "DESKTOP_SESSION": "plasmawayland", + WINDOW_DIAGNOSTICS_ENV: "1", + WINDOW_DIAGNOSTICS_SESSION_ENV: "c" * 32, + } + stderr = io.StringIO() + with ( + mock.patch.dict(os.environ, environment, clear=True), + mock.patch( + "ptlinux.gamescope_runtime._read_child_status", + return_value=0, + ), + contextlib.redirect_stderr(stderr), + ): + status = run_gamescope(["gamescope", "--", "game"], 1280, 960) + + self.assertEqual(status, 0) + self.assertEqual( + popen.call_args.args[0][:4], + [ + "gamescope", + "--backend", + "sdl", + "--force-composition", + ], + ) + self.assertNotIn( + "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE", + popen.call_args.kwargs["env"], + ) + open_host_x11.assert_called_once_with() + tune_process_window.assert_called_once() + self.assertIn("KDE Wayland compatibility mode active", stderr.getvalue()) + payloads = [ + parse_window_diagnostic(line) + for line in stderr.getvalue().splitlines() + ] + runtime_configured = next( + payload + for payload in payloads + if payload is not None and payload["diagnostic"] == "runtime_configured" + ) + details = runtime_configured["details"] + assert isinstance(details, dict) + self.assertTrue(details["kde_session"]) + self.assertTrue(details["kde_wayland_compatibility_mode"]) + self.assertTrue(details["kde_wayland_xwayland_fallback"]) + self.assertEqual( + details["backend_reason"], + "kde_wayland_xwayland_compatibility", + ) + self.assertEqual(details["presentation_mode"], "sdl_composed") + + def test_main_reports_an_empty_command(self) -> None: + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + status = main(["--width", "1280", "--height", "960"]) + + self.assertEqual(status, 1) + self.assertIn("cannot be empty", stderr.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ptlinux_packaging.py b/tests/test_ptlinux_packaging.py new file mode 100644 index 0000000..2fa745b --- /dev/null +++ b/tests/test_ptlinux_packaging.py @@ -0,0 +1,866 @@ +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import shlex +import subprocess +import sys +import tarfile +import tempfile +import unittest + +from ptlinux import __version__ +from ptlinux.desktop_entry import escape_icon_path, quote_exec_argument +from ptlinux.launcher_wrapper import quote_shell_word +from scripts.repository_paths import PROJECT_ROOT, SOURCE_ROOT +from tools.sign_release_artifact import sign_release_artifact + + +class AioPackagingTests(unittest.TestCase): + def test_bootstrap_release_remains_reproducible_with_a_fixed_epoch(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + outputs = (root / "first", root / "second") + for output in outputs: + environment = os.environ.copy() + environment.pop("PTLINUX_VERSION", None) + environment.update( + { + "PTLINUX_OUTPUT_DIRECTORY": str(output), + "PTLINUX_PYTHON": sys.executable, + "SOURCE_DATE_EPOCH": "0", + } + ) + built = subprocess.run( + ["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(built.returncode, 0, built.stderr) + + first = { + path.name: path.read_bytes() + for path in outputs[0].iterdir() + if path.is_file() + } + second = { + path.name: path.read_bytes() + for path in outputs[1].iterdir() + if path.is_file() + } + self.assertEqual(first, second) + + def test_packaged_installer_uses_the_compatible_top_level_source_layout(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + output = root / "dist" + build_environment = os.environ.copy() + build_environment.pop("PTLINUX_VERSION", None) + build_environment.update( + { + "PTLINUX_OUTPUT_DIRECTORY": str(output), + "PTLINUX_PYTHON": sys.executable, + "SOURCE_DATE_EPOCH": "0", + } + ) + built = subprocess.run( + ["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")], + cwd=PROJECT_ROOT, + env=build_environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + self.assertEqual(built.returncode, 0, built.stderr) + + release_name = ( + f"pristontale-eu-linux-installer-v{__version__.replace('.', '-')}" + ) + archive = output / f"{release_name}.tar.gz" + extracted = root / "extracted" + with tarfile.open(archive, "r:gz") as bundle: + bundle.extractall(extracted, filter="data") + packaged_root = extracted / release_name + self.assertTrue((packaged_root / "ptlinux").is_dir()) + self.assertFalse((packaged_root / "src").exists()) + + home = root / "home" + data_home = root / "desktop-data" + install_directory = root / "managed-install" + install_environment = os.environ.copy() + install_environment.pop("PTLINUX_INSTALL_DIR", None) + install_environment.update( + { + "HOME": str(home), + "XDG_DATA_HOME": str(data_home), + "PATH": "/usr/bin:/bin", + "PTLINUX_PYTHON": sys.executable, + } + ) + installed = subprocess.run( + [ + "bash", + str(packaged_root / "install.sh"), + "--install-dir", + str(install_directory), + "--skip-deps", + "--skip-doctor", + "--skip-runtime-bootstrap", + ], + cwd=packaged_root, + env=install_environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + self.assertEqual(installed.returncode, 0, installed.stderr) + current = install_directory / "app/current" + self.assertTrue((current / "ptlinux/cli.py").is_file()) + self.assertTrue((current / "ptdiag/logs.py").is_file()) + + def test_installer_dry_run_reports_destinations_without_writing(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + home = root / "home" + data_home = root / "desktop-data" + install_directory = root / "managed-install" + environment = os.environ.copy() + environment.pop("PTLINUX_INSTALL_DIR", None) + environment.update( + { + "HOME": str(home), + "XDG_DATA_HOME": str(data_home), + "PATH": "/usr/bin:/bin", + "PTLINUX_PYTHON": sys.executable, + } + ) + + result = subprocess.run( + [ + "bash", + str(PROJECT_ROOT / "install.sh"), + "--install-dir", + str(install_directory), + "--dry-run", + "--skip-deps", + "--skip-doctor", + "--skip-runtime-bootstrap", + ], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"Install data: {install_directory}", result.stdout) + self.assertIn("Skipping host dependency installation.", result.stdout) + self.assertFalse(install_directory.exists()) + self.assertFalse(home.exists()) + self.assertFalse(data_home.exists()) + + def test_installer_writes_path_independent_desktop_entry(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + home = root / "home with spaces" + data_home = root / "data with spaces" + home.mkdir() + fake_bin = root / "bin" + fake_bin.mkdir() + fake_umu = fake_bin / "umu-run" + fake_umu.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + fake_umu.chmod(0o755) + environment = os.environ.copy() + environment.pop("PTLINUX_INSTALL_DIR", None) + environment.update( + { + "HOME": str(home), + "XDG_DATA_HOME": str(data_home), + "PATH": f"{fake_bin}:/usr/bin:/bin", + "PTLINUX_PYTHON": sys.executable, + } + ) + result = subprocess.run( + [ + "bash", + str(PROJECT_ROOT / "install.sh"), + "--skip-deps", + "--skip-doctor", + ], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(str(fake_umu.resolve()), result.stdout) + self.assertIn( + f"Note: {home / '.local/bin'} is not in PATH for this shell.", + result.stdout, + ) + output_lines = result.stdout.splitlines() + start_line = output_lines[output_lines.index("Start PristonTale EU with:") + 1] + self.assertEqual( + shlex.split(start_line), + [str(home / ".local/bin/pristontale-eu"), "gui"], + ) + self.assertIn("export PATH=", result.stdout) + default_install = home / "Games/PristontaleEU" + self.assertTrue((default_install / "app/current/ptlinux").is_dir()) + self.assertTrue((default_install / "app/current/ptlinux/umu.py").is_file()) + self.assertTrue( + (default_install / "app/current/ptlinux/ui/dashboard.py").is_file() + ) + self.assertTrue( + ( + default_install + / "app/current/ptlinux/ui/viewmodels/downloader.py" + ).is_file() + ) + self.assertTrue( + (default_install / "app/current/ptlinux/native/pt-identity-probe.exe").is_file() + ) + self.assertTrue( + (default_install / "app/current/ptlinux/native/ptr-bcrypt-proxy.dll").is_file() + ) + gamescope = ( + default_install + / "app/current/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" + ) + self.assertTrue(gamescope.is_file()) + self.assertEqual(gamescope.stat().st_mode & 0o777, 0o755) + self.assertFalse( + (default_install / "app/current/ptlinux/desktop_entry.py").exists() + ) + self.assertFalse( + (default_install / "app/current/ptlinux/launcher_wrapper.py").exists() + ) + self.assertFalse( + (default_install / "app/current/ptlinux/world_fix.py").exists() + ) + self.assertTrue((default_install / "app/current").is_symlink()) + self.assertEqual( + (default_install / "app/current").resolve().stat().st_mode & 0o777, + 0o755, + ) + launcher = home / ".local/bin/pristontale-eu" + desktop = data_home / "applications/eu.pristontale.PTLinux.desktop" + icon = data_home / "pixmaps/eu.pristontale.PTLinux.png" + self.assertTrue(launcher.is_file()) + self.assertEqual( + icon.read_bytes(), + (PROJECT_ROOT / "packaging/eu.pristontale.PTLinux.png").read_bytes(), + ) + entry = desktop.read_text(encoding="utf-8") + exec_line = next(line for line in entry.splitlines() if line.startswith("Exec=")) + icon_line = next(line for line in entry.splitlines() if line.startswith("Icon=")) + self.assertEqual( + exec_line, + f"Exec={quote_exec_argument(str(launcher))} gui", + ) + self.assertEqual(icon_line, f"Icon={escape_icon_path(str(icon))}") + self.assertIn("StartupWMClass=PristonTaleEU", entry) + self.assertNotIn("TryExec=", entry) + self.assertIn( + f"install_directory={quote_shell_word(str(default_install))}", + launcher.read_text(encoding="utf-8"), + ) + self.assertIn( + 'exec "$python_bin" -B -P -m ptlinux', + launcher.read_text(encoding="utf-8"), + ) + + arguments = shlex.split(exec_line.removeprefix("Exec=")) + self.assertEqual(arguments, [str(launcher), "gui"]) + arguments[1] = "--help" + launched = subprocess.run( + arguments, + cwd=root, + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + self.assertEqual(launched.returncode, 0, launched.stderr) + self.assertIn("PristonTale EU all-in-one", launched.stdout) + + stale_directory = root / "unpacked-old-installer" + stale_package = stale_directory / "ptlinux" + stale_package.mkdir(parents=True) + (stale_package / "__init__.py").write_text("", encoding="utf-8") + (stale_package / "__main__.py").write_text( + 'print("STALE LAUNCHER WAS IMPORTED")\n', + encoding="utf-8", + ) + isolated = subprocess.run( + [launcher, "--version"], + cwd=stale_directory, + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + self.assertEqual(isolated.returncode, 0, isolated.stderr) + self.assertEqual(isolated.stdout.strip(), f"pristontale-eu {__version__}") + self.assertNotIn("STALE LAUNCHER", isolated.stdout) + + def test_installer_persists_custom_install_directory(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + home = root / "home" + data_home = root / "desktop data" + install_directory = root / "External $(touch INJECTED)'s Game" + home.mkdir() + environment = os.environ.copy() + environment.pop("PTLINUX_INSTALL_DIR", None) + environment.update( + { + "HOME": str(home), + "XDG_DATA_HOME": str(data_home), + "PATH": "/usr/bin:/bin", + "PTLINUX_PYTHON": sys.executable, + } + ) + result = subprocess.run( + [ + "bash", + str(PROJECT_ROOT / "install.sh"), + "--install-dir", + str(install_directory), + "--skip-deps", + "--skip-doctor", + "--skip-runtime-bootstrap", + ], + cwd=root, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((install_directory / "app/current/ptlinux").is_dir()) + self.assertTrue((install_directory / "app/current").is_symlink()) + launcher = home / ".local/bin/pristontale-eu" + wrapper = launcher.read_text(encoding="utf-8") + self.assertIn( + f"install_directory={quote_shell_word(str(install_directory))}", + wrapper, + ) + + runtime_environment = environment.copy() + runtime_environment.pop("PTLINUX_INSTALL_DIR", None) + runtime_environment["XDG_DATA_HOME"] = str(root / "different data home") + launched = subprocess.run( + [str(launcher), "--help"], + cwd=root, + env=runtime_environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + self.assertEqual(launched.returncode, 0, launched.stderr) + self.assertIn("PristonTale EU all-in-one", launched.stdout) + self.assertFalse((root / "INJECTED").exists()) + + def test_installer_preserves_the_previous_default_on_upgrade(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + home = root / "home" + data_home = root / "data" + legacy_install = data_home / "pristontale-eu" + legacy_game = ( + legacy_install + / "launcher-prefix/drive_c/Program Files (x86)/Pristontale EU/Game.exe" + ) + legacy_game.parent.mkdir(parents=True) + legacy_game.write_bytes(b"existing managed game") + legacy_package = legacy_install / "app/ptlinux" + legacy_package.mkdir(parents=True) + (legacy_package / "__init__.py").write_text( + '__version__ = "0.4.7"\n', + encoding="utf-8", + ) + settings_file = home / ".config/pristontale-eu/settings.json" + settings_file.parent.mkdir(parents=True) + settings_file.write_text( + json.dumps( + { + "borderless": False, + "runtime": "proton7", + "proton_sync": "esync", + } + ), + encoding="utf-8", + ) + environment = os.environ.copy() + environment.pop("PTLINUX_INSTALL_DIR", None) + environment.update( + { + "HOME": str(home), + "XDG_DATA_HOME": str(data_home), + "PATH": "/usr/bin:/bin", + "PTLINUX_PYTHON": sys.executable, + } + ) + result = subprocess.run( + [ + "bash", + str(PROJECT_ROOT / "install.sh"), + "--skip-deps", + "--skip-doctor", + "--skip-runtime-bootstrap", + ], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("preserving its previous data location", result.stdout) + launcher = home / ".local/bin/pristontale-eu" + self.assertIn( + f"install_directory={quote_shell_word(str(legacy_install))}", + launcher.read_text(encoding="utf-8"), + ) + self.assertFalse((home / "Games/PristontaleEU/app").exists()) + self.assertTrue((legacy_install / "app/current").is_symlink()) + settings_result = subprocess.run( + [launcher, "--json", "settings", "--init"], + cwd=root, + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + self.assertEqual(settings_result.returncode, 0, settings_result.stderr) + migrated = json.loads(settings_result.stdout)["settings"] + self.assertFalse(migrated["borderless"]) + self.assertEqual(migrated["runtime"], "ge-proton") + self.assertEqual(migrated["proton_sync"], "esync") + persisted = json.loads(settings_file.read_text(encoding="utf-8")) + self.assertEqual(persisted["runtime"], "ge-proton") + self.assertIs(persisted["ge_proton_default_migrated"], True) + + def test_production_release_requires_a_signing_key(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) / "output" + environment = os.environ.copy() + environment.pop("PTLINUX_SIGNING_KEY", None) + environment["PTLINUX_RELEASE_MODE"] = "production" + environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output) + result = subprocess.run( + ["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("PTLINUX_SIGNING_KEY is required", result.stderr) + self.assertFalse(output.exists()) + + def test_production_release_rejects_a_key_path_inside_the_checkout(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) / "output" + environment = os.environ.copy() + environment["PTLINUX_RELEASE_MODE"] = "production" + environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output) + environment["PTLINUX_PYTHON"] = sys.executable + environment["PTLINUX_SIGNING_KEY"] = str( + PROJECT_ROOT / "release-keys/fixture-private.pem" + ) + result = subprocess.run( + ["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("outside the repository", result.stderr) + self.assertFalse(output.exists()) + + def test_release_artifact_signature_is_generated_and_verified(self) -> None: + openssl = shutil.which("openssl") + if openssl is None: + self.skipTest("OpenSSL is unavailable") + with tempfile.TemporaryDirectory() as temporary_directory: + root = Path(temporary_directory) + private_key = root / "private.pem" + public_key = root / "public.pem" + artifact = root / "installer.tar.gz" + artifact.write_bytes(b"release bytes") + commands = ( + [ + openssl, + "genpkey", + "-quiet", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + "-out", + str(private_key), + ], + [ + openssl, + "pkey", + "-in", + str(private_key), + "-pubout", + "-out", + str(public_key), + ], + ) + for command in commands: + generated = subprocess.run( + command, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + self.assertEqual(generated.returncode, 0, generated.stderr) + private_key.chmod(0o600) + + signature = sign_release_artifact( + artifact, + signing_key=private_key, + public_key=public_key, + ) + + self.assertTrue(signature.is_file()) + self.assertEqual(signature.stat().st_mode & 0o777, 0o644) + artifact.write_bytes(b"tampered release bytes") + verification = subprocess.run( + [ + openssl, + "dgst", + "-sha256", + "-verify", + str(public_key), + "-signature", + str(signature), + str(artifact), + ], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + self.assertNotEqual(verification.returncode, 0) + + def test_release_builder_packages_current_updater_sources(self) -> None: + self.assertIn( + f'version = "{__version__}"', + (PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8"), + ) + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) + environment = os.environ.copy() + environment.pop("PTLINUX_VERSION", None) + environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output) + environment["SOURCE_DATE_EPOCH"] = "0" + result = subprocess.run( + ["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + release_name = ( + f"pristontale-eu-linux-installer-v{__version__.replace('.', '-')}" + ) + archive = output / f"{release_name}.tar.gz" + checksum = archive.with_suffix(archive.suffix + ".sha256") + self.assertTrue(archive.is_file()) + self.assertTrue(checksum.is_file()) + self.assertFalse(Path(f"{archive}.sig").exists()) + self.assertTrue((output / "launcher-update-public.pem").is_file()) + zip_archive = output / f"{release_name}.zip" + self.assertTrue(zip_archive.is_file()) + self.assertFalse(Path(f"{zip_archive}.sig").exists()) + update_archive = output / f"pristontale-eu-linux-update-v{__version__}.tar.gz" + self.assertTrue(update_archive.is_file()) + update_checksum = update_archive.with_suffix(update_archive.suffix + ".sha256") + self.assertTrue(update_checksum.is_file()) + manifest = output / "stable.json" + self.assertTrue(manifest.is_file()) + for artifact in ( + archive, + checksum, + update_archive, + update_checksum, + manifest, + ): + self.assertEqual(artifact.stat().st_mode & 0o777, 0o644) + expected_sha256 = checksum.read_text(encoding="utf-8").split()[0] + self.assertEqual( + hashlib.sha256(archive.read_bytes()).hexdigest(), + expected_sha256, + ) + + release_root = release_name + with tarfile.open(archive, "r:gz") as release: + members = release.getmembers() + names = {member.name for member in members} + self.assertTrue( + all(member.mode & 0o022 == 0 for member in members), + "release entries must not be group- or world-writable", + ) + self.assertEqual( + next(member.mode for member in members if member.name == release_root), + 0o755, + ) + self.assertEqual( + {name for name in names if name.endswith(".md")}, + {f"{release_root}/README.md"}, + ) + self.assertIn(f"{release_root}/ptlinux/desktop_entry.py", names) + self.assertIn(f"{release_root}/ptlinux/install_status.py", names) + self.assertIn(f"{release_root}/ptlinux/ui/dashboard.py", names) + self.assertIn( + f"{release_root}/ptlinux/ui/components/progress_panel.py", + names, + ) + self.assertIn(f"{release_root}/ptlinux/launcher_wrapper.py", names) + self.assertIn(f"{release_root}/ptlinux/self_update.py", names) + self.assertIn(f"{release_root}/ptlinux/container_host.py", names) + self.assertIn(f"{release_root}/release/config.json", names) + self.assertIn(f"{release_root}/scripts/__init__.py", names) + self.assertIn(f"{release_root}/scripts/repository_paths.py", names) + self.assertIn(f"{release_root}/scripts/release_lib.py", names) + self.assertFalse(any(name.startswith(f"{release_root}/devtools/") for name in names)) + self.assertIn( + f"{release_root}/ptlinux/launcher-update-public.pem", + names, + ) + self.assertIn( + f"{release_root}/ptlinux/native/pt-identity-probe.exe", + names, + ) + self.assertIn( + f"{release_root}/ptlinux/native/ptr-bcrypt-proxy.dll", + names, + ) + gamescope_name = ( + f"{release_root}/ptlinux/native/" + "gamescope-3.16.20-ubuntu26.04-amd64" + ) + self.assertIn(gamescope_name, names) + self.assertEqual( + next(member.mode for member in members if member.name == gamescope_name), + 0o755, + ) + for name in ( + "gamescope-compat.README", + "gamescope-compat.copyright", + "gamescope-lazy-output-images.patch", + "gamescope-wayland-live-resize.patch", + "gamescope-wayland-letterbox-backing.patch", + "gamescope-wayland-libdecor-input-scope.patch", + "gamescope-keep-nested-windows-visible.patch", + ): + self.assertIn(f"{release_root}/ptlinux/native/{name}", names) + self.assertNotIn( + f"{release_root}/ptlinux/native/pt-world-fix.exe", + names, + ) + self.assertNotIn(f"{release_root}/ptlinux/world_fix.py", names) + archived_readme = release.extractfile(f"{release_root}/README.md") + self.assertIsNotNone(archived_readme) + assert archived_readme is not None + readme = archived_readme.read().decode("utf-8") + self.assertIn(release_name, readme) + self.assertIn("./install.sh", readme) + self.assertIn("pristontale-eu update --repair", readme) + self.assertIn("pristontale-eu --help", readme) + for command in ( + "gui", + "doctor", + "container-host", + "fetch", + "setup", + "update", + "sync", + "wine-update", + "import-game", + "cache-compatibility", + "prepare", + "play", + "settings", + "launcher-update", + ): + self.assertIn(f"pristontale-eu {command}", readme) + archived_depot = release.extractfile(f"{release_root}/ptlinux/depot.py") + self.assertIsNotNone(archived_depot) + assert archived_depot is not None + self.assertEqual( + archived_depot.read(), + (SOURCE_ROOT / "ptlinux/depot.py").read_bytes(), + ) + archived_login_trace = release.extractfile( + f"{release_root}/ptlinux/login_trace.py" + ) + self.assertIsNotNone(archived_login_trace) + assert archived_login_trace is not None + login_trace_source = archived_login_trace.read().decode("utf-8") + self.assertIn("LOGIN_TRACE_ENABLED = False", login_trace_source) + self.assertNotIn("+relay", login_trace_source) + self.assertNotIn("RelayInclude", login_trace_source) + + with tarfile.open(update_archive, "r:gz") as update: + update_root = f"pristontale-eu-linux-update-v{__version__}" + update_members = update.getmembers() + update_names = {member.name for member in update_members} + self.assertIn(f"{update_root}/release.json", update_names) + self.assertIn(f"{update_root}/ptlinux/self_update.py", update_names) + self.assertIn(f"{update_root}/ptlinux/container_host.py", update_names) + self.assertIn(f"{update_root}/ptlinux/import_state.py", update_names) + self.assertIn(f"{update_root}/ptlinux/ui/dashboard.py", update_names) + self.assertIn( + f"{update_root}/ptlinux/ui/dialogs/settings_dialog.py", + update_names, + ) + self.assertNotIn( + f"{update_root}/ptlinux/desktop_entry.py", + update_names, + ) + self.assertNotIn( + f"{update_root}/ptlinux/launcher_wrapper.py", + update_names, + ) + self.assertIn( + f"{update_root}/ptlinux/launcher-update-public.pem", + update_names, + ) + self.assertIn( + f"{update_root}/ptlinux/native/pt-identity-probe.exe", + update_names, + ) + self.assertIn( + f"{update_root}/ptlinux/native/ptr-bcrypt-proxy.dll", + update_names, + ) + gamescope_name = ( + f"{update_root}/ptlinux/native/" + "gamescope-3.16.20-ubuntu26.04-amd64" + ) + self.assertIn(gamescope_name, update_names) + self.assertEqual( + next( + member.mode + for member in update_members + if member.name == gamescope_name + ), + 0o755, + ) + for name in ( + "gamescope-compat.README", + "gamescope-compat.copyright", + "gamescope-lazy-output-images.patch", + "gamescope-wayland-live-resize.patch", + "gamescope-wayland-letterbox-backing.patch", + "gamescope-wayland-libdecor-input-scope.patch", + "gamescope-keep-nested-windows-visible.patch", + ): + self.assertIn( + f"{update_root}/ptlinux/native/{name}", + update_names, + ) + self.assertNotIn( + f"{update_root}/ptlinux/native/pt-world-fix.exe", + update_names, + ) + self.assertNotIn(f"{update_root}/ptlinux/world_fix.py", update_names) + + def test_release_builder_rejects_unsafe_version_text(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) / "output" + environment = os.environ.copy() + environment["PTLINUX_VERSION"] = "../unsafe" + environment["PTLINUX_OUTPUT_DIRECTORY"] = str(output) + result = subprocess.run( + ["bash", str(PROJECT_ROOT / "tools/build-aio-release.sh")], + cwd=PROJECT_ROOT, + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("expected X.Y.Z", result.stderr) + self.assertFalse(output.exists()) + + def test_compact_release_can_import_overlay_in_clean_interpreter(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + release_root = Path(temporary_directory) + shutil.copytree(SOURCE_ROOT / "ptlinux", release_root / "ptlinux") + (release_root / "ptdiag").mkdir() + shutil.copy2( + SOURCE_ROOT / "ptdiag" / "__init__.py", + release_root / "ptdiag" / "__init__.py", + ) + shutil.copy2( + SOURCE_ROOT / "ptdiag" / "assbin.py", + release_root / "ptdiag" / "assbin.py", + ) + shutil.copy2( + SOURCE_ROOT / "ptdiag" / "logs.py", + release_root / "ptdiag" / "logs.py", + ) + + environment = os.environ.copy() + environment["PYTHONPATH"] = str(release_root) + result = subprocess.run( + [sys.executable, "-c", "import ptlinux.overlay"], + cwd=release_root, + env=environment, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ptlinux_window_diagnostics.py b/tests/test_ptlinux_window_diagnostics.py new file mode 100644 index 0000000..aad33e0 --- /dev/null +++ b/tests/test_ptlinux_window_diagnostics.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import contextlib +import io +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from ptlinux.window_diagnostics import ( + WINDOW_DIAGNOSTIC_EVENT_LIMIT, + WINDOW_DIAGNOSTIC_PREFIX, + WINDOW_DIAGNOSTICS_ENV, + WINDOW_DIAGNOSTICS_SESSION_ENV, + WindowDiagnosticLog, + WindowDiagnosticRecorder, + parse_window_diagnostic, +) + + +class WindowDiagnosticTests(unittest.TestCase): + def test_local_log_keeps_bounded_samples_and_summaries_from_this_session(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "logs" / "windows-session.jsonl" + log = WindowDiagnosticLog(path, "a" * 32) + for component in ("game_window", "gamescope_host"): + payload = { + "component": component, + "session_id": "a" * 32, + "diagnostic": "focus_state_changed", + } + log.record({**payload, "session_id": "b" * 32}) + for sequence in range(WINDOW_DIAGNOSTIC_EVENT_LIMIT + 10): + log.record({**payload, "sequence": sequence + 1}) + for _ in range(2): + log.record({**payload, "diagnostic": "session_summary"}) + payloads = [json.loads(line) for line in path.read_text().splitlines()] + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual({item["session_id"] for item in payloads}, {"a" * 32}) + for component in ("game_window", "gamescope_host"): + records = [item for item in payloads if item["component"] == component] + self.assertEqual(len(records), WINDOW_DIAGNOSTIC_EVENT_LIMIT + 1) + self.assertEqual(records[-1]["diagnostic"], "session_summary") + + def test_local_log_io_failure_warns_once_without_interrupting_play(self) -> None: + log = WindowDiagnosticLog(Path("/unused/windows-session.jsonl"), "a" * 32) + payload = {"component": "game_window", "session_id": "a" * 32, "diagnostic": "focus"} + output = io.StringIO() + with ( + patch("ptlinux.window_diagnostics.Path.mkdir", side_effect=OSError("disk full")) as mkdir, + contextlib.redirect_stderr(output), + ): + log.record(payload) + log.record(payload) + mkdir.assert_called_once() + self.assertEqual(output.getvalue().count("could not be saved locally"), 1) + + def test_recorder_bounds_samples_and_always_emits_a_summary(self) -> None: + environment = { + WINDOW_DIAGNOSTICS_ENV: "1", + WINDOW_DIAGNOSTICS_SESSION_ENV: "a" * 32, + } + output = io.StringIO() + with contextlib.redirect_stderr(output): + recorder = WindowDiagnosticRecorder( + "gamescope_host", + environment=environment, + monotonic=lambda: 10.0, + ) + for index in range(WINDOW_DIAGNOSTIC_EVENT_LIMIT + 8): + recorder.record("host_inactive_remap", attempt=index) + recorder.close(minimized_latched=True) + + payloads = [ + parse_window_diagnostic(line) + for line in output.getvalue().splitlines() + ] + self.assertNotIn(None, payloads) + self.assertEqual(len(payloads), WINDOW_DIAGNOSTIC_EVENT_LIMIT + 1) + summary = payloads[-1] + assert summary is not None + self.assertEqual(summary["diagnostic"], "session_summary") + self.assertEqual(summary["session_id"], "a" * 32) + details = summary["details"] + assert isinstance(details, dict) + self.assertEqual( + details["counts"], + {"host_inactive_remap": WINDOW_DIAGNOSTIC_EVENT_LIMIT + 8}, + ) + self.assertEqual(details["sampled_events"], WINDOW_DIAGNOSTIC_EVENT_LIMIT) + self.assertEqual(details["suppressed_events"], 8) + + def test_transition_records_only_state_changes(self) -> None: + output = io.StringIO() + with contextlib.redirect_stderr(output): + recorder = WindowDiagnosticRecorder( + "game_window", + environment={WINDOW_DIAGNOSTICS_ENV: "true"}, + monotonic=lambda: 1.0, + ) + recorder.transition("focus", (False, False), "focus_state_changed") + recorder.transition("focus", (False, False), "focus_state_changed") + recorder.transition("focus", (True, True), "focus_state_changed") + recorder.close() + + payloads = [ + json.loads(line.removeprefix(WINDOW_DIAGNOSTIC_PREFIX)) + for line in output.getvalue().splitlines() + ] + self.assertEqual( + [payload["diagnostic"] for payload in payloads], + ["focus_state_changed", "focus_state_changed", "session_summary"], + ) + self.assertEqual(payloads[-1]["details"]["counts"], {"focus_state_changed": 2}) + + def test_parser_rejects_untrusted_or_malformed_records(self) -> None: + self.assertIsNone(parse_window_diagnostic("ordinary game output\n")) + self.assertIsNone( + parse_window_diagnostic( + WINDOW_DIAGNOSTIC_PREFIX + + '{"schema":1,"component":"other","diagnostic":"state",' + + '"sequence":1,"elapsed_ms":0,"details":{}}\n' + ) + ) + self.assertIsNone( + parse_window_diagnostic( + WINDOW_DIAGNOSTIC_PREFIX + "{not-json}\n" + ) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_window_focus_regressions.py b/tests/test_window_focus_regressions.py new file mode 100644 index 0000000..1ee3640 --- /dev/null +++ b/tests/test_window_focus_regressions.py @@ -0,0 +1,514 @@ +"""Focus ownership and supervisor failure-path regressions (no live desktop).""" + +from contextlib import ExitStack +import ctypes +from pathlib import Path +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from ptlinux.presentation import game_window as gw + + +class Clock: + def __init__(self) -> None: + self.now = 0.0 + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +class WindowFocusRegressions(unittest.TestCase): + def setUp(self) -> None: + self.context = self.enterContext(ExitStack()) + self.clock = Clock() + self.context.enter_context(patch.object(gw.time, "sleep", self.clock.sleep)) + self.context.enter_context(patch.object(gw.time, "monotonic", lambda: self.clock.now)) + self.mapper = MagicMock(spec=gw._X11) + self.mapper.window_state.return_value = 1 + self.mapper.window_is_active.return_value = True + self.mapper.window_is_focused.return_value = False + self.mapper.activate_window.return_value = True + + def assert_no_input_change(self) -> None: + self.mapper.focus_window.assert_not_called() + self.mapper.restore_input_focus.assert_not_called() + + def test_denied_desktop_activation_is_not_overridden_with_raw_focus(self) -> None: + self.mapper.window_is_active.return_value = False + gw._focus_game_window(self.mapper, 12) + self.assert_no_input_change() + self.mapper.activate_window.assert_called_once_with(12) + self.assertLessEqual(self.clock.now, 2.0) + + def test_slow_desktop_activation_is_confirmed_before_input_repair(self) -> None: + self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 0.6 + self.mapper.window_is_focused.side_effect = [False, True] + gw._focus_game_window(self.mapper, 12) + self.assertGreaterEqual(self.clock.now, 0.6) + self.assertLessEqual(self.clock.now, 2.0) + self.mapper.restore_input_focus.assert_called_once_with(12) + self.mapper.focus_window.assert_not_called() + + def test_already_focused_child_is_not_replaced_by_startup_handoff(self) -> None: + self.mapper.window_is_focused.return_value = True + gw._focus_game_window(self.mapper, 12) + self.assert_no_input_change() + self.mapper.activate_window.assert_not_called() + + def test_alt_tab_during_focus_inspection_is_not_undone(self) -> None: + self.mapper.window_is_active.side_effect = [True, False] + gw._repair_game_window_focus(self.mapper, 12) + self.assert_no_input_change() + + def test_unmapped_or_unknown_window_is_never_focused(self) -> None: + for state in (None, 0, 2, 3): + for repair in (gw._repair_game_window_focus, gw._repair_gamescope_game_window_focus): + with self.subTest(state=state, repair=repair.__name__): + self.mapper.reset_mock() + self.mapper.window_state.return_value = state + with patch.object(gw, "_gamescope_host_focus_is_allowed", return_value=True): + self.assertFalse(repair(self.mapper, 12)) + self.assert_no_input_change() + + def test_nested_handoff_rechecks_the_host_after_the_dashboard_delay(self) -> None: + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}): + gw._focus_game_window(self.mapper, 12, activate=False) + self.assert_no_input_change() + self.mapper.map_window.assert_not_called() + + def test_nested_startup_maps_the_ge_client_before_repairing_input(self) -> None: + self.mapper.window_state.return_value = None + self.mapper.window_size.return_value = (1920, 1080) + mapped_at = None + + def map_window(_window, *, borderless): + nonlocal mapped_at + self.assertFalse(borderless) + mapped_at = self.clock.now + + self.mapper.map_window.side_effect = map_window + self.mapper.window_state.side_effect = lambda _window: ( + 1 if mapped_at is not None and self.clock.now - mapped_at >= 0.1 else None + ) + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}): + gw._prepare_gamescope_startup_window(self.mapper, 12, set()) + gw._repair_gamescope_game_window_focus(self.mapper, 12) + self.mapper.map_window.assert_called_once_with(12, borderless=False) + self.mapper.restore_input_focus.assert_called_once_with(12) + self.mapper.activate_window.assert_not_called() + self.mapper.force_windowed_state.assert_not_called() + self.mapper.refresh_render_surface.assert_not_called() + + def test_nested_startup_does_not_remap_existing_or_destroyed_windows(self) -> None: + for state, size in ((1, (1920, 1080)), (3, (1920, 1080)), (0, (1920, 1080)), (None, None)): + with self.subTest(state=state, size=size): + self.mapper.reset_mock() + self.mapper.window_state.return_value = state + self.mapper.window_size.return_value = size + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}): + gw._prepare_gamescope_startup_window(self.mapper, 12, set()) + self.mapper.map_window.assert_not_called() + self.mapper.activate_window.assert_not_called() + + def test_nested_startup_does_not_map_after_host_focus_changes(self) -> None: + self.mapper.window_state.return_value = None + self.mapper.window_size.return_value = (1920, 1080) + with patch.object(gw, "_gamescope_host_focus_status", side_effect=[ + {"allowed": True}, {"allowed": False}, {"allowed": False}, + ]): + gw._prepare_gamescope_startup_window(self.mapper, 12, set()) + self.mapper.map_window.assert_not_called() + self.assert_no_input_change() + + def test_nested_startup_mapping_wait_is_bounded_and_does_not_force_focus(self) -> None: + self.mapper.window_state.return_value = None + self.mapper.window_size.return_value = (1920, 1080) + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}): + gw._prepare_gamescope_startup_window(self.mapper, 12, set()) + self.mapper.map_window.assert_called_once_with(12, borderless=False) + self.assertLessEqual(self.clock.now, 1.0) + self.assert_no_input_change() + + def test_nested_repair_rechecks_host_focus_before_setting_input(self) -> None: + with patch.object(gw, "_gamescope_host_focus_status", side_effect=[ + {"allowed": True}, {"allowed": False}, + ]): + gw._repair_gamescope_game_window_focus(self.mapper, 12) + self.assert_no_input_change() + + def test_nested_mapping_is_deferred_until_host_focus_returns(self) -> None: + self.mapper.window_state.return_value = None + self.mapper.window_size.return_value = (1920, 1080) + self.mapper.find_first_window.return_value = None + mapped_at = [] + + def map_window(_window, *, borderless): + mapped_at.append(self.clock.now) + self.mapper.window_state.return_value = 1 + + self.mapper.map_window.side_effect = map_window + process = MagicMock() + process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None + process.wait.return_value = 0 + with patch.object(gw, "_gamescope_host_focus_status", side_effect=lambda **_kw: { + "allowed": self.clock.now >= 1.0, + }): + gw._focus_game_window(self.mapper, 12, activate=False) + gw._maintain_gamescope_game_window_focus(self.mapper, process, 12) + self.assertEqual(len(mapped_at), 1) + self.assertGreaterEqual(mapped_at[0], 1.0) + self.mapper.activate_window.assert_not_called() + self.mapper.restore_input_focus.assert_called_with(12) + + def test_nested_replacement_login_window_gets_its_own_startup_mapping(self) -> None: + mapped = set() + self.mapper.window_state.side_effect = lambda window: ( + 1 if window in mapped or (window == 12 and self.clock.now < 0.5) else None + ) + self.mapper.window_size.side_effect = lambda window: ( + (1920, 1080) if window == 21 else None + ) + self.mapper.find_first_window.return_value = (21, "PristonTale EU Login") + self.mapper.map_window.side_effect = lambda window, **_kw: mapped.add(window) + process = MagicMock() + process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None + process.wait.return_value = 0 + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}): + gw._maintain_gamescope_game_window_focus(self.mapper, process, 12) + self.mapper.map_window.assert_called_once_with(21, borderless=False) + self.mapper.restore_input_focus.assert_called_with(21) + + def test_nested_monitor_does_not_keep_remapping_an_unacknowledged_client(self) -> None: + self.mapper.window_state.return_value = None + self.mapper.window_size.return_value = (1920, 1080) + self.mapper.find_first_window.return_value = None + process = MagicMock() + process.poll.side_effect = lambda: 0 if self.clock.now >= 3 else None + process.wait.return_value = 0 + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}): + gw._maintain_gamescope_game_window_focus(self.mapper, process, 12) + self.mapper.map_window.assert_called_once_with(12, borderless=False) + self.assert_no_input_change() + + def test_nested_mapping_stays_deferred_while_the_host_is_minimized(self) -> None: + self.mapper.window_state.return_value = None + self.mapper.window_size.return_value = (1920, 1080) + self.mapper.find_first_window.return_value = None + process = MagicMock() + process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None + process.wait.return_value = 0 + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": False}): + gw._maintain_gamescope_game_window_focus(self.mapper, process, 12) + self.mapper.map_window.assert_not_called() + self.assert_no_input_change() + + def test_nested_monitor_does_not_resurrect_a_previously_managed_client(self) -> None: + self.mapper.window_state.side_effect = lambda _window: 1 if self.clock.now < 0.5 else None + self.mapper.window_size.return_value = (1920, 1080) + self.mapper.find_first_window.return_value = None + self.mapper.window_is_focused.return_value = True + process = MagicMock() + process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None + process.wait.return_value = 0 + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}): + gw._maintain_gamescope_game_window_focus(self.mapper, process, 12) + self.mapper.map_window.assert_not_called() + self.assert_no_input_change() + + def test_nested_mapping_error_does_not_end_the_game_supervisor(self) -> None: + self.mapper.window_state.return_value = None + self.mapper.window_size.return_value = (1920, 1080) + self.mapper.find_first_window.return_value = None + self.mapper.map_window.side_effect = OSError("window disappeared") + process = MagicMock() + process.poll.side_effect = lambda: 0 if self.clock.now >= 2 else None + process.wait.return_value = 0 + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}): + self.assertEqual(gw._maintain_gamescope_game_window_focus(self.mapper, process, 12), 0) + self.mapper.map_window.assert_called_once_with(12, borderless=False) + self.assert_no_input_change() + + def test_nested_startup_rechecks_minimize_before_mapping(self) -> None: + self.mapper.window_state.side_effect = [None, 3] + self.mapper.window_size.return_value = (1920, 1080) + prepared_windows = set() + with patch.object(gw, "_gamescope_host_focus_status", return_value={"allowed": True}): + gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows) + # Losing WM_STATE after minimization must not trigger startup mapping. + self.mapper.window_state.side_effect = None + self.mapper.window_state.return_value = None + gw._prepare_gamescope_startup_window(self.mapper, 12, prepared_windows) + self.mapper.map_window.assert_not_called() + self.assert_no_input_change() + + def test_activation_errors_do_not_fall_back_to_forced_focus(self) -> None: + self.mapper.window_is_active.return_value = False + self.mapper.activate_window.side_effect = RuntimeError("WM disconnected") + gw._focus_game_window(self.mapper, 12) + self.assert_no_input_change() + + def test_initial_preparation_respects_minimize_after_discovery(self) -> None: + self.mapper.root_window_ids.return_value = set() + self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96") + self.mapper.window_state.return_value = 3 + process = MagicMock() + process.poll.return_value = None + with ( + patch.object(gw, "_X11", return_value=self.mapper), + patch.object(gw.subprocess, "Popen", return_value=process), + patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0), + patch.object(gw, "_maintain_game_window_focus", return_value=17), + ): + self.assertEqual(gw.run_game_window(["unused"], borderless=True), 17) + self.mapper.map_window.assert_not_called() + self.mapper.force_windowed_state.assert_not_called() + self.mapper.refresh_render_surface.assert_not_called() + self.mapper.activate_window.assert_not_called() + self.assert_no_input_change() + + def test_preparation_never_raises_a_client_before_desktop_activation(self) -> None: + gw._prepare_game_window(self.mapper, 12, + borderless=False, suppress_wm_ping=False, raise_window=False) + self.mapper.map_window.assert_called_once_with(12, borderless=False, raise_window=False) + self.assert_no_input_change() + + def test_layout_recovery_does_not_remap_or_raise_the_x11_client(self) -> None: + mapper: gw._X11 = object.__new__(gw._X11) + mapper.library = MagicMock() + mapper.display = ctypes.c_void_p(1) + with ( + patch.object(mapper, "window_size", return_value=(1280, 720)), + patch.object(mapper, "_remove_decorations"), + ): + self.assertEqual(mapper.map_window(12, borderless=True, raise_window=False), (1280, 720)) + mapper.library.XMapRaised.assert_not_called() + mapper.library.XMapWindow.assert_not_called() + mapper.library.XSetInputFocus.assert_not_called() + + def test_bad_bridge_timestamps_do_not_crash_the_supervisor(self) -> None: + with tempfile.TemporaryDirectory() as directory: + state = Path(directory) / "focus" + environment = {gw.GAMESCOPE_HOST_FOCUS_STATE_ENV: str(state)} + for timestamp in ("nan", "inf", "-inf", "1e999", "1e308", "-1e308"): + with self.subTest(timestamp=timestamp): + state.write_text(f"1 {timestamp}\n", encoding="ascii") + self.assertFalse(gw._gamescope_host_focus_is_allowed(environment=environment, now=10)) + + def test_startup_recovery_does_not_activate_a_background_window(self) -> None: + self.mapper.window_is_active.return_value = False + self.mapper.window_is_fullscreen_or_maximized.return_value = True + process = MagicMock() + process.poll.side_effect = [None, 0] + with patch.object(gw, "_prepare_game_window") as prepare: + gw._guard_game_window_startup(self.mapper, process, 12, borderless=False) + prepare.assert_not_called() + self.mapper.activate_window.assert_not_called() + self.assert_no_input_change() + + def test_startup_recovery_does_not_undo_alt_tab_during_surface_refresh(self) -> None: + self.mapper.window_is_fullscreen_or_maximized.return_value = True + process = MagicMock() + process.poll.side_effect = [None, 0] + + def switch_away(*_args, **_kwargs) -> None: + self.mapper.window_is_active.return_value = False + + with patch.object(gw, "_prepare_game_window", side_effect=switch_away) as prepare: + gw._guard_game_window_startup(self.mapper, process, 12, borderless=False) + self.assertIs(prepare.call_args.kwargs["raise_window"], False) + self.mapper.activate_window.assert_not_called() + self.assert_no_input_change() + + def test_scaling_failures_keep_the_focus_supervisor_running(self) -> None: + for failure in ("subscribe", "configure", "poll", "repair"): + with self.subTest(failure=failure): + mapper = MagicMock(spec=gw._X11) + mapper.enable_window_scaling.return_value = failure != "configure" + if failure == "subscribe": + mapper.select_scaling_hint_events.side_effect = RuntimeError("subscription failed") + elif failure == "poll": + mapper.wait_for_scaling_hint_change.side_effect = RuntimeError("poll failed") + elif failure == "repair": + mapper.enable_window_scaling.side_effect = [True] + [RuntimeError("hints failed")] * 3 + process = MagicMock() + process.poll.return_value = None + with patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain: + self.assertEqual(gw._maintain_game_window_scaling(mapper, process, 12), 17) + maintain.assert_called_once_with( + mapper, process, 12, window_title=gw.WINDOW_TITLE, + existing_window_ids=(), expected_pid=None, + ) + process.wait.assert_not_called() + + def test_delayed_desktop_activation_is_repaired_during_startup_guard(self) -> None: + self.mapper.window_is_fullscreen_or_maximized.return_value = False + self.mapper.window_is_active.side_effect = lambda _window: self.clock.now >= 1.5 + process = MagicMock() + process.poll.side_effect = lambda: None if self.clock.now < 4 else 0 + repair_times: list[float] = [] + + def repair(_window: int) -> None: + repair_times.append(self.clock.now) + self.mapper.window_is_focused.return_value = True + + self.mapper.restore_input_focus.side_effect = repair + gw._guard_game_window_startup(self.mapper, process, 12, borderless=False) + self.assertEqual(len(repair_times), 1) + self.assertGreaterEqual(repair_times[0], 1.5) + self.assertLess(repair_times[0], 4) + self.mapper.activate_window.assert_not_called() + self.mapper.map_window.assert_not_called() + self.mapper.refresh_render_surface.assert_not_called() + + def test_startup_guard_hands_a_vanished_window_to_the_monitor_immediately(self) -> None: + process = MagicMock() + process.poll.return_value = None + for state in (None, 0): + with self.subTest(state=state): + self.mapper.window_state.return_value = state + self.assertFalse(gw._guard_game_window_startup( + self.mapper, process, 12, borderless=False, + )) + self.assertEqual(self.clock.now, 0.0) + self.assert_no_input_change() + + def test_direct_monitor_follows_the_same_process_without_resizing_or_activating(self) -> None: + self.mapper.window_pid.return_value = 42 + self.mapper.window_state.side_effect = lambda window: 0 if window == 12 else 1 + self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96") + self.mapper.restore_input_focus.side_effect = lambda _window: setattr( + self.mapper.window_is_focused, "return_value", True, + ) + process = MagicMock() + process.poll.side_effect = lambda: None if self.clock.now < 3 else 17 + process.wait.return_value = 17 + self.assertEqual(gw._maintain_game_window_focus( + self.mapper, process, 12, expected_pid=42, existing_window_ids={8, 9}, + ), 17) + self.mapper.find_first_window.assert_called_once_with( + gw.WINDOW_TITLE, excluded_window_ids={8, 9, 12}, expected_pid=42, + ) + self.mapper.restore_input_focus.assert_called_once_with(22) + self.mapper.activate_window.assert_not_called() + self.mapper.map_window.assert_not_called() + self.mapper.refresh_render_surface.assert_not_called() + + def test_a_replaced_background_window_does_not_take_keyboard_focus(self) -> None: + self.mapper.window_pid.return_value = 42 + self.mapper.window_state.side_effect = lambda window: None if window == 12 else 1 + self.mapper.find_first_window.return_value = (22, "PristonTale EU V23.86.96") + self.mapper.window_is_active.return_value = False + process = MagicMock() + process.poll.side_effect = lambda: None if self.clock.now < 2 else 0 + process.wait.return_value = 0 + gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42) + self.assert_no_input_change() + self.mapper.activate_window.assert_not_called() + + def test_minimized_direct_client_is_not_replaced_or_restored(self) -> None: + self.mapper.window_pid.return_value = 42 + self.mapper.window_state.return_value = 3 + process = MagicMock() + process.poll.side_effect = lambda: None if self.clock.now < 2 else 0 + process.wait.return_value = 0 + gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42) + self.mapper.find_first_window.assert_not_called() + self.mapper.map_window.assert_not_called() + self.mapper.restore_normal_window_state.assert_not_called() + self.assert_no_input_change() + + def test_missing_process_identity_does_not_attach_to_another_account(self) -> None: + self.mapper.window_pid.return_value = None + self.mapper.window_state.return_value = None + process = MagicMock() + process.poll.side_effect = lambda: None if self.clock.now < 2 else 0 + process.wait.return_value = 0 + gw._maintain_game_window_focus(self.mapper, process, 12) + self.mapper.find_first_window.assert_not_called() + self.assert_no_input_change() + + def test_reused_window_id_from_another_process_is_not_focused(self) -> None: + self.mapper.window_pid.return_value = 99 + self.mapper.find_first_window.return_value = None + process = MagicMock() + process.poll.side_effect = lambda: None if self.clock.now < 2 else 0 + process.wait.return_value = 0 + gw._maintain_game_window_focus(self.mapper, process, 12, expected_pid=42) + self.assert_no_input_change() + for call in self.mapper.find_first_window.call_args_list: + self.assertEqual(call.kwargs["expected_pid"], 42) + self.assertIn(12, call.kwargs["excluded_window_ids"]) + + def test_discovery_keeps_the_original_pid_if_the_window_disappears_during_startup(self) -> None: + self.mapper.root_window_ids.return_value = {8, 9} + self.mapper.find_first_window.return_value = (12, "PristonTale EU V23.86.96") + self.mapper.window_pid.return_value = 42 + process = MagicMock() + process.poll.return_value = None + + def disappear(*_args, **_kwargs) -> bool: + self.mapper.window_pid.return_value = None + return False + + with ( + patch.object(gw, "_X11", return_value=self.mapper), + patch.object(gw.subprocess, "Popen", return_value=process), + patch.object(gw, "WINDOW_READY_DELAY_SECONDS", 0), + patch.object(gw, "_prepare_game_window"), + patch.object(gw, "_focus_game_window"), + patch.object(gw, "_guard_game_window_startup", side_effect=disappear), + patch.object(gw, "_maintain_game_window_focus", return_value=17) as maintain, + ): + self.assertEqual(gw.run_game_window(["unused"]), 17) + maintain.assert_called_once_with( + self.mapper, process, 12, window_title=gw.WINDOW_TITLE, + existing_window_ids={8, 9}, expected_pid=42, + ) + + +class ClientWindowDiscoveryTests(unittest.TestCase): + def test_replacement_discovery_rejects_other_processes_with_identical_titles(self) -> None: + mapper: gw._X11 = object.__new__(gw._X11) + with ( + patch.object(mapper, "_root_windows", return_value=[50, 60, 70]), + patch.object(mapper, "_client_window", side_effect={50: 12, 60: 13, 70: 14}.__getitem__), + patch.object(mapper, "window_pid", side_effect={12: 99, 13: None, 14: 42}.__getitem__), + patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"), + ): + self.assertEqual(mapper.find_first_window( + gw.WINDOW_TITLE, excluded_window_ids=set(), expected_pid=42, + ), (14, "PristonTale EU V23.86.96")) + + def test_title_is_read_from_the_client_inside_an_untitled_wm_frame(self) -> None: + mapper: gw._X11 = object.__new__(gw._X11) + with ( + patch.object(mapper, "_root_windows", return_value=[50]), + patch.object(mapper, "_client_window", return_value=12), + patch.object(mapper, "_window_title", side_effect=lambda window: + "PristonTale EU V23.86.96" if window == 12 else None), + ): + self.assertEqual(mapper.find_first_window("PristonTale EU", excluded_window_ids=set()), + (12, "PristonTale EU V23.86.96")) + + def test_an_existing_client_is_excluded_even_if_its_frame_changes(self) -> None: + mapper: gw._X11 = object.__new__(gw._X11) + frames = {50: 12, 60: 12, 70: 13} + with ( + patch.object(mapper, "_root_windows", side_effect=[[50], [60, 70]]), + patch.object(mapper, "_client_window", side_effect=frames.__getitem__), + patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"), + ): + existing = mapper.root_window_ids() + found = mapper.find_first_window("PristonTale EU", excluded_window_ids=existing) + self.assertIn(12, existing) + self.assertEqual(found, (13, "PristonTale EU V23.86.96")) + + def test_a_retired_client_is_excluded_even_when_a_frame_supplies_its_title(self) -> None: + mapper: gw._X11 = object.__new__(gw._X11) + with ( + patch.object(mapper, "_root_windows", return_value=[50]), + patch.object(mapper, "_client_window", return_value=12), + patch.object(mapper, "_window_title", return_value="PristonTale EU V23.86.96"), + ): + self.assertIsNone(mapper.find_first_window("PristonTale EU", excluded_window_ids={12})) diff --git a/tools/build-aio-release.sh b/tools/build-aio-release.sh new file mode 100755 index 0000000..847bbf2 --- /dev/null +++ b/tools/build-aio-release.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PROJECT_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +PACKAGE_SOURCE_ROOT="${PROJECT_ROOT}/src" +GAMESCOPE_PROVENANCE_ROOT="${PROJECT_ROOT}/native/gamescope" +SOURCE_VERSION="$(sed -n 's/^__version__ = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"$/\1/p' "${PACKAGE_SOURCE_ROOT}/ptlinux/__init__.py")" +VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}" +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +then + printf 'Invalid release version: %s (expected X.Y.Z)\n' "$VERSION" >&2 + exit 2 +fi +if [[ "$VERSION" != "$SOURCE_VERSION" ]] +then + printf 'Release version %s does not match source version %s.\n' "$VERSION" "$SOURCE_VERSION" >&2 + exit 2 +fi +RELEASE_MODE="${PTLINUX_RELEASE_MODE:-development}" +if [[ "$RELEASE_MODE" != "development" && "$RELEASE_MODE" != "production" ]] +then + printf 'Invalid release mode: %s (expected development or production)\n' "$RELEASE_MODE" >&2 + exit 2 +fi +VERSION_SLUG="${VERSION//./-}" +ARCHIVE_NAME="pristontale-eu-linux-installer-v${VERSION_SLUG}" +OUTPUT_DIRECTORY="${PTLINUX_OUTPUT_DIRECTORY:-${PROJECT_ROOT}/.artifacts/releases/aio}" +PYTHON_BIN="${PTLINUX_PYTHON:-python3}" +if [[ "$RELEASE_MODE" == "production" && -z "${PTLINUX_SIGNING_KEY:-}" ]] +then + printf '%s\n' "PTLINUX_SIGNING_KEY is required for a production release." >&2 + exit 2 +fi +if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]] +then + PTLINUX_SIGNING_KEY="$( + PYTHONPATH="${PROJECT_ROOT}/src:${PROJECT_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" \ + "$PYTHON_BIN" -c \ + 'import sys +from pathlib import Path +from scripts.release_lib import ReleaseToolError, _safe_signing_key_path +try: + print(_safe_signing_key_path(Path(sys.argv[1]), output_directory=Path(sys.argv[2]))) +except ReleaseToolError as exc: + print(f"Invalid release signing key: {exc}", file=sys.stderr) + raise SystemExit(2)' \ + "$PTLINUX_SIGNING_KEY" "$OUTPUT_DIRECTORY" + )" + export PTLINUX_SIGNING_KEY +fi +STAGING_DIRECTORY="$(mktemp -d /tmp/ptlinux-release.XXXXXX)" +RELEASE_ROOT="${STAGING_DIRECTORY}/${ARCHIVE_NAME}" + +cleanup() +{ + rm -r -- "$STAGING_DIRECTORY" +} + +trap cleanup EXIT + +install -d -m 0755 "$RELEASE_ROOT" +install -d -m 0755 \ + "$RELEASE_ROOT/ptlinux" \ + "$RELEASE_ROOT/ptlinux/native" \ + "$RELEASE_ROOT/ptdiag" \ + "$RELEASE_ROOT/packaging" \ + "$RELEASE_ROOT/release" \ + "$RELEASE_ROOT/scripts" +shopt -s globstar nullglob +for SOURCE_MODULE in "${PACKAGE_SOURCE_ROOT}/ptlinux/"**/*.py +do + RELATIVE_MODULE="${SOURCE_MODULE#"${PACKAGE_SOURCE_ROOT}/ptlinux/"}" + install -D -m 0644 \ + "$SOURCE_MODULE" \ + "${RELEASE_ROOT}/ptlinux/${RELATIVE_MODULE}" +done +shopt -u globstar nullglob +install -m 0644 \ + "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ + "$RELEASE_ROOT/ptlinux/launcher-update-public.pem" +install -m 0644 \ + "${PACKAGE_SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \ + "$RELEASE_ROOT/ptlinux/native/pt-identity-probe.exe" +install -m 0644 \ + "${PACKAGE_SOURCE_ROOT}/ptlinux/native/ptr-bcrypt-proxy.dll" \ + "$RELEASE_ROOT/ptlinux/native/ptr-bcrypt-proxy.dll" +install -m 0755 \ + "${PACKAGE_SOURCE_ROOT}/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" \ + "$RELEASE_ROOT/ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" +install -m 0644 \ + "${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.README" \ + "${GAMESCOPE_PROVENANCE_ROOT}/gamescope-compat.copyright" \ + "${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-lazy-output-images.patch" \ + "${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-wayland-live-resize.patch" \ + "${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-wayland-letterbox-backing.patch" \ + "${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-wayland-libdecor-input-scope.patch" \ + "${GAMESCOPE_PROVENANCE_ROOT}/patches/gamescope-keep-nested-windows-visible.patch" \ + "$RELEASE_ROOT/ptlinux/native/" +install -m 0644 "${PACKAGE_SOURCE_ROOT}/ptdiag/__init__.py" "$RELEASE_ROOT/ptdiag/__init__.py" +install -m 0644 "${PACKAGE_SOURCE_ROOT}/ptdiag/assbin.py" "$RELEASE_ROOT/ptdiag/assbin.py" +install -m 0644 "${PACKAGE_SOURCE_ROOT}/ptdiag/logs.py" "$RELEASE_ROOT/ptdiag/logs.py" +install -m 0644 \ + "${PROJECT_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ + "${PROJECT_ROOT}/packaging/eu.pristontale.PTLinux.png" \ + "${PROJECT_ROOT}/packaging/install-dependencies.sh" \ + "$RELEASE_ROOT/packaging/" +install -m 0755 "${PROJECT_ROOT}/packaging/pristontale-eu" "$RELEASE_ROOT/packaging/pristontale-eu" +install -m 0644 \ + "${PROJECT_ROOT}/release/config.json" \ + "$RELEASE_ROOT/release/config.json" +install -m 0644 \ + "${PROJECT_ROOT}/scripts/__init__.py" \ + "${PROJECT_ROOT}/scripts/repository_paths.py" \ + "${PROJECT_ROOT}/scripts/release_lib.py" \ + "$RELEASE_ROOT/scripts/" +install -m 0755 "${PROJECT_ROOT}/install.sh" "$RELEASE_ROOT/install.sh" +install -m 0644 "${PROJECT_ROOT}/RELEASE_README.md" "$RELEASE_ROOT/README.md" + +install -d "$OUTPUT_DIRECTORY" +tar \ + --sort=name \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + --mtime="@${SOURCE_DATE_EPOCH:-0}" \ + -C "$STAGING_DIRECTORY" \ + -czf "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz" \ + "$ARCHIVE_NAME" +chmod 0644 "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz" +( + cd "$OUTPUT_DIRECTORY" + sha256sum "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256" +) +chmod 0644 "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz.sha256" + +if ! command -v zip >/dev/null 2>&1 +then + printf 'Missing release dependency: zip\n' >&2 + exit 1 +fi +ZIP_TIMESTAMP_EPOCH="${SOURCE_DATE_EPOCH:-315532800}" +if (( ZIP_TIMESTAMP_EPOCH < 315532800 )) +then + # ZIP timestamps cannot represent dates before 1980-01-01. + ZIP_TIMESTAMP_EPOCH=315532800 +fi +find "$RELEASE_ROOT" -exec touch -h -d "@${ZIP_TIMESTAMP_EPOCH}" {} + +( + cd "$STAGING_DIRECTORY" + LC_ALL=C find "$ARCHIVE_NAME" -print \ + | LC_ALL=C sort \ + | zip -X -q "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.zip" -@ +) +chmod 0644 "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.zip" +( + cd "$OUTPUT_DIRECTORY" + sha256sum "${ARCHIVE_NAME}.zip" > "${ARCHIVE_NAME}.zip.sha256" +) +chmod 0644 "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.zip.sha256" + +PUBLIC_KEY_ARTIFACT="${OUTPUT_DIRECTORY}/launcher-update-public.pem" +install -m 0644 \ + "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ + "$PUBLIC_KEY_ARTIFACT" +for installer_archive in \ + "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz" \ + "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.zip" +do + if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]] + then + "$PYTHON_BIN" "${PROJECT_ROOT}/tools/sign_release_artifact.py" \ + --signing-key "$PTLINUX_SIGNING_KEY" \ + --public-key "${PACKAGE_SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ + "$installer_archive" + else + rm -f -- "${installer_archive}.sig" + fi +done + +UPDATE_COMMAND=( + "$PYTHON_BIN" + "${PROJECT_ROOT}/tools/build_launcher_update.py" + --version "$VERSION" + --output-directory "$OUTPUT_DIRECTORY" + --repository-url "${PTLINUX_REPOSITORY_URL:-https://git.m4ko.net/m4ko/pristontale-eu-linux}" +) +if [[ -n "${SOURCE_DATE_EPOCH:-}" ]] +then + UPDATE_COMMAND+=(--source-date-epoch "$SOURCE_DATE_EPOCH") +fi +if [[ -n "${PTLINUX_SIGNING_KEY:-}" ]] +then + UPDATE_COMMAND+=(--signing-key "$PTLINUX_SIGNING_KEY") +fi +if [[ "$RELEASE_MODE" == "production" ]] +then + UPDATE_COMMAND+=(--require-signature) +fi +"${UPDATE_COMMAND[@]}" + +printf '%s\n' "${OUTPUT_DIRECTORY}/${ARCHIVE_NAME}.tar.gz" diff --git a/tools/build_launcher_update.py b/tools/build_launcher_update.py new file mode 100755 index 0000000..7e0a25a --- /dev/null +++ b/tools/build_launcher_update.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Compatibility entry point for the deterministic native-launcher update.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import sys +import time +from typing import Sequence + + +# This file remains at its historical path for release scripts and imports. The +# implementation belongs to the canonical release library under scripts/. +_BOOTSTRAP_ROOT = Path(__file__).resolve().parents[1] +if str(_BOOTSTRAP_ROOT) not in sys.path: + sys.path.insert(0, str(_BOOTSTRAP_ROOT)) + +from scripts.release_lib import ( # noqa: E402 + ReleaseToolError, + build_legacy_launcher_update, +) +from scripts.repository_paths import PROJECT_ROOT # noqa: E402 + + +DEFAULT_REPOSITORY_URL = "https://git.m4ko.net/m4ko/pristontale-eu-linux" +DEFAULT_MINIMUM_UPDATER_VERSION = "0.5.0" +ReleaseBuildError = ReleaseToolError + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--output-directory", type=Path, required=True) + parser.add_argument( + "--repository-url", + default=DEFAULT_REPOSITORY_URL, + help="public Gitea repository base URL", + ) + parser.add_argument( + "--minimum-updater-version", + default=DEFAULT_MINIMUM_UPDATER_VERSION, + ) + parser.add_argument("--signing-key", type=Path) + parser.add_argument( + "--require-signature", + action="store_true", + help="fail unless a signing key is provided and matches the bundled public key", + ) + parser.add_argument( + "--source-date-epoch", + type=int, + default=int(os.environ.get("SOURCE_DATE_EPOCH", str(int(time.time())))), + ) + return parser + + +def build_launcher_update( + *, + version: str, + output_directory: Path, + repository_url: str, + minimum_updater_version: str, + signing_key: Path | None, + require_signature: bool = False, + source_date_epoch: int, +) -> tuple[Path, ...]: + """Forward the historical Python API to the canonical release library.""" + + return build_legacy_launcher_update( + version=version, + output_directory=output_directory, + repository_url=repository_url, + minimum_updater_version=minimum_updater_version, + signing_key=signing_key, + require_signature=require_signature, + source_date_epoch=source_date_epoch, + ) + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = build_parser().parse_args(argv) + try: + artifacts = build_launcher_update( + version=arguments.version, + output_directory=arguments.output_directory, + repository_url=arguments.repository_url, + minimum_updater_version=arguments.minimum_updater_version, + signing_key=arguments.signing_key, + require_signature=arguments.require_signature, + source_date_epoch=arguments.source_date_epoch, + ) + except (OSError, ReleaseBuildError) as exc: + print(f"Could not build launcher update: {exc}", file=sys.stderr) + return 1 + for artifact in artifacts: + print(artifact) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/sign_release_artifact.py b/tools/sign_release_artifact.py new file mode 100644 index 0000000..b03ab14 --- /dev/null +++ b/tools/sign_release_artifact.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Create and verify an atomic OpenSSL signature for one release artifact.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import shutil +import subprocess +import sys +from typing import Sequence +from uuid import uuid4 + + +_BOOTSTRAP_ROOT = Path(__file__).resolve().parents[1] +if str(_BOOTSTRAP_ROOT) not in sys.path: + sys.path.insert(0, str(_BOOTSTRAP_ROOT)) + +from scripts.release_lib import ( # noqa: E402 + ReleaseToolError, + _safe_signing_key_path, +) + + +class ReleaseSigningError(RuntimeError): + """Raised when a release artifact cannot be signed safely.""" + + +def sign_release_artifact( + artifact: Path, + *, + signing_key: Path, + public_key: Path, +) -> Path: + """Sign an artifact and verify the result with the published public key.""" + + source = _regular_file(artifact, "release artifact") + try: + private = _safe_signing_key_path(signing_key) + except ReleaseToolError as exc: + raise ReleaseSigningError(str(exc)) from exc + public = _regular_file(public_key, "public key") + openssl = shutil.which("openssl") + if openssl is None: + raise ReleaseSigningError("OpenSSL is required to sign release artifacts") + signature = Path(f"{source}.sig") + if signature.is_dir() and not signature.is_symlink(): + raise ReleaseSigningError(f"signature path is a directory: {signature}") + signature.unlink(missing_ok=True) + temporary = signature.with_name(f".{signature.name}.{uuid4().hex}.tmp") + try: + _sign(openssl, source, temporary, private) + _verify(openssl, source, temporary, public) + temporary.chmod(0o644) + temporary.replace(signature) + except BaseException: + temporary.unlink(missing_ok=True) + raise + return signature + + +def _regular_file(path: Path, label: str) -> Path: + value = path.expanduser() + if value.is_symlink() or not value.is_file(): + raise ReleaseSigningError(f"{label} is missing or not a regular file: {value}") + return value.resolve() + + +def _sign(openssl: str, artifact: Path, signature: Path, private_key: Path) -> None: + _run_openssl( + [ + openssl, + "dgst", + "-sha256", + "-sign", + str(private_key), + "-out", + str(signature), + str(artifact), + ], + "sign the release artifact", + ) + + +def _verify(openssl: str, artifact: Path, signature: Path, public_key: Path) -> None: + _run_openssl( + [ + openssl, + "dgst", + "-sha256", + "-verify", + str(public_key), + "-signature", + str(signature), + str(artifact), + ], + "verify the release artifact signature", + ) + + +def _run_openssl(command: list[str], action: str) -> None: + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ReleaseSigningError(f"OpenSSL could not {action}: {exc}") from exc + if result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or "unknown error" + raise ReleaseSigningError(f"OpenSSL could not {action}: {detail}") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifact", type=Path) + parser.add_argument("--signing-key", type=Path, required=True) + parser.add_argument("--public-key", type=Path, required=True) + arguments = parser.parse_args(argv) + try: + signature = sign_release_artifact( + arguments.artifact, + signing_key=arguments.signing_key, + public_key=arguments.public_key, + ) + except (OSError, ReleaseSigningError) as exc: + print(f"Could not sign release artifact: {exc}", file=sys.stderr) + return 1 + print(signature) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())