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