Fixed more issues with Cinnamon window resizing and popups.

This commit is contained in:
2026-09-08 22:46:53 +02:00
parent 1a120bbf86
commit 76bef94757
3 changed files with 257 additions and 99 deletions

View File

@@ -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)