diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index 28764f1..cc06fcb 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -48,6 +48,7 @@ _NET_WM_STATE_REMOVE = 0 _NET_WM_STATE_SOURCE_APPLICATION = 1 _NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING = 2 WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS = 1.0 +GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS = 0.25 _P_MIN_SIZE = 1 << 4 _P_MAX_SIZE = 1 << 5 _P_RESIZE_INC = 1 << 6 @@ -1556,6 +1557,28 @@ def _maintain_game_window_focus( return int(process.wait()) +def _maintain_gamescope_game_window_focus( + mapper: _X11, + process: subprocess.Popen[bytes], + window: int, +) -> int: + """Restore nested keyboard focus after host resize/maximize operations.""" + + next_focus_repair_at = ( + time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS + ) + while process.poll() is None: + time.sleep(GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS) + now = time.monotonic() + if now < next_focus_repair_at: + continue + _repair_game_window_focus(mapper, window) + next_focus_repair_at = ( + time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS + ) + return int(process.wait()) + + def _guard_game_window_startup( mapper: _X11, process: subprocess.Popen[bytes], @@ -1732,7 +1755,11 @@ def run_game_window( file=sys.stderr, flush=True, ) - return int(process.wait()) + return _maintain_gamescope_game_window_focus( + mapper, + process, + game_window, + ) _prepare_game_window( mapper, game_window, diff --git a/ptlinux/gamescope_runtime.py b/ptlinux/gamescope_runtime.py index 6029f55..ee8d751 100644 --- a/ptlinux/gamescope_runtime.py +++ b/ptlinux/gamescope_runtime.py @@ -146,13 +146,13 @@ def _bundled_gamescope_host_supported() -> bool: def _bundled_gamescope_is_usable() -> bool: - """Verify the launcher-local executable before selecting it.""" + """Verify the launcher-local executable and restore its safe archive mode.""" if not _bundled_gamescope_host_supported(): return False path = _bundled_gamescope_path() try: - if not path.is_file() or not os.access(path, os.X_OK): + if path.is_symlink() or not path.is_file(): return False digest = hashlib.sha256() with path.open("rb") as handle: @@ -160,7 +160,14 @@ def _bundled_gamescope_is_usable() -> bool: digest.update(chunk) except OSError: return False - return digest.hexdigest() == BUNDLED_GAMESCOPE_SHA256 + if digest.hexdigest() != BUNDLED_GAMESCOPE_SHA256: + return False + if not os.access(path, os.X_OK): + try: + path.chmod(0o755) + except OSError: + return False + return os.access(path, os.X_OK) def _select_wayland_gamescope(executable: str) -> tuple[str, bool, bool]: diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py index 19cc872..77f27f8 100644 --- a/ptlinux/self_update.py +++ b/ptlinux/self_update.py @@ -776,6 +776,7 @@ def _stage_and_install_release( raise LauncherUpdateError( f"launcher release destination is not a directory: {destination}" ) + _validate_release_directory(destination) if _tree_digest(destination) != _tree_digest(staging): raise LauncherUpdateError( f"launcher release {manifest.version} already exists with different contents" @@ -916,6 +917,16 @@ def _validate_release_directory(release: Path) -> str: ): if not (release / required).is_file(): raise LauncherUpdateError(f"launcher release is missing {required}") + bundled_gamescope = ( + release + / "ptlinux/native/gamescope-3.16.20-ubuntu26.04-amd64" + ) + if bundled_gamescope.exists(): + mode = bundled_gamescope.stat().st_mode + if not stat.S_ISREG(mode) or not mode & 0o111: + raise LauncherUpdateError( + "launcher release contains a non-executable Gamescope compatibility binary" + ) return version @@ -1019,15 +1030,20 @@ def _archive_matches(path: Path, manifest: UpdateManifest) -> bool: def _tree_digest(root: Path) -> str: digest = hashlib.sha256() + root_mode = root.stat().st_mode + digest.update(b"R\x00" + str(root_mode & 0o777).encode("ascii") + b"\x00") for item in sorted(root.rglob("*"), key=lambda value: value.relative_to(root).as_posix()): relative = item.relative_to(root).as_posix().encode("utf-8") if item.is_symlink(): raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}") - if item.is_dir(): - digest.update(b"D\x00" + relative + b"\x00") - elif item.is_file(): + state = item.stat() + mode = str(state.st_mode & 0o777).encode("ascii") + if stat.S_ISDIR(state.st_mode): + digest.update(b"D\x00" + relative + b"\x00" + mode + b"\x00") + elif stat.S_ISREG(state.st_mode): digest.update(b"F\x00" + relative + b"\x00") - digest.update(str(item.stat().st_size).encode("ascii") + b"\x00") + digest.update(mode + b"\x00") + digest.update(str(state.st_size).encode("ascii") + b"\x00") with item.open("rb") as handle: for chunk in iter(lambda: handle.read(READ_SIZE), b""): digest.update(chunk)