"""Launch Gamescope with low-overhead host-window resize behavior.""" 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 .gamebox_runtime import ( GAMESCOPE_HOST_FOCUS_STATE_ENV, GameboxRuntimeError, _X11, _X_WM_STATE_ICONIC, _X_WM_STATE_NORMAL, _sample_window_diagnostic_details, _wm_state_name, ) 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 Path(__file__).resolve().parent / "native" / 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())