Fixed more issues with Cinnamon window resizing and popups.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user