Added more folders and restructured classes inside them.
This commit is contained in:
949
src/ptlinux/presentation/gamescope.py
Normal file
949
src/ptlinux/presentation/gamescope.py
Normal file
@@ -0,0 +1,949 @@
|
||||
"""Launch and supervise Gamescope's resizable host presentation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from .game_window import (
|
||||
GAMESCOPE_HOST_FOCUS_STATE_ENV,
|
||||
GameboxRuntimeError,
|
||||
_X11,
|
||||
_X_WM_STATE_ICONIC,
|
||||
_X_WM_STATE_NORMAL,
|
||||
_sample_window_diagnostic_details,
|
||||
_wm_state_name,
|
||||
)
|
||||
from ..resource_paths import native_resource_path
|
||||
from ..window_diagnostics import WindowDiagnosticRecorder
|
||||
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 10.0
|
||||
WINDOW_DISCOVERY_POLL_SECONDS = 0.05
|
||||
RESIZE_STEPS_PER_GAME_CANVAS = 20
|
||||
MIN_RESIZE_INCREMENT = 8
|
||||
MAX_RESIZE_INCREMENT = 96
|
||||
CHILD_STATUS_POLL_SECONDS = 0.05
|
||||
HOST_FOCUS_HEARTBEAT_SECONDS = 0.2
|
||||
HOST_REICONIFY_INTERVAL_SECONDS = 0.2
|
||||
GAMESCOPE_VERSION_TIMEOUT_SECONDS = 1.0
|
||||
WAYLAND_BACKING_FIX_VERSION = (3, 16, 28)
|
||||
BUNDLED_GAMESCOPE_VERSION = (3, 16, 20)
|
||||
BUNDLED_GAMESCOPE_FILENAME = "gamescope-3.16.20-ubuntu26.04-amd64"
|
||||
BUNDLED_GAMESCOPE_SHA256 = (
|
||||
"3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639"
|
||||
)
|
||||
BUNDLED_GAMESCOPE_OS_ID = "ubuntu"
|
||||
BUNDLED_GAMESCOPE_OS_VERSION = "26.04"
|
||||
KEEP_NESTED_WINDOWS_VISIBLE_ENV = "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE"
|
||||
HASH_READ_SIZE = 256 * 1024
|
||||
_GAMESCOPE_VERSION_PATTERN = re.compile(
|
||||
r"\bgamescope\s+version\s+(\d+)\.(\d+)\.(\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class GamescopeRuntimeError(RuntimeError):
|
||||
"""Raised when the Gamescope process cannot be started."""
|
||||
|
||||
|
||||
class _HostFocusPublisher:
|
||||
"""Publish the host WM's focus decision to the nested game supervisor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_descriptor: int,
|
||||
path: Path,
|
||||
diagnostics: WindowDiagnosticRecorder | None = None,
|
||||
) -> None:
|
||||
self.file_descriptor = file_descriptor
|
||||
self.path = path
|
||||
self._diagnostics = diagnostics
|
||||
self._last_active: bool | None = None
|
||||
self._last_published_at = 0.0
|
||||
self._last_reiconify_at = 0.0
|
||||
self._seen_normal_state = False
|
||||
self._minimized_latched = False
|
||||
self._updates = 0
|
||||
self._monitor_errors = 0
|
||||
self._iconic_observations = 0
|
||||
self._inactive_remaps = 0
|
||||
self._hidden_observations = 0
|
||||
self._hidden_state_clears = 0
|
||||
self._reiconify_attempts = 0
|
||||
self._reiconify_accepted = 0
|
||||
self._last_state: int | None = None
|
||||
self._last_hidden: bool | None = None
|
||||
self._last_diagnostic_state: tuple[object, ...] | None = None
|
||||
self._publish(False, force=True)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
diagnostics: WindowDiagnosticRecorder | None = None,
|
||||
) -> _HostFocusPublisher:
|
||||
runtime_directory = os.environ.get("XDG_RUNTIME_DIR")
|
||||
try:
|
||||
file_descriptor, path = tempfile.mkstemp(
|
||||
prefix="ptlinux-gamescope-focus-",
|
||||
dir=runtime_directory,
|
||||
)
|
||||
except OSError:
|
||||
file_descriptor, path = tempfile.mkstemp(
|
||||
prefix="ptlinux-gamescope-focus-",
|
||||
)
|
||||
try:
|
||||
return cls(file_descriptor, Path(path), diagnostics)
|
||||
except BaseException:
|
||||
os.close(file_descriptor)
|
||||
Path(path).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def update(self, mapper: _X11, window: int) -> None:
|
||||
now = time.monotonic()
|
||||
self._updates += 1
|
||||
try:
|
||||
state = mapper.window_state(window)
|
||||
active = state == _X_WM_STATE_NORMAL and mapper.window_is_active(window)
|
||||
except Exception as exc:
|
||||
state = None
|
||||
active = False
|
||||
self._monitor_errors += 1
|
||||
if self._diagnostics is not None:
|
||||
self._diagnostics.transition(
|
||||
"host_monitor_error",
|
||||
type(exc).__name__,
|
||||
"host_monitor_error",
|
||||
window_id=window,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
net_wm_state: dict[str, bool | None] = {}
|
||||
if self._diagnostics is not None:
|
||||
try:
|
||||
net_wm_state = mapper.window_net_wm_state_flags(window)
|
||||
except Exception as exc:
|
||||
self._monitor_errors += 1
|
||||
self._diagnostics.transition(
|
||||
"host_ewmh_monitor_error",
|
||||
type(exc).__name__,
|
||||
"host_ewmh_monitor_error",
|
||||
window_id=window,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
hidden = net_wm_state.get("hidden")
|
||||
if hidden is True:
|
||||
self._hidden_observations += 1
|
||||
if self._last_hidden is True and hidden is False and not active:
|
||||
self._hidden_state_clears += 1
|
||||
if self._diagnostics is not None:
|
||||
self._diagnostics.record(
|
||||
"host_hidden_state_cleared",
|
||||
window_id=window,
|
||||
observed_active=active,
|
||||
minimized_latched=self._minimized_latched,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
self._last_hidden = hidden
|
||||
self._last_state = state
|
||||
if state == _X_WM_STATE_ICONIC and self._seen_normal_state:
|
||||
self._iconic_observations += 1
|
||||
if not self._minimized_latched:
|
||||
self._minimized_latched = True
|
||||
if self._diagnostics is not None:
|
||||
self._diagnostics.record(
|
||||
"host_minimize_latched",
|
||||
window_id=window,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
elif state == _X_WM_STATE_NORMAL:
|
||||
self._seen_normal_state = True
|
||||
if self._minimized_latched:
|
||||
if active:
|
||||
# A normal, active window was explicitly restored by the
|
||||
# desktop. Resume nested focus repair for it.
|
||||
self._minimized_latched = False
|
||||
if self._diagnostics is not None:
|
||||
self._diagnostics.record(
|
||||
"host_restore_observed",
|
||||
window_id=window,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
elif now - self._last_reiconify_at >= HOST_REICONIFY_INTERVAL_SECONDS:
|
||||
# Gamescope's SDL visibility handoff uses XMapRaised. If it
|
||||
# remaps an inactive window after a user minimize, preserve
|
||||
# the user's state instead of letting it cover other apps.
|
||||
self._inactive_remaps += 1
|
||||
self._reiconify_attempts += 1
|
||||
error_type: str | None = None
|
||||
try:
|
||||
accepted = mapper.minimize_window(window)
|
||||
except Exception as exc:
|
||||
accepted = False
|
||||
error_type = type(exc).__name__
|
||||
if accepted:
|
||||
self._reiconify_accepted += 1
|
||||
self._last_reiconify_at = now
|
||||
active = False
|
||||
if self._diagnostics is not None:
|
||||
details: dict[str, object] = {
|
||||
"window_id": window,
|
||||
"observed_state": "normal",
|
||||
"active": False,
|
||||
"minimize_request_accepted": accepted,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
}
|
||||
if error_type is not None:
|
||||
details["error_type"] = error_type
|
||||
self._diagnostics.record(
|
||||
"host_inactive_remap",
|
||||
**details,
|
||||
)
|
||||
diagnostic_state = (
|
||||
state,
|
||||
active,
|
||||
self._minimized_latched,
|
||||
hidden,
|
||||
net_wm_state.get("above"),
|
||||
net_wm_state.get("demands_attention"),
|
||||
net_wm_state.get("maximized_horizontal"),
|
||||
net_wm_state.get("maximized_vertical"),
|
||||
)
|
||||
if (
|
||||
self._diagnostics is not None
|
||||
and diagnostic_state != self._last_diagnostic_state
|
||||
):
|
||||
self._last_diagnostic_state = diagnostic_state
|
||||
self._diagnostics.record(
|
||||
"host_state_changed",
|
||||
window_id=window,
|
||||
observed_wm_state=_wm_state_name(state),
|
||||
observed_active=active,
|
||||
minimized_latched=self._minimized_latched,
|
||||
**_sample_window_diagnostic_details(
|
||||
self._diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
self._publish(active, now=now)
|
||||
|
||||
def summary_details(self) -> dict[str, object]:
|
||||
return {
|
||||
"focus_updates": self._updates,
|
||||
"monitor_errors": self._monitor_errors,
|
||||
"iconic_observations": self._iconic_observations,
|
||||
"inactive_remaps": self._inactive_remaps,
|
||||
"hidden_observations": self._hidden_observations,
|
||||
"hidden_state_clears": self._hidden_state_clears,
|
||||
"reiconify_attempts": self._reiconify_attempts,
|
||||
"reiconify_accepted": self._reiconify_accepted,
|
||||
"seen_normal_state": self._seen_normal_state,
|
||||
"minimized_latched": self._minimized_latched,
|
||||
"last_wm_state": _wm_state_name(self._last_state),
|
||||
"last_active": self._last_active,
|
||||
}
|
||||
|
||||
def _publish(
|
||||
self,
|
||||
active: bool,
|
||||
*,
|
||||
force: bool = False,
|
||||
now: float | None = None,
|
||||
) -> None:
|
||||
published_at = time.monotonic() if now is None else now
|
||||
if (
|
||||
not force
|
||||
and active == self._last_active
|
||||
and published_at - self._last_published_at < HOST_FOCUS_HEARTBEAT_SECONDS
|
||||
):
|
||||
return
|
||||
payload = f"{int(active)} {published_at:.9f}\n".encode("ascii")
|
||||
try:
|
||||
os.lseek(self.file_descriptor, 0, os.SEEK_SET)
|
||||
# Truncating first makes an interrupted update fail closed in the
|
||||
# nested reader instead of leaving a stale active value behind.
|
||||
os.ftruncate(self.file_descriptor, 0)
|
||||
os.write(self.file_descriptor, payload)
|
||||
except OSError:
|
||||
self._last_active = None
|
||||
return
|
||||
self._last_active = active
|
||||
self._last_published_at = published_at
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
os.close(self.file_descriptor)
|
||||
finally:
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def resize_increments(width: int, height: int) -> tuple[int, int]:
|
||||
"""Return coarse-enough size steps to avoid a swapchain rebuild storm."""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("game canvas dimensions must be positive")
|
||||
return (
|
||||
min(
|
||||
MAX_RESIZE_INCREMENT,
|
||||
max(MIN_RESIZE_INCREMENT, width // RESIZE_STEPS_PER_GAME_CANVAS),
|
||||
),
|
||||
min(
|
||||
MAX_RESIZE_INCREMENT,
|
||||
max(MIN_RESIZE_INCREMENT, height // RESIZE_STEPS_PER_GAME_CANVAS),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _supervised_command(
|
||||
command: Sequence[str],
|
||||
status_file: Path,
|
||||
backend: str,
|
||||
*,
|
||||
force_composition: bool = False,
|
||||
) -> list[str]:
|
||||
"""Select a presentation backend and wrap the primary child."""
|
||||
|
||||
if not command:
|
||||
raise ValueError("Gamescope command cannot be empty")
|
||||
if backend not in {"sdl", "wayland"}:
|
||||
raise ValueError(f"unsupported Gamescope backend: {backend}")
|
||||
result = [
|
||||
str(command[0]),
|
||||
"--backend",
|
||||
backend,
|
||||
"--keep-alive",
|
||||
]
|
||||
if backend == "sdl" or force_composition:
|
||||
result.append("--force-composition")
|
||||
result.extend(map(str, command[1:]))
|
||||
try:
|
||||
separator = result.index("--")
|
||||
except ValueError as exc:
|
||||
raise ValueError("Gamescope command has no nested command separator") from exc
|
||||
if separator == len(result) - 1:
|
||||
raise ValueError("Gamescope command has no nested command")
|
||||
result[separator + 1:separator + 1] = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.gamescope_child_runtime",
|
||||
"--status-file",
|
||||
str(status_file),
|
||||
"--",
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _read_gamescope_version(executable: str) -> tuple[int, int, int] | None:
|
||||
"""Read Gamescope's release tuple without delaying a normal launch."""
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[executable, "--version"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=GAMESCOPE_VERSION_TIMEOUT_SECONDS,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if completed.returncode != 0:
|
||||
return None
|
||||
match = _GAMESCOPE_VERSION_PATTERN.search(completed.stdout)
|
||||
if match is None:
|
||||
return None
|
||||
major, minor, patch = match.groups()
|
||||
return int(major), int(minor), int(patch)
|
||||
|
||||
|
||||
def _bundled_gamescope_path() -> Path:
|
||||
return native_resource_path(BUNDLED_GAMESCOPE_FILENAME)
|
||||
|
||||
|
||||
def _bundled_gamescope_host_supported() -> bool:
|
||||
"""Limit the distribution-specific executable to its build target."""
|
||||
|
||||
if sys.platform != "linux" or platform.machine().casefold() not in {
|
||||
"amd64",
|
||||
"x86_64",
|
||||
}:
|
||||
return False
|
||||
try:
|
||||
release = platform.freedesktop_os_release()
|
||||
except OSError:
|
||||
return False
|
||||
return (
|
||||
release.get("ID", "").casefold() == BUNDLED_GAMESCOPE_OS_ID
|
||||
and release.get("VERSION_ID") == BUNDLED_GAMESCOPE_OS_VERSION
|
||||
)
|
||||
|
||||
|
||||
def _bundled_gamescope_is_usable() -> bool:
|
||||
"""Verify the launcher-local executable and restore its safe archive mode."""
|
||||
|
||||
if not _bundled_gamescope_host_supported():
|
||||
return False
|
||||
path = _bundled_gamescope_path()
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return False
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(HASH_READ_SIZE), b""):
|
||||
digest.update(chunk)
|
||||
except OSError:
|
||||
return False
|
||||
if digest.hexdigest() != BUNDLED_GAMESCOPE_SHA256:
|
||||
return False
|
||||
if not os.access(path, os.X_OK):
|
||||
try:
|
||||
path.chmod(0o755)
|
||||
except OSError:
|
||||
return False
|
||||
return os.access(path, os.X_OK)
|
||||
|
||||
|
||||
def _is_kde_session(environment: Mapping[str, str]) -> bool:
|
||||
"""Recognize Plasma sessions without relying on an Ubuntu flavor name."""
|
||||
|
||||
if environment.get("KDE_FULL_SESSION", "").strip().casefold() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
}:
|
||||
return True
|
||||
if environment.get("KDE_SESSION_VERSION", "").strip():
|
||||
return True
|
||||
for key in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"):
|
||||
tokens = re.split(
|
||||
r"[^a-z0-9]+",
|
||||
environment.get(key, "").strip().casefold(),
|
||||
)
|
||||
if "kde" in tokens or any(token.startswith("plasma") for token in tokens):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _select_wayland_gamescope(
|
||||
executable: str,
|
||||
*,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> tuple[str, bool, bool]:
|
||||
"""Choose a direct-safe Gamescope or request the stable composed path."""
|
||||
|
||||
version = _read_gamescope_version(executable)
|
||||
env = os.environ if environment is None else environment
|
||||
if _is_kde_session(env):
|
||||
# This is reached only when Plasma has no usable XWayland display. Keep
|
||||
# native Wayland fully composed because newer Gamescope builds still have
|
||||
# KWin-specific presentation failures outside our backing-surface fixes.
|
||||
return executable, True, False
|
||||
if version is not None and version >= WAYLAND_BACKING_FIX_VERSION:
|
||||
return executable, False, False
|
||||
if version == BUNDLED_GAMESCOPE_VERSION and _bundled_gamescope_is_usable():
|
||||
return str(_bundled_gamescope_path()), False, True
|
||||
return executable, True, False
|
||||
|
||||
|
||||
def _wayland_socket_available(environment: Mapping[str, str]) -> bool:
|
||||
"""Return whether the launch environment names an accessible Wayland socket."""
|
||||
|
||||
display = environment.get("WAYLAND_DISPLAY", "").strip()
|
||||
if not display:
|
||||
return False
|
||||
display_path = Path(display)
|
||||
if not display_path.is_absolute():
|
||||
runtime_directory = environment.get("XDG_RUNTIME_DIR", "").strip()
|
||||
if not runtime_directory:
|
||||
return False
|
||||
display_path = Path(runtime_directory) / display_path
|
||||
try:
|
||||
return display_path.is_socket()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _select_host_backend(environment: Mapping[str, str]) -> str:
|
||||
"""Choose the host backend with a Plasma-safe XWayland fallback."""
|
||||
|
||||
if _wayland_socket_available(environment):
|
||||
# Gamescope's native Wayland backend still has KWin-specific presentation,
|
||||
# fullscreen, and fractional-scaling failures. Plasma normally exposes an
|
||||
# XWayland display, so keep the resizable host window on the mature SDL/X11
|
||||
# path while leaving non-Plasma Wayland sessions on direct presentation.
|
||||
if _is_kde_session(environment) and environment.get("DISPLAY", "").strip():
|
||||
return "sdl"
|
||||
return "wayland"
|
||||
return "sdl"
|
||||
|
||||
|
||||
def _create_child_status_file() -> Path:
|
||||
"""Create a private rendezvous file inherited by the nested child command."""
|
||||
|
||||
runtime_directory = os.environ.get("XDG_RUNTIME_DIR")
|
||||
try:
|
||||
file_descriptor, path = tempfile.mkstemp(
|
||||
prefix="ptlinux-gamescope-status-",
|
||||
dir=runtime_directory,
|
||||
)
|
||||
except OSError:
|
||||
file_descriptor, path = tempfile.mkstemp(prefix="ptlinux-gamescope-status-")
|
||||
os.close(file_descriptor)
|
||||
return Path(path)
|
||||
|
||||
|
||||
def _read_child_status(status_file: Path) -> int | None:
|
||||
try:
|
||||
value = status_file.read_text(encoding="ascii").strip()
|
||||
except (FileNotFoundError, OSError, UnicodeError):
|
||||
return None
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
returncode = int(value, 10)
|
||||
except ValueError:
|
||||
return None
|
||||
if returncode < -255 or returncode > 255:
|
||||
return None
|
||||
return returncode
|
||||
|
||||
|
||||
def _process_exit_status(returncode: int) -> int:
|
||||
if returncode < 0:
|
||||
return 128 + min(-returncode, 127)
|
||||
return min(returncode, 255)
|
||||
|
||||
|
||||
def _wait_for_optimized_gamescope(
|
||||
process: subprocess.Popen[bytes],
|
||||
status_file: Path | None,
|
||||
*,
|
||||
host_focus_publisher: _HostFocusPublisher | None = None,
|
||||
host_mapper: _X11 | None = None,
|
||||
host_window: int | None = None,
|
||||
) -> int:
|
||||
"""Monitor host focus and preserve the nested child's real exit status."""
|
||||
|
||||
while True:
|
||||
if host_focus_publisher is not None and host_mapper is not None:
|
||||
if host_window is None:
|
||||
try:
|
||||
host_window = host_mapper.find_window_by_pid(process.pid)
|
||||
except Exception:
|
||||
host_window = None
|
||||
if host_window is not None:
|
||||
host_focus_publisher.update(host_mapper, host_window)
|
||||
child_status = (
|
||||
_read_child_status(status_file) if status_file is not None else None
|
||||
)
|
||||
if child_status is not None:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
return _process_exit_status(child_status)
|
||||
gamescope_status = process.poll()
|
||||
if gamescope_status is not None:
|
||||
child_status = (
|
||||
_read_child_status(status_file) if status_file is not None else None
|
||||
)
|
||||
return _process_exit_status(
|
||||
gamescope_status if child_status is None else child_status
|
||||
)
|
||||
time.sleep(CHILD_STATUS_POLL_SECONDS)
|
||||
|
||||
|
||||
def _open_host_x11() -> _X11 | None:
|
||||
"""Open the host X11 connection used by Gamescope's SDL window."""
|
||||
|
||||
if not os.environ.get("DISPLAY"):
|
||||
return None
|
||||
try:
|
||||
return _X11()
|
||||
except GameboxRuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def _tune_process_window(
|
||||
mapper: _X11,
|
||||
process: subprocess.Popen[bytes],
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
host_focus_publisher: _HostFocusPublisher | None = None,
|
||||
diagnostics: WindowDiagnosticRecorder | None = None,
|
||||
) -> tuple[int, int] | None:
|
||||
"""Stabilize resizing once the SDL host window becomes available."""
|
||||
|
||||
deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS
|
||||
reported_window: int | None = None
|
||||
last_configuration: tuple[bool, bool, bool] | None = None
|
||||
while process.poll() is None and time.monotonic() < deadline:
|
||||
window = mapper.find_window_by_pid(process.pid)
|
||||
if window is not None:
|
||||
if diagnostics is not None and window != reported_window:
|
||||
reported_window = window
|
||||
diagnostics.record(
|
||||
"host_window_discovered",
|
||||
window_id=window,
|
||||
**_sample_window_diagnostic_details(
|
||||
diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
if host_focus_publisher is not None:
|
||||
host_focus_publisher.update(mapper, window)
|
||||
increments = resize_increments(width, height)
|
||||
increments_applied = mapper.set_resize_increments(
|
||||
window,
|
||||
(width, height),
|
||||
increments,
|
||||
)
|
||||
# Apply every client-owned hint first, then wait for the window
|
||||
# manager to publish resize and maximize actions from those hints.
|
||||
scaling_enabled = mapper.enable_window_scaling(window)
|
||||
if host_focus_publisher is not None:
|
||||
host_focus_publisher.update(mapper, window)
|
||||
compositing_preferred = mapper.prefer_composited_presentation(window)
|
||||
configuration = (
|
||||
increments_applied,
|
||||
scaling_enabled,
|
||||
compositing_preferred,
|
||||
)
|
||||
if diagnostics is not None and configuration != last_configuration:
|
||||
last_configuration = configuration
|
||||
diagnostics.record(
|
||||
"host_scaling_configured",
|
||||
window_id=window,
|
||||
resize_increments_applied=increments_applied,
|
||||
scaling_enabled=scaling_enabled,
|
||||
compositing_preferred=compositing_preferred,
|
||||
width_increment=increments[0],
|
||||
height_increment=increments[1],
|
||||
**_sample_window_diagnostic_details(
|
||||
diagnostics,
|
||||
mapper,
|
||||
window,
|
||||
),
|
||||
)
|
||||
if scaling_enabled and increments_applied and compositing_preferred:
|
||||
return increments
|
||||
time.sleep(WINDOW_DISCOVERY_POLL_SECONDS)
|
||||
return None
|
||||
|
||||
|
||||
def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
"""Run Gamescope with stable host presentation for the current session."""
|
||||
|
||||
if not command:
|
||||
raise GamescopeRuntimeError("Gamescope command cannot be empty")
|
||||
resize_increments(width, height)
|
||||
diagnostics = WindowDiagnosticRecorder("gamescope_host")
|
||||
environment = os.environ.copy()
|
||||
backend = _select_host_backend(environment)
|
||||
kde_session = _is_kde_session(environment)
|
||||
kde_wayland_xwayland_fallback = (
|
||||
backend == "sdl"
|
||||
and kde_session
|
||||
and bool(environment.get("WAYLAND_DISPLAY", "").strip())
|
||||
and bool(environment.get("DISPLAY", "").strip())
|
||||
)
|
||||
selected_command = list(map(str, command))
|
||||
force_composition = backend == "sdl"
|
||||
using_bundled_gamescope = False
|
||||
if backend == "wayland":
|
||||
(
|
||||
selected_command[0],
|
||||
force_composition,
|
||||
using_bundled_gamescope,
|
||||
) = _select_wayland_gamescope(
|
||||
selected_command[0],
|
||||
environment=environment,
|
||||
)
|
||||
mapper = _open_host_x11() if backend == "sdl" else None
|
||||
diagnostics.record(
|
||||
"runtime_configured",
|
||||
backend=backend,
|
||||
force_composition=force_composition,
|
||||
using_bundled_gamescope=using_bundled_gamescope,
|
||||
display_available=bool(environment.get("DISPLAY")),
|
||||
wayland_display_available=bool(environment.get("WAYLAND_DISPLAY")),
|
||||
host_x11_monitor_available=mapper is not None,
|
||||
kde_session=kde_session,
|
||||
kde_wayland_compatibility_mode=(
|
||||
kde_wayland_xwayland_fallback
|
||||
or (backend == "wayland" and kde_session and force_composition)
|
||||
),
|
||||
kde_wayland_xwayland_fallback=kde_wayland_xwayland_fallback,
|
||||
backend_reason=(
|
||||
"kde_wayland_xwayland_compatibility"
|
||||
if kde_wayland_xwayland_fallback
|
||||
else "wayland_socket"
|
||||
if backend == "wayland"
|
||||
else "x11_or_unavailable_wayland"
|
||||
),
|
||||
presentation_mode=(
|
||||
f"{backend}_{'composed' if force_composition else 'direct'}"
|
||||
),
|
||||
canvas_width=width,
|
||||
canvas_height=height,
|
||||
)
|
||||
diagnostic_sink = diagnostics if diagnostics.enabled else None
|
||||
status_file: Path | None = None
|
||||
host_focus_publisher: _HostFocusPublisher | None = None
|
||||
try:
|
||||
candidate_status_file = _create_child_status_file()
|
||||
try:
|
||||
launch_command = _supervised_command(
|
||||
selected_command,
|
||||
candidate_status_file,
|
||||
backend,
|
||||
force_composition=force_composition,
|
||||
)
|
||||
except ValueError:
|
||||
candidate_status_file.unlink(missing_ok=True)
|
||||
if mapper is not None:
|
||||
mapper.close()
|
||||
diagnostics.close(process_started=False, backend=backend)
|
||||
raise
|
||||
status_file = candidate_status_file
|
||||
except OSError as exc:
|
||||
diagnostics.record(
|
||||
"child_supervision_unavailable",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
launch_command = [
|
||||
selected_command[0],
|
||||
"--backend",
|
||||
backend,
|
||||
]
|
||||
if force_composition:
|
||||
launch_command.append("--force-composition")
|
||||
launch_command.extend(selected_command[1:])
|
||||
print(
|
||||
f"Warning: Gamescope child-status supervision is unavailable: {exc}; "
|
||||
"continuing with stable presentation.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if backend == "sdl" and environment.get("DISPLAY"):
|
||||
environment["SDL_VIDEODRIVER"] = "x11"
|
||||
# Keep Mutter on one composited presentation path at every window size.
|
||||
# SDL otherwise requests compositor bypass, which can switch near
|
||||
# screen-filling sizes and visibly flicker while Gamescope is scaling.
|
||||
environment["SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"] = "0"
|
||||
# If host X11 monitoring cannot start, make the nested focus repair
|
||||
# fail closed. An absent bridge retains legacy behavior for direct
|
||||
# gamebox invocations and native Wayland Gamescope only.
|
||||
environment[GAMESCOPE_HOST_FOCUS_STATE_ENV] = "unavailable"
|
||||
if mapper is not None:
|
||||
try:
|
||||
publisher = (
|
||||
_HostFocusPublisher.create()
|
||||
if diagnostic_sink is None
|
||||
else _HostFocusPublisher.create(diagnostics=diagnostic_sink)
|
||||
)
|
||||
except OSError as exc:
|
||||
diagnostics.record(
|
||||
"host_focus_monitor_unavailable",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
print(
|
||||
f"Warning: Gamescope host-focus monitoring is unavailable: "
|
||||
f"{exc}; nested focus repair will remain disabled.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
host_focus_publisher = publisher
|
||||
environment[GAMESCOPE_HOST_FOCUS_STATE_ENV] = str(publisher.path)
|
||||
if using_bundled_gamescope:
|
||||
# The game asks its X11 WM to iconify the first client while entering
|
||||
# the login screen. Our scoped Gamescope patch keeps that nested surface
|
||||
# visible without activating or raising the host window.
|
||||
environment[KEEP_NESTED_WINDOWS_VISIBLE_ENV] = "1"
|
||||
try:
|
||||
process = subprocess.Popen(launch_command, env=environment)
|
||||
except OSError as exc:
|
||||
diagnostics.record(
|
||||
"process_start_failed",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
if mapper is not None:
|
||||
mapper.close()
|
||||
if host_focus_publisher is not None:
|
||||
host_focus_publisher.close()
|
||||
if status_file is not None:
|
||||
status_file.unlink(missing_ok=True)
|
||||
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,
|
||||
)
|
||||
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:
|
||||
return _wait_for_optimized_gamescope(
|
||||
process,
|
||||
status_file,
|
||||
host_focus_publisher=host_focus_publisher,
|
||||
host_mapper=mapper,
|
||||
)
|
||||
except BaseException:
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise
|
||||
finally:
|
||||
host_summary = (
|
||||
host_focus_publisher.summary_details()
|
||||
if host_focus_publisher is not None
|
||||
else {}
|
||||
)
|
||||
diagnostics.close(
|
||||
process_started=True,
|
||||
backend=backend,
|
||||
host_focus_monitor_available=host_focus_publisher is not None,
|
||||
**host_summary,
|
||||
)
|
||||
if mapper is not None:
|
||||
mapper.close()
|
||||
if host_focus_publisher is not None:
|
||||
host_focus_publisher.close()
|
||||
if status_file is not None:
|
||||
status_file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--width", required=True, type=int)
|
||||
parser.add_argument("--height", required=True, type=int)
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
arguments = parser.parse_args(argv)
|
||||
command = arguments.command
|
||||
if command and command[0] == "--":
|
||||
command = command[1:]
|
||||
try:
|
||||
return run_gamescope(command, arguments.width, arguments.height)
|
||||
except (GamescopeRuntimeError, ValueError) as exc:
|
||||
print(f"Gamescope launch failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user