Updated the update system to support multi channels (stable / pre-release).
This commit is contained in:
@@ -20,7 +20,10 @@ from .gamebox_runtime import (
|
||||
_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
|
||||
@@ -54,18 +57,38 @@ class GamescopeRuntimeError(RuntimeError):
|
||||
class _HostFocusPublisher:
|
||||
"""Publish the host WM's focus decision to the nested game supervisor."""
|
||||
|
||||
def __init__(self, file_descriptor: int, path: Path) -> None:
|
||||
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) -> _HostFocusPublisher:
|
||||
def create(
|
||||
cls,
|
||||
diagnostics: WindowDiagnosticRecorder | None = None,
|
||||
) -> _HostFocusPublisher:
|
||||
runtime_directory = os.environ.get("XDG_RUNTIME_DIR")
|
||||
try:
|
||||
file_descriptor, path = tempfile.mkstemp(
|
||||
@@ -77,7 +100,7 @@ class _HostFocusPublisher:
|
||||
prefix="ptlinux-gamescope-focus-",
|
||||
)
|
||||
try:
|
||||
return cls(file_descriptor, Path(path))
|
||||
return cls(file_descriptor, Path(path), diagnostics)
|
||||
except BaseException:
|
||||
os.close(file_descriptor)
|
||||
Path(path).unlink(missing_ok=True)
|
||||
@@ -85,14 +108,68 @@ class _HostFocusPublisher:
|
||||
|
||||
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:
|
||||
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._minimized_latched = True
|
||||
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:
|
||||
@@ -100,18 +177,95 @@ class _HostFocusPublisher:
|
||||
# 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:
|
||||
mapper.minimize_window(window)
|
||||
except Exception:
|
||||
pass
|
||||
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,
|
||||
@@ -404,13 +558,27 @@ def _tune_process_window(
|
||||
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)
|
||||
@@ -425,6 +593,27 @@ def _tune_process_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)
|
||||
@@ -437,6 +626,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
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)
|
||||
selected_command = list(map(str, command))
|
||||
@@ -449,6 +639,18 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
using_bundled_gamescope,
|
||||
) = _select_wayland_gamescope(selected_command[0])
|
||||
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,
|
||||
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:
|
||||
@@ -464,9 +666,14 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
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",
|
||||
@@ -493,8 +700,16 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
environment[GAMESCOPE_HOST_FOCUS_STATE_ENV] = "unavailable"
|
||||
if mapper is not None:
|
||||
try:
|
||||
publisher = _HostFocusPublisher.create()
|
||||
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.",
|
||||
@@ -512,12 +727,17 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
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 backend == "wayland" and not force_composition:
|
||||
@@ -541,15 +761,29 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
)
|
||||
elif mapper is not None:
|
||||
try:
|
||||
increments = _tune_process_window(
|
||||
mapper,
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
host_focus_publisher=host_focus_publisher,
|
||||
)
|
||||
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.",
|
||||
@@ -565,6 +799,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
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.",
|
||||
@@ -572,6 +807,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
diagnostics.record("host_x11_monitor_unavailable")
|
||||
print(
|
||||
"Gamescope is using its SDL presentation fallback; host X11 resize "
|
||||
"tuning is unavailable.",
|
||||
@@ -595,6 +831,17 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user