import contextlib import hashlib import io import os from pathlib import Path import socket import subprocess import tempfile import unittest from unittest import mock # noinspection PyProtectedMember from ptlinux.gamescope_runtime import ( _HostFocusPublisher, _bundled_gamescope_is_usable, _is_cinnamon_session, _is_kde_session, _process_exit_status, _read_gamescope_version, _select_host_backend, _select_wayland_gamescope, _supervised_command, _tune_process_window, _wait_for_optimized_gamescope, main, resize_increments, run_gamescope, ) from ptlinux.gamebox_runtime import GAMESCOPE_HOST_FOCUS_STATE_ENV from ptlinux.window_diagnostics import ( WINDOW_DIAGNOSTICS_ENV, WINDOW_DIAGNOSTICS_SESSION_ENV, WindowDiagnosticRecorder, parse_window_diagnostic, ) class GamescopeRuntimeTests(unittest.TestCase): def test_uses_five_percent_resize_steps_with_sensible_bounds(self) -> None: self.assertEqual(resize_increments(1280, 960), (64, 48)) self.assertEqual(resize_increments(100, 100), (8, 8)) self.assertEqual(resize_increments(7680, 4320), (96, 96)) def test_rejects_invalid_canvas_dimensions(self) -> None: with self.assertRaisesRegex(ValueError, "positive"): resize_increments(0, 960) def test_inserts_selected_backend_before_existing_gamescope_options(self) -> None: self.assertEqual( _supervised_command( ["/usr/bin/gamescope", "-w", "1280", "--", "game"], Path("/tmp/status"), "wayland", ), [ "/usr/bin/gamescope", "--backend", "wayland", "-w", "1280", "--", mock.ANY, "-P", "-m", "ptlinux.gamescope_child_runtime", "--status-file", "/tmp/status", "--", "game", ], ) def test_can_force_composition_for_an_unsafe_wayland_build(self) -> None: command = _supervised_command( ["gamescope", "--", "game"], Path("/tmp/status"), "wayland", force_composition=True, ) self.assertEqual(command[:4], [ "gamescope", "--backend", "wayland", "--force-composition", ]) def test_reads_the_gamescope_release_tuple(self) -> None: completed = mock.Mock( returncode=0, stdout="[gamescope] gamescope version 3.16.20+ds-1 (gcc 15.2.0)\n" ) with mock.patch( "ptlinux.gamescope_runtime.subprocess.run", return_value=completed, ) as run: self.assertEqual( _read_gamescope_version("/usr/games/gamescope"), (3, 16, 20), ) run.assert_called_once_with( ["/usr/games/gamescope", "--version"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", timeout=1.0, check=False, ) def test_selects_upstream_or_launcher_local_wayland_backing_fix(self) -> None: with mock.patch( "ptlinux.gamescope_runtime._read_gamescope_version", side_effect=[(3, 16, 28), (3, 16, 20)], ), mock.patch( "ptlinux.gamescope_runtime._bundled_gamescope_is_usable", return_value=True, ), mock.patch( "ptlinux.gamescope_runtime._bundled_gamescope_path", return_value=Path("/opt/ptlinux/gamescope"), ): self.assertEqual( _select_wayland_gamescope("/usr/games/gamescope"), ("/usr/games/gamescope", False, False), ) self.assertEqual( _select_wayland_gamescope("/usr/games/gamescope"), ("/opt/ptlinux/gamescope", False, True), ) def test_unsafe_wayland_build_uses_composed_fallback(self) -> None: with ( mock.patch( "ptlinux.gamescope_runtime._read_gamescope_version", return_value=(3, 16, 20), ), mock.patch( "ptlinux.gamescope_runtime._bundled_gamescope_is_usable", return_value=False, ), ): self.assertEqual( _select_wayland_gamescope("gamescope"), ("gamescope", True, False), ) def test_kubuntu_keeps_every_native_wayland_build_fully_composed(self) -> None: environment = { "XDG_CURRENT_DESKTOP": "KDE", "XDG_SESSION_DESKTOP": "KDE", "DESKTOP_SESSION": "plasmawayland", } with ( mock.patch( "ptlinux.gamescope_runtime._read_gamescope_version", return_value=(3, 16, 28), ), mock.patch( "ptlinux.gamescope_runtime._bundled_gamescope_is_usable", return_value=True, ) as bundled_gamescope, ): self.assertEqual( _select_wayland_gamescope( "/usr/games/gamescope", environment=environment, ), ("/usr/games/gamescope", True, False), ) self.assertTrue(_is_kde_session(environment)) bundled_gamescope.assert_not_called() def test_recognizes_cinnamon_session_names(self) -> None: self.assertTrue( _is_cinnamon_session( { "XDG_CURRENT_DESKTOP": "X-Cinnamon:GNOME", "DESKTOP_SESSION": "cinnamon2d", } ) ) self.assertFalse(_is_cinnamon_session({"XDG_CURRENT_DESKTOP": "GNOME"})) def test_launcher_local_gamescope_matches_its_pinned_digest(self) -> None: with mock.patch( "ptlinux.gamescope_runtime._bundled_gamescope_host_supported", return_value=True, ): self.assertTrue(_bundled_gamescope_is_usable()) def test_repairs_a_non_executable_verified_launcher_local_gamescope( self, ) -> None: with tempfile.TemporaryDirectory() as temporary: executable = Path(temporary) / "gamescope" payload = b"verified launcher-local gamescope" executable.write_bytes(payload) executable.chmod(0o644) with ( mock.patch( "ptlinux.gamescope_runtime._bundled_gamescope_host_supported", return_value=True, ), mock.patch( "ptlinux.gamescope_runtime._bundled_gamescope_path", return_value=executable, ), mock.patch( "ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256", hashlib.sha256(payload).hexdigest(), ), ): self.assertTrue(_bundled_gamescope_is_usable()) self.assertEqual(executable.stat().st_mode & 0o777, 0o755) def test_does_not_make_an_unverified_launcher_local_gamescope_executable( self, ) -> None: with tempfile.TemporaryDirectory() as temporary: executable = Path(temporary) / "gamescope" executable.write_bytes(b"unexpected contents") executable.chmod(0o644) with ( mock.patch( "ptlinux.gamescope_runtime._bundled_gamescope_host_supported", return_value=True, ), mock.patch( "ptlinux.gamescope_runtime._bundled_gamescope_path", return_value=executable, ), mock.patch( "ptlinux.gamescope_runtime.BUNDLED_GAMESCOPE_SHA256", "0" * 64, ), ): self.assertFalse(_bundled_gamescope_is_usable()) self.assertEqual(executable.stat().st_mode & 0o777, 0o644) def test_selects_wayland_only_for_an_accessible_socket(self) -> None: with tempfile.TemporaryDirectory() as temporary: socket_path = Path(temporary) / "wayland-test" with socket.socket(socket.AF_UNIX) as wayland_socket: wayland_socket.bind(str(socket_path)) self.assertEqual( _select_host_backend( { "XDG_RUNTIME_DIR": temporary, "WAYLAND_DISPLAY": socket_path.name, } ), "wayland", ) self.assertEqual( _select_host_backend( { "XDG_RUNTIME_DIR": temporary, "WAYLAND_DISPLAY": socket_path.name, "DISPLAY": ":1", "XDG_CURRENT_DESKTOP": "X-Cinnamon", } ), "sdl", ) self.assertEqual( _select_host_backend( { "XDG_RUNTIME_DIR": temporary, "WAYLAND_DISPLAY": socket_path.name, "XDG_CURRENT_DESKTOP": "X-Cinnamon", } ), "wayland", ) self.assertEqual( _select_host_backend( { "XDG_RUNTIME_DIR": temporary, "WAYLAND_DISPLAY": socket_path.name, "DISPLAY": ":1", "XDG_CURRENT_DESKTOP": "KDE", } ), "sdl", ) self.assertEqual( _select_host_backend( { "XDG_RUNTIME_DIR": temporary, "WAYLAND_DISPLAY": socket_path.name, "XDG_CURRENT_DESKTOP": "KDE", } ), "wayland", ) self.assertEqual( _select_host_backend( { "XDG_RUNTIME_DIR": temporary, "WAYLAND_DISPLAY": "missing-wayland-socket", } ), "sdl", ) def test_maps_signals_to_a_shell_exit_status(self) -> None: self.assertEqual(_process_exit_status(-6), 134) self.assertEqual(_process_exit_status(7), 7) def test_tunes_resize_steps_and_keeps_the_host_window_composited(self) -> None: mapper = mock.Mock() mapper.find_window_by_pid.return_value = 41 mapper.enable_window_scaling.return_value = True mapper.window_allows_scaling.return_value = True mapper.set_resize_increments.return_value = True mapper.prefer_composited_presentation.return_value = True process = mock.Mock() process.pid = 1234 process.poll.return_value = None publisher = mock.Mock() self.assertEqual( _tune_process_window( mapper, process, 1280, 960, host_focus_publisher=publisher, ), (64, 48), ) self.assertEqual(publisher.update.call_args_list, [ mock.call(mapper, 41), mock.call(mapper, 41), ]) mapper.enable_window_scaling.assert_called_once_with(41, confirm=False) mapper.window_allows_scaling.assert_called_once_with(41) mapper.set_resize_increments.assert_called_once_with( 41, (1280, 960), (64, 48), ) mapper.prefer_composited_presentation.assert_called_once_with(41) def test_tuning_retains_hints_while_muffin_refreshes_its_frame(self) -> None: mapper = mock.Mock() mapper.find_window_by_pid.return_value = 41 mapper.enable_window_scaling.return_value = True mapper.window_allows_scaling.side_effect = [False, True] mapper.set_resize_increments.return_value = True mapper.prefer_composited_presentation.return_value = True process = mock.Mock(pid=1234) process.poll.return_value = None with ( mock.patch( "ptlinux.gamescope_runtime.time.monotonic", side_effect=[0.0, 0.0, 0.1], ), mock.patch("ptlinux.gamescope_runtime.time.sleep") as sleep, ): self.assertEqual( _tune_process_window(mapper, process, 1280, 960), (64, 48), ) mapper.find_window_by_pid.assert_called_once_with(1234) self.assertEqual( mapper.enable_window_scaling.call_args_list, [ mock.call(41, confirm=False), mock.call(41, confirm=False), ], ) sleep.assert_called_once_with(0.05) def test_host_focus_publisher_tracks_active_and_minimized_state(self) -> None: with tempfile.TemporaryDirectory() as temporary: with ( mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}), mock.patch( "ptlinux.gamescope_runtime.time.monotonic", side_effect=[10.0, 10.1, 10.2], ), ): publisher = _HostFocusPublisher.create() try: mapper = mock.Mock() mapper.window_state.return_value = 1 mapper.window_is_active.return_value = True publisher.update(mapper, 41) self.assertEqual( publisher.path.read_text(encoding="ascii").split()[0], "1", ) mapper.window_state.return_value = 3 publisher.update(mapper, 41) self.assertEqual( publisher.path.read_text(encoding="ascii").split()[0], "0", ) finally: state_path = publisher.path publisher.close() self.assertFalse(state_path.exists()) def test_host_focus_publisher_preserves_an_inactive_minimized_remap( self, ) -> None: with tempfile.TemporaryDirectory() as temporary: with ( mock.patch.dict(os.environ, {"XDG_RUNTIME_DIR": temporary}), mock.patch( "ptlinux.gamescope_runtime.time.monotonic", side_effect=[10.0, 10.1, 10.2, 10.3, 10.4], ), ): publisher = _HostFocusPublisher.create() try: mapper = mock.Mock() mapper.window_state.side_effect = [1, 3, 1, 1] mapper.window_is_active.side_effect = [True, False, True] publisher.update(mapper, 41) publisher.update(mapper, 41) publisher.update(mapper, 41) mapper.minimize_window.assert_called_once_with(41) self.assertEqual( publisher.path.read_text(encoding="ascii").split()[0], "0", ) publisher.update(mapper, 41) self.assertEqual( publisher.path.read_text(encoding="ascii").split()[0], "1", ) mapper.minimize_window.assert_called_once_with(41) finally: publisher.close() def test_host_focus_diagnostics_capture_the_minimize_remap_cycle(self) -> None: with tempfile.TemporaryDirectory() as temporary: environment = { "XDG_RUNTIME_DIR": temporary, WINDOW_DIAGNOSTICS_ENV: "1", WINDOW_DIAGNOSTICS_SESSION_ENV: "b" * 32, } output = io.StringIO() with ( mock.patch.dict(os.environ, environment, clear=True), mock.patch( "ptlinux.gamescope_runtime.time.monotonic", side_effect=[10.0, 10.1, 10.2, 10.4], ), contextlib.redirect_stderr(output), ): diagnostics = WindowDiagnosticRecorder( "gamescope_host", environment=environment, monotonic=lambda: 20.0, ) publisher = _HostFocusPublisher.create(diagnostics=diagnostics) summary: dict[str, object] = {} try: mapper = mock.Mock() mapper.window_state.side_effect = [1, 3, 1] mapper.window_is_active.side_effect = [True, False] mapper.minimize_window.return_value = True mapper.window_diagnostic_snapshot.return_value = { "wm_state": "normal", "allows_resize": True, "allows_maximize_horizontal": False, "allows_maximize_vertical": False, } mapper.window_net_wm_state_flags.side_effect = [ { "hidden": False, "above": False, "demands_attention": False, "maximized_horizontal": False, "maximized_vertical": False, }, { "hidden": True, "above": False, "demands_attention": False, "maximized_horizontal": False, "maximized_vertical": False, }, { "hidden": False, "above": False, "demands_attention": False, "maximized_horizontal": False, "maximized_vertical": False, }, ] publisher.update(mapper, 41) publisher.update(mapper, 41) publisher.update(mapper, 41) summary = publisher.summary_details() finally: publisher.close() diagnostics.close(**summary) payloads = [ parse_window_diagnostic(line) for line in output.getvalue().splitlines() ] diagnostics_by_name = { payload["diagnostic"]: payload for payload in payloads if payload is not None } self.assertIn("host_minimize_latched", diagnostics_by_name) self.assertIn("host_hidden_state_cleared", diagnostics_by_name) remap = diagnostics_by_name["host_inactive_remap"] details = remap["details"] assert isinstance(details, dict) self.assertTrue(details["minimize_request_accepted"]) self.assertFalse(details["allows_maximize_horizontal"]) self.assertEqual(summary["inactive_remaps"], 1) self.assertEqual(summary["hidden_state_clears"], 1) self.assertEqual(summary["reiconify_accepted"], 1) def test_optimized_wait_refreshes_host_focus_before_returning(self) -> None: process = mock.Mock(pid=1234) process.poll.return_value = 0 mapper = mock.Mock() mapper.find_window_by_pid.return_value = 41 publisher = mock.Mock() self.assertEqual( _wait_for_optimized_gamescope( process, None, host_focus_publisher=publisher, host_mapper=mapper, ), 1, ) publisher.update.assert_called_once_with(mapper, 41) def test_optimized_wait_returns_the_nested_child_status(self) -> None: with tempfile.TemporaryDirectory() as temporary: status_file = Path(temporary) / "status" status_file.write_text("0\n", encoding="ascii") process = mock.Mock() process.poll.return_value = None self.assertEqual(_wait_for_optimized_gamescope(process, status_file), 0) process.terminate.assert_called_once_with() process.kill.assert_not_called() process.wait.assert_called_once_with(timeout=2.0) @mock.patch("ptlinux.gamescope_runtime.subprocess.Popen") @mock.patch( "ptlinux.gamescope_runtime._tune_process_window", return_value=(64, 48), ) @mock.patch("ptlinux.gamescope_runtime._open_host_x11") @mock.patch( "ptlinux.gamescope_runtime._select_host_backend", return_value="sdl", ) def test_x11_path_tunes_the_window_and_preserves_child_status( self, _select_host_backend: mock.Mock, open_host_x11: mock.Mock, tune_process_window: mock.Mock, popen: mock.Mock, ) -> None: mapper = open_host_x11.return_value process = popen.return_value process.poll.return_value = 7 stderr = io.StringIO() with ( mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True), contextlib.redirect_stderr(stderr), mock.patch( "ptlinux.gamescope_runtime._read_child_status", return_value=7, ), ): status = run_gamescope(["gamescope", "--", "game"], 1280, 960) self.assertEqual(status, 7) self.assertEqual( popen.call_args.args[0], [ "gamescope", "--backend", "sdl", "--force-composition", "--", mock.ANY, "-P", "-m", "ptlinux.gamescope_child_runtime", "--status-file", mock.ANY, "--", "game", ], ) self.assertEqual(popen.call_args.kwargs["env"]["SDL_VIDEODRIVER"], "x11") self.assertEqual( popen.call_args.kwargs["env"][ "SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR" ], "0", ) self.assertIn( GAMESCOPE_HOST_FOCUS_STATE_ENV, popen.call_args.kwargs["env"], ) tune_process_window.assert_called_once_with( mapper, process, 1280, 960, host_focus_publisher=mock.ANY, ) mapper.close.assert_called_once_with() self.assertIn("64x48", stderr.getvalue()) @mock.patch("ptlinux.gamescope_runtime.subprocess.Popen") @mock.patch("ptlinux.gamescope_runtime._open_host_x11", return_value=None) @mock.patch( "ptlinux.gamescope_runtime._select_host_backend", return_value="sdl", ) def test_x11_path_disables_nested_focus_repair_without_host_monitor( self, _select_host_backend: mock.Mock, _open_host_x11: mock.Mock, popen: mock.Mock, ) -> None: process = popen.return_value process.poll.return_value = 0 with ( mock.patch.dict(os.environ, {"DISPLAY": ":0"}, clear=True), contextlib.redirect_stderr(io.StringIO()), mock.patch( "ptlinux.gamescope_runtime._read_child_status", return_value=0, ), ): self.assertEqual( run_gamescope(["gamescope", "--", "game"], 1280, 960), 0, ) self.assertEqual( popen.call_args.kwargs["env"][GAMESCOPE_HOST_FOCUS_STATE_ENV], "unavailable", ) @mock.patch("ptlinux.gamescope_runtime.subprocess.Popen") @mock.patch("ptlinux.gamescope_runtime._open_host_x11") @mock.patch( "ptlinux.gamescope_runtime._select_wayland_gamescope", return_value=("/opt/ptlinux/gamescope", False, True), ) @mock.patch( "ptlinux.gamescope_runtime._select_host_backend", return_value="wayland", ) def test_wayland_path_avoids_sdl_and_preserves_child_status( self, _select_host_backend: mock.Mock, select_wayland_gamescope: mock.Mock, open_host_x11: mock.Mock, popen: mock.Mock, ) -> None: process = popen.return_value process.poll.return_value = 0 stderr = io.StringIO() with ( mock.patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=True), contextlib.redirect_stderr(stderr), mock.patch( "ptlinux.gamescope_runtime._read_child_status", return_value=0, ), ): status = run_gamescope(["gamescope", "--", "game"], 1280, 960) self.assertEqual(status, 0) self.assertEqual( popen.call_args.args[0], [ "/opt/ptlinux/gamescope", "--backend", "wayland", "--", mock.ANY, "-P", "-m", "ptlinux.gamescope_child_runtime", "--status-file", mock.ANY, "--", "game", ], ) self.assertNotIn("SDL_VIDEODRIVER", popen.call_args.kwargs["env"]) self.assertNotIn( "SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR", popen.call_args.kwargs["env"], ) self.assertEqual( popen.call_args.kwargs["env"][ "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE" ], "1", ) open_host_x11.assert_not_called() select_wayland_gamescope.assert_called_once_with( "gamescope", environment=mock.ANY, ) self.assertIn("native Wayland", stderr.getvalue()) self.assertIn("lazy-output", stderr.getvalue()) self.assertIn("login-window visibility", stderr.getvalue()) @mock.patch("ptlinux.gamescope_runtime.subprocess.Popen") @mock.patch( "ptlinux.gamescope_runtime._tune_process_window", return_value=(64, 48), ) @mock.patch("ptlinux.gamescope_runtime._open_host_x11") @mock.patch( "ptlinux.gamescope_runtime._select_host_backend", return_value="sdl", ) def test_kubuntu_wayland_uses_xwayland_presentation_and_reports_policy( self, _select_host_backend: mock.Mock, open_host_x11: mock.Mock, tune_process_window: mock.Mock, popen: mock.Mock, ) -> None: process = popen.return_value process.poll.return_value = 0 environment = { "WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ":1", "XDG_CURRENT_DESKTOP": "KDE", "DESKTOP_SESSION": "plasmawayland", WINDOW_DIAGNOSTICS_ENV: "1", WINDOW_DIAGNOSTICS_SESSION_ENV: "c" * 32, } stderr = io.StringIO() with ( mock.patch.dict(os.environ, environment, clear=True), mock.patch( "ptlinux.gamescope_runtime._read_child_status", return_value=0, ), contextlib.redirect_stderr(stderr), ): status = run_gamescope(["gamescope", "--", "game"], 1280, 960) self.assertEqual(status, 0) self.assertEqual( popen.call_args.args[0][:4], [ "gamescope", "--backend", "sdl", "--force-composition", ], ) self.assertNotIn( "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE", popen.call_args.kwargs["env"], ) open_host_x11.assert_called_once_with() tune_process_window.assert_called_once() self.assertIn("KDE Wayland compatibility mode active", stderr.getvalue()) payloads = [ parse_window_diagnostic(line) for line in stderr.getvalue().splitlines() ] runtime_configured = next( payload for payload in payloads if payload is not None and payload["diagnostic"] == "runtime_configured" ) details = runtime_configured["details"] assert isinstance(details, dict) self.assertTrue(details["kde_session"]) self.assertTrue(details["kde_wayland_compatibility_mode"]) self.assertTrue(details["kde_wayland_xwayland_fallback"]) self.assertEqual( details["backend_reason"], "kde_wayland_xwayland_compatibility", ) self.assertEqual(details["presentation_mode"], "sdl_composed") def test_main_reports_an_empty_command(self) -> None: stderr = io.StringIO() with contextlib.redirect_stderr(stderr): status = main(["--width", "1280", "--height", "960"]) self.assertEqual(status, 1) self.assertIn("cannot be empty", stderr.getvalue()) if __name__ == "__main__": unittest.main()