Reworked some game window and gamescope methods for Cinnamon.
This commit is contained in:
@@ -11,6 +11,7 @@ import select
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Collection, Iterable, Mapping, Sequence
|
||||
|
||||
from ..desktop_identity import (
|
||||
@@ -322,10 +323,10 @@ class _X11:
|
||||
def find_window_by_pid(self, expected_pid: int) -> int | None:
|
||||
"""Find a top-level host window created by one native process."""
|
||||
|
||||
queue = self._root_windows()
|
||||
queue = deque(self._root_windows())
|
||||
visited: set[int] = set()
|
||||
while queue and len(visited) < 4096:
|
||||
window = queue.pop(0)
|
||||
window = queue.popleft()
|
||||
if window in visited:
|
||||
continue
|
||||
visited.add(window)
|
||||
@@ -351,14 +352,17 @@ class _X11:
|
||||
current_size,
|
||||
increments,
|
||||
)
|
||||
self._replace_long_property(
|
||||
window,
|
||||
b"WM_NORMAL_HINTS",
|
||||
b"WM_SIZE_HINTS",
|
||||
updated,
|
||||
)
|
||||
self.library.XSync(self.display, 0)
|
||||
confirmed = self._long_property(window, b"WM_NORMAL_HINTS", 18)
|
||||
if updated == hints:
|
||||
confirmed = hints
|
||||
else:
|
||||
self._replace_long_property(
|
||||
window,
|
||||
b"WM_NORMAL_HINTS",
|
||||
b"WM_SIZE_HINTS",
|
||||
updated,
|
||||
)
|
||||
self.library.XSync(self.display, 0)
|
||||
confirmed = self._long_property(window, b"WM_NORMAL_HINTS", 18)
|
||||
return bool(
|
||||
confirmed
|
||||
and len(confirmed) >= 17
|
||||
@@ -369,18 +373,25 @@ class _X11:
|
||||
def prefer_composited_presentation(self, window: int) -> bool:
|
||||
"""Keep the host compositor active for this window at every size."""
|
||||
|
||||
preferred = (_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,)
|
||||
if self._long_property(
|
||||
window,
|
||||
b"_NET_WM_BYPASS_COMPOSITOR",
|
||||
1,
|
||||
) == preferred:
|
||||
return True
|
||||
self._replace_long_property(
|
||||
window,
|
||||
b"_NET_WM_BYPASS_COMPOSITOR",
|
||||
b"CARDINAL",
|
||||
(_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,),
|
||||
preferred,
|
||||
)
|
||||
self.library.XSync(self.display, 0)
|
||||
return self._long_property(
|
||||
window,
|
||||
b"_NET_WM_BYPASS_COMPOSITOR",
|
||||
1,
|
||||
) == (_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING,)
|
||||
) == preferred
|
||||
|
||||
def _root_windows(self) -> list[int]:
|
||||
root = self.library.XDefaultRootWindow(self.display)
|
||||
@@ -720,9 +731,9 @@ class _X11:
|
||||
Wine marks this game with identical current-size ``PMinSize`` and
|
||||
``PMaxSize`` constraints and excludes resize/maximize from its Motif hints.
|
||||
Remove only that exact fixed-size pair and preserve unrelated aspect,
|
||||
increment, and geometry hints. Initial setup is retained only when the
|
||||
window manager confirms resize plus both maximize actions. Event-driven
|
||||
maintenance can skip the confirmation because support was already proven.
|
||||
increment, and geometry hints. Callers can wait for the window manager to
|
||||
confirm resize plus both maximize actions, or retain the safe client hints
|
||||
while polling the manager-owned action property independently.
|
||||
"""
|
||||
|
||||
current_size = self.window_size(window)
|
||||
|
||||
@@ -31,7 +31,7 @@ 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
|
||||
CHILD_STATUS_POLL_SECONDS = 0.2
|
||||
HOST_FOCUS_HEARTBEAT_SECONDS = 0.2
|
||||
HOST_REICONIFY_INTERVAL_SECONDS = 0.2
|
||||
GAMESCOPE_VERSION_TIMEOUT_SECONDS = 1.0
|
||||
@@ -429,6 +429,20 @@ def _bundled_gamescope_is_usable() -> bool:
|
||||
return os.access(path, os.X_OK)
|
||||
|
||||
|
||||
def _desktop_session_tokens(environment: Mapping[str, str]) -> set[str]:
|
||||
tokens: set[str] = set()
|
||||
for key in ("XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"):
|
||||
tokens.update(
|
||||
token
|
||||
for token in re.split(
|
||||
r"[^a-z0-9]+",
|
||||
environment.get(key, "").strip().casefold(),
|
||||
)
|
||||
if token
|
||||
)
|
||||
return tokens
|
||||
|
||||
|
||||
def _is_kde_session(environment: Mapping[str, str]) -> bool:
|
||||
"""Recognize Plasma sessions without relying on an Ubuntu flavor name."""
|
||||
|
||||
@@ -440,14 +454,17 @@ def _is_kde_session(environment: Mapping[str, str]) -> bool:
|
||||
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
|
||||
tokens = _desktop_session_tokens(environment)
|
||||
return "kde" in tokens or any(token.startswith("plasma") for token in tokens)
|
||||
|
||||
|
||||
def _is_cinnamon_session(environment: Mapping[str, str]) -> bool:
|
||||
"""Recognize Cinnamon/Muffin sessions, including the X-Cinnamon spelling."""
|
||||
|
||||
return any(
|
||||
token.startswith("cinnamon")
|
||||
for token in _desktop_session_tokens(environment)
|
||||
)
|
||||
|
||||
|
||||
def _select_wayland_gamescope(
|
||||
@@ -490,14 +507,17 @@ def _wayland_socket_available(environment: Mapping[str, str]) -> bool:
|
||||
|
||||
|
||||
def _select_host_backend(environment: Mapping[str, str]) -> str:
|
||||
"""Choose the host backend with a Plasma-safe XWayland fallback."""
|
||||
"""Choose the host backend with desktop-specific XWayland fallbacks."""
|
||||
|
||||
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():
|
||||
# KWin still has presentation/fullscreen failures here. Cinnamon's native
|
||||
# Wayland decoration path can omit Gamescope's maximize control. Both
|
||||
# desktops normally expose XWayland, where Gamescope creates an explicitly
|
||||
# resizable SDL window and the host window manager supplies its controls.
|
||||
desktop_needs_xwayland = (
|
||||
_is_kde_session(environment) or _is_cinnamon_session(environment)
|
||||
)
|
||||
if desktop_needs_xwayland and environment.get("DISPLAY", "").strip():
|
||||
return "sdl"
|
||||
return "wayland"
|
||||
return "sdl"
|
||||
@@ -601,11 +621,17 @@ def _tune_process_window(
|
||||
"""Stabilize resizing once the SDL host window becomes available."""
|
||||
|
||||
deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS
|
||||
increments = resize_increments(width, height)
|
||||
reported_window: int | None = None
|
||||
last_configuration: tuple[bool, bool, bool] | None = None
|
||||
host_window: int | None = None
|
||||
last_configuration: tuple[bool, bool, bool, bool] | None = None
|
||||
while process.poll() is None and time.monotonic() < deadline:
|
||||
window = mapper.find_window_by_pid(process.pid)
|
||||
window = host_window
|
||||
if window is None:
|
||||
window = mapper.find_window_by_pid(process.pid)
|
||||
host_window = window
|
||||
if window is not None:
|
||||
window = int(window)
|
||||
if diagnostics is not None and window != reported_window:
|
||||
reported_window = window
|
||||
diagnostics.record(
|
||||
@@ -619,20 +645,28 @@ def _tune_process_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)
|
||||
# Gamescope creates this SDL window as resizable. Keep the matching
|
||||
# client hints even while Muffin is still recalculating its frame;
|
||||
# rolling them back on a slow acknowledgement permanently removes
|
||||
# Cinnamon's maximize control. Poll the WM-owned actions separately.
|
||||
scaling_hints_applied = mapper.enable_window_scaling(
|
||||
window,
|
||||
confirm=False,
|
||||
)
|
||||
scaling_enabled = (
|
||||
scaling_hints_applied and mapper.window_allows_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_hints_applied,
|
||||
scaling_enabled,
|
||||
compositing_preferred,
|
||||
)
|
||||
@@ -642,6 +676,7 @@ def _tune_process_window(
|
||||
"host_scaling_configured",
|
||||
window_id=window,
|
||||
resize_increments_applied=increments_applied,
|
||||
scaling_hints_applied=scaling_hints_applied,
|
||||
scaling_enabled=scaling_enabled,
|
||||
compositing_preferred=compositing_preferred,
|
||||
width_increment=increments[0],
|
||||
@@ -668,12 +703,19 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
environment = os.environ.copy()
|
||||
backend = _select_host_backend(environment)
|
||||
kde_session = _is_kde_session(environment)
|
||||
cinnamon_session = _is_cinnamon_session(environment)
|
||||
kde_wayland_xwayland_fallback = (
|
||||
backend == "sdl"
|
||||
and kde_session
|
||||
and bool(environment.get("WAYLAND_DISPLAY", "").strip())
|
||||
and bool(environment.get("DISPLAY", "").strip())
|
||||
)
|
||||
cinnamon_wayland_xwayland_fallback = (
|
||||
backend == "sdl"
|
||||
and cinnamon_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
|
||||
@@ -696,14 +738,18 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
wayland_display_available=bool(environment.get("WAYLAND_DISPLAY")),
|
||||
host_x11_monitor_available=mapper is not None,
|
||||
kde_session=kde_session,
|
||||
cinnamon_session=cinnamon_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,
|
||||
cinnamon_wayland_xwayland_fallback=cinnamon_wayland_xwayland_fallback,
|
||||
backend_reason=(
|
||||
"kde_wayland_xwayland_compatibility"
|
||||
if kde_wayland_xwayland_fallback
|
||||
else "cinnamon_wayland_xwayland_compatibility"
|
||||
if cinnamon_wayland_xwayland_fallback
|
||||
else "wayland_socket"
|
||||
if backend == "wayland"
|
||||
else "x11_or_unavailable_wayland"
|
||||
@@ -812,6 +858,14 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif cinnamon_wayland_xwayland_fallback:
|
||||
print(
|
||||
"Cinnamon Wayland compatibility mode active: Gamescope is using "
|
||||
"its resizable SDL host through XWayland so Muffin provides the "
|
||||
"native minimize, maximize, and close controls.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if backend == "wayland" and not force_composition:
|
||||
message = (
|
||||
"Gamescope resize optimization active: native Wayland direct "
|
||||
|
||||
Reference in New Issue
Block a user