diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index 3d77a1b..475b7ca 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -5,11 +5,13 @@ from __future__ import annotations import argparse import ctypes import ctypes.util +import os +from pathlib import Path import select import subprocess import sys import time -from collections.abc import Collection, Iterable, Sequence +from collections.abc import Collection, Iterable, Mapping, Sequence from .desktop_identity import ( APPLICATION_NAME, @@ -44,12 +46,15 @@ _X_PROPERTY_CHANGE_MASK = 1 << 22 _X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19 _X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20 _X_WM_STATE_WITHDRAWN = 0 +_X_WM_STATE_NORMAL = 1 _X_WM_STATE_ICONIC = 3 _NET_WM_STATE_REMOVE = 0 _NET_WM_STATE_SOURCE_APPLICATION = 1 _NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING = 2 WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS = 1.0 GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS = 0.25 +GAMESCOPE_HOST_FOCUS_STATE_ENV = "PTLINUX_GAMESCOPE_HOST_FOCUS_STATE" +GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS = 0.6 _P_MIN_SIZE = 1 << 4 _P_MAX_SIZE = 1 << 5 _P_RESIZE_INC = 1 << 6 @@ -64,6 +69,14 @@ _MOTIF_DECOR_MAXIMIZE = 1 << 6 _X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) +def _process_exit_status(returncode: int) -> int: + """Convert Popen's negative signal code before passing it through SystemExit.""" + + if returncode < 0: + return 128 + min(-returncode, 127) + return min(returncode, 255) + + def _ignore_x11_error(_display: object, _event: object) -> int: """Keep a stale X11 window race from terminating the mapper process.""" return 0 @@ -555,21 +568,14 @@ class _X11: and updated_normal_hints != normal_hints ) motif_changed = motif_hints is not None and updated_motif_hints != motif_hints - allowed_actions = self._long_property(window, b"_NET_WM_ALLOWED_ACTIONS", 64) - allowed_actions_with_scaling = self._allowed_actions_with_scaling( - allowed_actions, - ) - allowed_actions_changed = ( - allowed_actions is not None - and allowed_actions_with_scaling is not None - and allowed_actions_with_scaling != allowed_actions - ) - if ( - not normal_changed - and not motif_changed - and not allowed_actions_changed - ): - return not confirm or self.window_allows_scaling(window) + if not normal_changed and not motif_changed: + if not confirm: + return True + # ``_NET_WM_ALLOWED_ACTIONS`` belongs to the window manager. Wait for + # its answer even when the client hints were already resizable; Muffin + # can publish the property shortly after the SDL window is discovered. + self.library.XSync(self.display, 0) + return self._wait_for_scaling_actions(window) confirmed = False try: @@ -587,13 +593,6 @@ class _X11: b"_MOTIF_WM_HINTS", updated_motif_hints, ) - if allowed_actions_changed and allowed_actions_with_scaling is not None: - self._replace_long_property( - window, - b"_NET_WM_ALLOWED_ACTIONS", - b"_ATOM", - allowed_actions_with_scaling, - ) if not confirm: self.library.XFlush(self.display) confirmed = True @@ -624,16 +623,6 @@ class _X11: ) except Exception as exc: restore_errors.append(exc) - if allowed_actions_changed and allowed_actions is not None: - try: - self._replace_long_property( - window, - b"_NET_WM_ALLOWED_ACTIONS", - b"_ATOM", - allowed_actions, - ) - except Exception as exc: - restore_errors.append(exc) try: self.library.XSync(self.display, 0) except Exception as exc: @@ -665,36 +654,6 @@ class _X11: vertical, }.issubset(actions) - def _allowed_actions_with_scaling( - self, - actions: tuple[int, ...] | None, - ) -> tuple[int, ...] | None: - if actions is None: - return None - - resize = self._intern_atom( - b"_NET_WM_ACTION_RESIZE", - only_if_exists=True, - ) - horizontal = self._intern_atom( - b"_NET_WM_ACTION_MAXIMIZE_HORZ", - only_if_exists=True, - ) - vertical = self._intern_atom( - b"_NET_WM_ACTION_MAXIMIZE_VERT", - only_if_exists=True, - ) - if resize == 0 or horizontal == 0 or vertical == 0: - return actions - - updated_actions = list(actions) - known_actions = set(actions) - for atom in (resize, horizontal, vertical): - if atom not in known_actions: - updated_actions.append(atom) - known_actions.add(atom) - return tuple(updated_actions) - def _wait_for_scaling_actions(self, window: int) -> bool: deadline = time.monotonic() + WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS while not self.window_allows_scaling(window): @@ -1004,6 +963,14 @@ class _X11: active = self._long_property(root, b"_NET_ACTIVE_WINDOW", 1) return bool(active and active[0] == window) + def minimize_window(self, window: int) -> bool: + """Ask the host window manager to keep a remapped client minimized.""" + + screen = self.library.XDefaultScreen(self.display) + accepted = self.library.XIconifyWindow(self.display, window, screen) != 0 + self.library.XSync(self.display, 0) + return accepted + def activate_window(self, window: int) -> bool: """Ask the window manager to restore and activate one client.""" @@ -1174,6 +1141,14 @@ class _X11: self.library.XOpenDisplay.restype = ctypes.c_void_p self.library.XDefaultRootWindow.argtypes = [ctypes.c_void_p] self.library.XDefaultRootWindow.restype = ctypes.c_ulong + self.library.XDefaultScreen.argtypes = [ctypes.c_void_p] + self.library.XDefaultScreen.restype = ctypes.c_int + self.library.XIconifyWindow.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.c_int, + ] + self.library.XIconifyWindow.restype = ctypes.c_int self.library.XQueryTree.argtypes = [ ctypes.c_void_p, ctypes.c_ulong, @@ -1457,6 +1432,8 @@ def _repair_game_window_focus(mapper: _X11, window: int) -> bool: def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool: """Restore nested focus without asking the desktop to activate Gamescope.""" + if not _gamescope_host_focus_is_allowed(): + return False try: if mapper.window_state(window) == _X_WM_STATE_ICONIC: return False @@ -1477,6 +1454,31 @@ def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool: return False +def _gamescope_host_focus_is_allowed( + *, + environment: Mapping[str, str] | None = None, + now: float | None = None, +) -> bool: + """Honor the outer X11 window's current focus without trusting stale state.""" + + env = os.environ if environment is None else environment + state_path = env.get(GAMESCOPE_HOST_FOCUS_STATE_ENV, "").strip() + if not state_path: + # Direct invocations and native Wayland Gamescope have no X11 host-state + # bridge. Retain their existing nested focus behavior. + return True + try: + fields = Path(state_path).read_text(encoding="ascii").split() + if len(fields) != 2 or fields[0] != "1": + return False + published_at = float(fields[1]) + except (OSError, UnicodeError, ValueError): + return False + observed_at = time.monotonic() if now is None else now + age = observed_at - published_at + return 0.0 <= age <= GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS + + def _resolve_gamescope_game_window( mapper: _X11, window: int, @@ -1851,7 +1853,8 @@ def run_game_window( file=sys.stderr, flush=True, ) - _focus_game_window(mapper, game_window) + if _gamescope_host_focus_is_allowed(): + _focus_game_window(mapper, game_window) print( "Game presentation is isolated; Gamescope handles " "aspect-preserving outer-window resizing.", @@ -1944,13 +1947,15 @@ def main(argv: Sequence[str] | None = None) -> int: if command and command[0] == "--": command = command[1:] try: - return run_game_window( - command, - arguments.window_title, - borderless=arguments.borderless, - allow_window_resizing=arguments.allow_window_resizing, - gamescope_scaling=arguments.gamescope_scaling, - suppress_wm_ping=arguments.suppress_wm_ping, + return _process_exit_status( + run_game_window( + command, + arguments.window_title, + borderless=arguments.borderless, + allow_window_resizing=arguments.allow_window_resizing, + gamescope_scaling=arguments.gamescope_scaling, + suppress_wm_ping=arguments.suppress_wm_ping, + ) ) except (GameboxRuntimeError, OSError) as exc: print(f"Error: {exc}", file=sys.stderr) diff --git a/ptlinux/gamescope_runtime.py b/ptlinux/gamescope_runtime.py index d5c95a2..b7f3f6e 100644 --- a/ptlinux/gamescope_runtime.py +++ b/ptlinux/gamescope_runtime.py @@ -14,7 +14,13 @@ import tempfile import time from collections.abc import Mapping, Sequence -from .gamebox_runtime import GameboxRuntimeError, _X11 +from .gamebox_runtime import ( + GAMESCOPE_HOST_FOCUS_STATE_ENV, + GameboxRuntimeError, + _X11, + _X_WM_STATE_ICONIC, + _X_WM_STATE_NORMAL, +) WINDOW_DISCOVERY_TIMEOUT_SECONDS = 10.0 WINDOW_DISCOVERY_POLL_SECONDS = 0.05 @@ -22,6 +28,8 @@ 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) @@ -43,6 +51,101 @@ 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) -> None: + self.file_descriptor = file_descriptor + self.path = path + 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._publish(False, force=True) + + @classmethod + def create(cls) -> _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)) + except BaseException: + os.close(file_descriptor) + Path(path).unlink(missing_ok=True) + raise + + def update(self, mapper: _X11, window: int) -> None: + now = time.monotonic() + try: + state = mapper.window_state(window) + active = state == _X_WM_STATE_NORMAL and mapper.window_is_active(window) + except Exception: + state = None + active = False + if state == _X_WM_STATE_ICONIC and self._seen_normal_state: + self._minimized_latched = True + 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 + 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. + try: + mapper.minimize_window(window) + except Exception: + pass + self._last_reiconify_at = now + active = False + self._publish(active, now=now) + + 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.""" @@ -247,12 +350,26 @@ def _process_exit_status(returncode: int) -> int: def _wait_for_optimized_gamescope( process: subprocess.Popen[bytes], - status_file: Path, + status_file: Path | None, + *, + host_focus_publisher: _HostFocusPublisher | None = None, + host_mapper: _X11 | None = None, + host_window: int | None = None, ) -> int: - """Return the real child status and bypass old Gamescope shutdown bugs.""" + """Monitor host focus and preserve the nested child's real exit status.""" while True: - child_status = _read_child_status(status_file) + 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() @@ -260,7 +377,9 @@ def _wait_for_optimized_gamescope( return _process_exit_status(child_status) gamescope_status = process.poll() if gamescope_status is not None: - child_status = _read_child_status(status_file) + 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 ) @@ -283,6 +402,8 @@ def _tune_process_window( process: subprocess.Popen[bytes], width: int, height: int, + *, + host_focus_publisher: _HostFocusPublisher | None = None, ) -> tuple[int, int] | None: """Stabilize resizing once the SDL host window becomes available.""" @@ -290,15 +411,19 @@ def _tune_process_window( while process.poll() is None and time.monotonic() < deadline: window = mapper.find_window_by_pid(process.pid) if window is not None: + if host_focus_publisher is not None: + host_focus_publisher.update(mapper, window) increments = resize_increments(width, height) - # Some X11 window managers keep the maximize decoration hidden until - # both the size constraints and allowed actions advertise resizing. - scaling_enabled = mapper.enable_window_scaling(window) 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) if scaling_enabled and increments_applied and compositing_preferred: return increments @@ -325,6 +450,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: ) = _select_wayland_gamescope(selected_command[0]) mapper = _open_host_x11() if backend == "sdl" else None status_file: Path | None = None + host_focus_publisher: _HostFocusPublisher | None = None try: candidate_status_file = _create_child_status_file() try: @@ -361,6 +487,23 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: # 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() + except OSError as exc: + 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 @@ -371,6 +514,8 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: except OSError as exc: 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) raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc @@ -396,23 +541,21 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: ) elif mapper is not None: try: - try: - increments = _tune_process_window( - mapper, - process, - width, - height, - ) - except Exception as exc: - increments = None - print( - f"Warning: Gamescope resize tuning failed: {exc}; " - "continuing normally.", - file=sys.stderr, - flush=True, - ) - finally: - mapper.close() + increments = _tune_process_window( + mapper, + process, + width, + height, + host_focus_publisher=host_focus_publisher, + ) + except Exception as exc: + increments = None + 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 " @@ -436,9 +579,12 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: flush=True, ) try: - if status_file is not None: - return _wait_for_optimized_gamescope(process, status_file) - return _process_exit_status(int(process.wait())) + 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() @@ -449,6 +595,10 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: process.wait() raise finally: + 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) diff --git a/ptlinux/runtime.py b/ptlinux/runtime.py index fabf73a..7ebf876 100644 --- a/ptlinux/runtime.py +++ b/ptlinux/runtime.py @@ -509,6 +509,9 @@ def run_game( output_line_filter=output_line_filter, pass_fds=pass_fds, telemetry_stage="game_start", + # Play distinguishes a failed launch from a nonzero status returned after + # a visible game session. Its game-specific event retains that context. + report_failure_telemetry=False, )