Fixed issues with gamescope not properly launching.
This commit is contained in:
@@ -162,6 +162,7 @@ if [[ ! -d "${SOURCE_ROOT}/ptlinux" \
|
|||||||
|| ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \
|
|| ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \
|
||||||
|| ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \
|
|| ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \
|
||||||
|| ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \
|
|| ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \
|
||||||
|
|| ! -f "${SOURCE_ROOT}/ptlinux/native/gamescope-keep-nested-windows-visible.patch" \
|
||||||
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|
||||||
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|
||||||
|| ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \
|
|| ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \
|
||||||
@@ -254,6 +255,7 @@ install -m 0644 \
|
|||||||
"${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \
|
"${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-live-resize.patch" \
|
||||||
"${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \
|
"${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-letterbox-backing.patch" \
|
||||||
"${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \
|
"${SOURCE_ROOT}/ptlinux/native/gamescope-wayland-libdecor-input-scope.patch" \
|
||||||
|
"${SOURCE_ROOT}/ptlinux/native/gamescope-keep-nested-windows-visible.patch" \
|
||||||
"${STAGING_RELEASE}/ptlinux/native/"
|
"${STAGING_RELEASE}/ptlinux/native/"
|
||||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py"
|
install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py"
|
||||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py"
|
install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py"
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ from .launcher import (
|
|||||||
launch_official_updater,
|
launch_official_updater,
|
||||||
prepare_launcher_binary,
|
prepare_launcher_binary,
|
||||||
)
|
)
|
||||||
from .locking import LauncherPlayLease, launcher_operation_lock, launcher_play_lock
|
from .locking import (
|
||||||
|
LauncherPlayLease,
|
||||||
|
launcher_operation_lock,
|
||||||
|
launcher_play_lock,
|
||||||
|
managed_game_process_is_active,
|
||||||
|
)
|
||||||
from .login_diagnostics import inspect_login_environment
|
from .login_diagnostics import inspect_login_environment
|
||||||
from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
|
from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
|
||||||
from .overlay import cache_compatibility_binaries, prepare_runtime_view
|
from .overlay import cache_compatibility_binaries, prepare_runtime_view
|
||||||
@@ -855,6 +860,7 @@ def _play(
|
|||||||
realm=realm,
|
realm=realm,
|
||||||
reuse_prepared=not play_lease.preparation_allowed,
|
reuse_prepared=not play_lease.preparation_allowed,
|
||||||
before_game=play_lease.allow_shared_clients,
|
before_game=play_lease.allow_shared_clients,
|
||||||
|
game_lease_fds=play_lease.inherited_fds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -869,6 +875,7 @@ def _play_locked(
|
|||||||
realm: str | None = None,
|
realm: str | None = None,
|
||||||
reuse_prepared: bool = False,
|
reuse_prepared: bool = False,
|
||||||
before_game: Callable[[], None] | None = None,
|
before_game: Callable[[], None] | None = None,
|
||||||
|
game_lease_fds: Sequence[int] = (),
|
||||||
) -> int:
|
) -> int:
|
||||||
if game_dir is None and game_import_requires_verification(paths):
|
if game_dir is None and game_import_requires_verification(paths):
|
||||||
raise LauncherError(
|
raise LauncherError(
|
||||||
@@ -980,6 +987,7 @@ def _play_locked(
|
|||||||
use_gamebox=not no_box,
|
use_gamebox=not no_box,
|
||||||
concurrent_prefix_launch=reuse_prepared,
|
concurrent_prefix_launch=reuse_prepared,
|
||||||
check=False,
|
check=False,
|
||||||
|
pass_fds=game_lease_fds,
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
current_game_log = _current_game_log(
|
current_game_log = _current_game_log(
|
||||||
@@ -1505,7 +1513,10 @@ def _play_guard(
|
|||||||
) -> AbstractContextManager[LauncherPlayLease]:
|
) -> AbstractContextManager[LauncherPlayLease]:
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return nullcontext(LauncherPlayLease.dry_run())
|
return nullcontext(LauncherPlayLease.dry_run())
|
||||||
return launcher_play_lock(paths.launcher_operation_lock)
|
return launcher_play_lock(
|
||||||
|
paths.launcher_operation_lock,
|
||||||
|
active_game_probe=lambda: _managed_game_process_is_active(paths),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _operation_guard(
|
def _operation_guard(
|
||||||
@@ -1515,7 +1526,18 @@ def _operation_guard(
|
|||||||
) -> AbstractContextManager[None]:
|
) -> AbstractContextManager[None]:
|
||||||
if dry_run:
|
if dry_run:
|
||||||
return nullcontext()
|
return nullcontext()
|
||||||
return launcher_operation_lock(paths.launcher_operation_lock, operation)
|
return launcher_operation_lock(
|
||||||
|
paths.launcher_operation_lock,
|
||||||
|
operation,
|
||||||
|
active_game_probe=lambda: _managed_game_process_is_active(paths),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _managed_game_process_is_active(paths: AppPaths) -> bool:
|
||||||
|
return managed_game_process_is_active(
|
||||||
|
paths.runtime_view,
|
||||||
|
(paths.proton_prefix, paths.umu_prefix),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _emit(value: object, as_json: bool) -> None:
|
def _emit(value: object, as_json: bool) -> None:
|
||||||
|
|||||||
@@ -99,10 +99,14 @@ class CommandRunner:
|
|||||||
check: bool = True,
|
check: bool = True,
|
||||||
stream_output: bool = False,
|
stream_output: bool = False,
|
||||||
output_line_filter: OutputLineFilter | None = None,
|
output_line_filter: OutputLineFilter | None = None,
|
||||||
|
pass_fds: Sequence[int] = (),
|
||||||
) -> CommandOutcome:
|
) -> CommandOutcome:
|
||||||
arguments = [str(value) for value in argv]
|
arguments = [str(value) for value in argv]
|
||||||
if not arguments:
|
if not arguments:
|
||||||
raise ValueError("command argv cannot be empty")
|
raise ValueError("command argv cannot be empty")
|
||||||
|
inherited_fds = tuple(dict.fromkeys(pass_fds))
|
||||||
|
if any(not isinstance(value, int) or value < 0 for value in inherited_fds):
|
||||||
|
raise ValueError("inherited file descriptors must be non-negative integers")
|
||||||
overrides = dict(env_overrides or {})
|
overrides = dict(env_overrides or {})
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
@@ -128,6 +132,7 @@ class CommandRunner:
|
|||||||
timeout_seconds=timeout_seconds,
|
timeout_seconds=timeout_seconds,
|
||||||
started=started,
|
started=started,
|
||||||
output_line_filter=output_line_filter,
|
output_line_filter=output_line_filter,
|
||||||
|
pass_fds=inherited_fds,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -142,6 +147,7 @@ class CommandRunner:
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
timeout=timeout_seconds,
|
timeout=timeout_seconds,
|
||||||
check=False,
|
check=False,
|
||||||
|
pass_fds=inherited_fds,
|
||||||
)
|
)
|
||||||
outcome = CommandOutcome(
|
outcome = CommandOutcome(
|
||||||
argv=arguments,
|
argv=arguments,
|
||||||
@@ -176,6 +182,7 @@ class CommandRunner:
|
|||||||
timeout_seconds: float,
|
timeout_seconds: float,
|
||||||
started: float,
|
started: float,
|
||||||
output_line_filter: OutputLineFilter | None,
|
output_line_filter: OutputLineFilter | None,
|
||||||
|
pass_fds: tuple[int, ...],
|
||||||
) -> CommandOutcome:
|
) -> CommandOutcome:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
arguments,
|
arguments,
|
||||||
@@ -188,6 +195,7 @@ class CommandRunner:
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
|
pass_fds=pass_fds,
|
||||||
)
|
)
|
||||||
stdout = BoundedTextCapture()
|
stdout = BoundedTextCapture()
|
||||||
stderr = BoundedTextCapture()
|
stderr = BoundedTextCapture()
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ _X_PROPERTY_NOTIFY = 28
|
|||||||
_X_PROPERTY_CHANGE_MASK = 1 << 22
|
_X_PROPERTY_CHANGE_MASK = 1 << 22
|
||||||
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
|
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
|
||||||
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
|
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
|
||||||
|
_X_WM_STATE_WITHDRAWN = 0
|
||||||
|
_X_WM_STATE_NORMAL = 1
|
||||||
_X_WM_STATE_ICONIC = 3
|
_X_WM_STATE_ICONIC = 3
|
||||||
_NET_WM_STATE_REMOVE = 0
|
_NET_WM_STATE_REMOVE = 0
|
||||||
_NET_WM_STATE_SOURCE_APPLICATION = 1
|
_NET_WM_STATE_SOURCE_APPLICATION = 1
|
||||||
@@ -448,6 +450,18 @@ class _X11:
|
|||||||
return None
|
return None
|
||||||
return values[0]
|
return values[0]
|
||||||
|
|
||||||
|
def restore_normal_window_state(self, window: int) -> bool:
|
||||||
|
"""Undo a nested iconify request without raising the host window."""
|
||||||
|
|
||||||
|
self._replace_long_property(
|
||||||
|
window,
|
||||||
|
b"WM_STATE",
|
||||||
|
b"WM_STATE",
|
||||||
|
(_X_WM_STATE_NORMAL, 0),
|
||||||
|
)
|
||||||
|
self.library.XSync(self.display, 0)
|
||||||
|
return self.window_state(window) == _X_WM_STATE_NORMAL
|
||||||
|
|
||||||
def window_is_borderless(self, window: int) -> bool | None:
|
def window_is_borderless(self, window: int) -> bool | None:
|
||||||
"""Return whether the client currently requests no Motif decorations."""
|
"""Return whether the client currently requests no Motif decorations."""
|
||||||
|
|
||||||
@@ -1463,6 +1477,54 @@ def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_gamescope_game_window(
|
||||||
|
mapper: _X11,
|
||||||
|
window: int,
|
||||||
|
*,
|
||||||
|
window_title: str,
|
||||||
|
excluded_window_ids: Collection[int],
|
||||||
|
) -> int | None:
|
||||||
|
"""Return a valid nested client across Wine's login-window transition."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = mapper.window_state(window)
|
||||||
|
except Exception:
|
||||||
|
state = None
|
||||||
|
if state == _X_WM_STATE_ICONIC:
|
||||||
|
try:
|
||||||
|
if not mapper.restore_normal_window_state(window):
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
print(
|
||||||
|
"Game login window requested minimization; kept it visible inside "
|
||||||
|
"Gamescope.",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return window
|
||||||
|
if state is not None and state != _X_WM_STATE_WITHDRAWN:
|
||||||
|
return window
|
||||||
|
|
||||||
|
try:
|
||||||
|
found = mapper.find_first_window(
|
||||||
|
window_title,
|
||||||
|
excluded_window_ids=set(excluded_window_ids) | {window},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if found is None:
|
||||||
|
return None
|
||||||
|
replacement, title = found
|
||||||
|
_apply_game_window_identity(mapper, replacement)
|
||||||
|
print(
|
||||||
|
f"Game login window changed; following it inside Gamescope: {title}",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return replacement
|
||||||
|
|
||||||
|
|
||||||
def _enable_game_window_scaling(mapper: _X11, window: int) -> bool:
|
def _enable_game_window_scaling(mapper: _X11, window: int) -> bool:
|
||||||
"""Enable confirmed resize/maximize actions without risking game startup."""
|
"""Enable confirmed resize/maximize actions without risking game startup."""
|
||||||
|
|
||||||
@@ -1587,9 +1649,14 @@ def _maintain_gamescope_game_window_focus(
|
|||||||
mapper: _X11,
|
mapper: _X11,
|
||||||
process: subprocess.Popen[bytes],
|
process: subprocess.Popen[bytes],
|
||||||
window: int,
|
window: int,
|
||||||
|
*,
|
||||||
|
window_title: str = WINDOW_TITLE,
|
||||||
|
existing_window_ids: Collection[int] = (),
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Restore nested keyboard focus after host resize/maximize operations."""
|
"""Keep a valid, focused nested client through login and host resizing."""
|
||||||
|
|
||||||
|
active_window: int = window
|
||||||
|
retired_window_ids = set(existing_window_ids)
|
||||||
next_focus_repair_at = (
|
next_focus_repair_at = (
|
||||||
time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS
|
time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS
|
||||||
)
|
)
|
||||||
@@ -1598,7 +1665,22 @@ def _maintain_gamescope_game_window_focus(
|
|||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now < next_focus_repair_at:
|
if now < next_focus_repair_at:
|
||||||
continue
|
continue
|
||||||
_repair_gamescope_game_window_focus(mapper, window)
|
resolved_window = _resolve_gamescope_game_window(
|
||||||
|
mapper,
|
||||||
|
active_window,
|
||||||
|
window_title=window_title,
|
||||||
|
excluded_window_ids=retired_window_ids,
|
||||||
|
)
|
||||||
|
if resolved_window is None:
|
||||||
|
next_focus_repair_at = (
|
||||||
|
time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
resolved_window_id = resolved_window
|
||||||
|
if resolved_window_id != active_window:
|
||||||
|
retired_window_ids.add(active_window)
|
||||||
|
active_window = resolved_window_id
|
||||||
|
_repair_gamescope_game_window_focus(mapper, active_window)
|
||||||
next_focus_repair_at = (
|
next_focus_repair_at = (
|
||||||
time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS
|
time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS
|
||||||
)
|
)
|
||||||
@@ -1785,6 +1867,8 @@ def run_game_window(
|
|||||||
mapper,
|
mapper,
|
||||||
process,
|
process,
|
||||||
game_window,
|
game_window,
|
||||||
|
window_title=window_title,
|
||||||
|
existing_window_ids=existing_window_ids,
|
||||||
)
|
)
|
||||||
_prepare_game_window(
|
_prepare_game_window(
|
||||||
mapper,
|
mapper,
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ WAYLAND_BACKING_FIX_VERSION = (3, 16, 28)
|
|||||||
BUNDLED_GAMESCOPE_VERSION = (3, 16, 20)
|
BUNDLED_GAMESCOPE_VERSION = (3, 16, 20)
|
||||||
BUNDLED_GAMESCOPE_FILENAME = "gamescope-3.16.20-ubuntu26.04-amd64"
|
BUNDLED_GAMESCOPE_FILENAME = "gamescope-3.16.20-ubuntu26.04-amd64"
|
||||||
BUNDLED_GAMESCOPE_SHA256 = (
|
BUNDLED_GAMESCOPE_SHA256 = (
|
||||||
"d4a082d8db0c467f65b429b6fb339b56b9a0d3e17e9c1957ede9ba1cb9a3ec90"
|
"3fc81536c22dcce2c593c11507a612c13221f5b2ce2e5326f7c63706ca965639"
|
||||||
)
|
)
|
||||||
BUNDLED_GAMESCOPE_OS_ID = "ubuntu"
|
BUNDLED_GAMESCOPE_OS_ID = "ubuntu"
|
||||||
BUNDLED_GAMESCOPE_OS_VERSION = "26.04"
|
BUNDLED_GAMESCOPE_OS_VERSION = "26.04"
|
||||||
|
KEEP_NESTED_WINDOWS_VISIBLE_ENV = "PTLINUX_GAMESCOPE_KEEP_WINDOWS_VISIBLE"
|
||||||
HASH_READ_SIZE = 256 * 1024
|
HASH_READ_SIZE = 256 * 1024
|
||||||
_GAMESCOPE_VERSION_PATTERN = re.compile(
|
_GAMESCOPE_VERSION_PATTERN = re.compile(
|
||||||
r"\bgamescope\s+version\s+(\d+)\.(\d+)\.(\d+)",
|
r"\bgamescope\s+version\s+(\d+)\.(\d+)\.(\d+)",
|
||||||
@@ -357,6 +358,11 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
|||||||
# SDL otherwise requests compositor bypass, which can switch near
|
# SDL otherwise requests compositor bypass, which can switch near
|
||||||
# screen-filling sizes and visibly flicker while Gamescope is scaling.
|
# screen-filling sizes and visibly flicker while Gamescope is scaling.
|
||||||
environment["SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"] = "0"
|
environment["SDL_VIDEO_X11_NET_WM_BYPASS_COMPOSITOR"] = "0"
|
||||||
|
if using_bundled_gamescope:
|
||||||
|
# The game asks its X11 WM to iconify the first client while entering
|
||||||
|
# the login screen. Our scoped Gamescope patch keeps that nested surface
|
||||||
|
# visible without activating or raising the host window.
|
||||||
|
environment[KEEP_NESTED_WINDOWS_VISIBLE_ENV] = "1"
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(launch_command, env=environment)
|
process = subprocess.Popen(launch_command, env=environment)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
@@ -374,7 +380,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int:
|
|||||||
if using_bundled_gamescope:
|
if using_bundled_gamescope:
|
||||||
message += (
|
message += (
|
||||||
" Launcher-local lazy-output, live-resize, letterbox backing, "
|
" Launcher-local lazy-output, live-resize, letterbox backing, "
|
||||||
"and decoration-input fixes are active."
|
"decoration-input, and login-window visibility fixes are active."
|
||||||
)
|
)
|
||||||
print(message, file=sys.stderr, flush=True)
|
print(message, file=sys.stderr, flush=True)
|
||||||
elif backend == "wayland":
|
elif backend == "wayland":
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ from datetime import datetime
|
|||||||
import fcntl
|
import fcntl
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator, TextIO
|
from typing import Callable, Iterator, Sequence, TextIO
|
||||||
|
|
||||||
|
|
||||||
|
ActiveGameProbe = Callable[[], bool]
|
||||||
|
MAX_PROCESS_ENVIRONMENT_BYTES = 1024 * 1024
|
||||||
|
MAX_PROCESS_COMMAND_BYTES = 128 * 1024
|
||||||
|
|
||||||
|
|
||||||
class OperationBusyError(RuntimeError):
|
class OperationBusyError(RuntimeError):
|
||||||
@@ -51,9 +56,22 @@ class LauncherPlayLease:
|
|||||||
def record_owned(self) -> bool:
|
def record_owned(self) -> bool:
|
||||||
return self._record_owned
|
return self._record_owned
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inherited_fds(self) -> tuple[int, ...]:
|
||||||
|
"""Keep the shared lease alive in the launched game supervisor."""
|
||||||
|
|
||||||
|
if self._handle is None:
|
||||||
|
return ()
|
||||||
|
return (self._handle.fileno(),)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
def launcher_operation_lock(
|
||||||
|
lock_path: Path,
|
||||||
|
operation: str,
|
||||||
|
*,
|
||||||
|
active_game_probe: ActiveGameProbe | None = None,
|
||||||
|
) -> Iterator[None]:
|
||||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with lock_path.open("a+", encoding="utf-8") as handle:
|
with lock_path.open("a+", encoding="utf-8") as handle:
|
||||||
try:
|
try:
|
||||||
@@ -64,6 +82,11 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
|||||||
raise OperationBusyError(
|
raise OperationBusyError(
|
||||||
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
||||||
) from exc
|
) from exc
|
||||||
|
if active_game_probe is not None and active_game_probe():
|
||||||
|
raise OperationBusyError(
|
||||||
|
"launcher files are busy (active managed game client detected); "
|
||||||
|
"close every game client and try again"
|
||||||
|
)
|
||||||
_write_owner(handle, operation)
|
_write_owner(handle, operation)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -78,6 +101,8 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
|||||||
def launcher_play_lock(
|
def launcher_play_lock(
|
||||||
lock_path: Path,
|
lock_path: Path,
|
||||||
operation: str = "play",
|
operation: str = "play",
|
||||||
|
*,
|
||||||
|
active_game_probe: ActiveGameProbe | None = None,
|
||||||
) -> Iterator[LauncherPlayLease]:
|
) -> Iterator[LauncherPlayLease]:
|
||||||
"""Serialize preparation, then allow multiple active game clients.
|
"""Serialize preparation, then allow multiple active game clients.
|
||||||
|
|
||||||
@@ -104,6 +129,18 @@ def launcher_play_lock(
|
|||||||
preparation_allowed=False,
|
preparation_allowed=False,
|
||||||
record_owned=False,
|
record_owned=False,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
if active_game_probe is not None and active_game_probe():
|
||||||
|
# A game from an older launcher may have outlived its Python
|
||||||
|
# supervisor and therefore its inherited lock. Rejoin that live
|
||||||
|
# prefix without mutating its runtime view or waiting for it to exit.
|
||||||
|
_write_owner(handle, operation)
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_SH)
|
||||||
|
lease = LauncherPlayLease(
|
||||||
|
handle,
|
||||||
|
preparation_allowed=False,
|
||||||
|
record_owned=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
_write_owner(handle, f"{operation} preparation")
|
_write_owner(handle, f"{operation} preparation")
|
||||||
lease = LauncherPlayLease(
|
lease = LauncherPlayLease(
|
||||||
@@ -123,6 +160,79 @@ def launcher_play_lock(
|
|||||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def managed_game_process_is_active(
|
||||||
|
runtime_view: Path,
|
||||||
|
wine_prefixes: Sequence[Path],
|
||||||
|
*,
|
||||||
|
process_root: Path = Path("/proc"),
|
||||||
|
) -> bool:
|
||||||
|
"""Detect a managed Wine game that outlived its launcher supervisor."""
|
||||||
|
|
||||||
|
prefixes = {_normalized_path(prefix) for prefix in wine_prefixes}
|
||||||
|
prefixes.discard("")
|
||||||
|
if not prefixes:
|
||||||
|
return False
|
||||||
|
runtime_marker = _normalized_path(runtime_view).casefold().replace("\\", "/")
|
||||||
|
if not runtime_marker:
|
||||||
|
return False
|
||||||
|
runtime_marker = runtime_marker.rstrip("/") + "/"
|
||||||
|
try:
|
||||||
|
processes = tuple(process_root.iterdir())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
for process in processes:
|
||||||
|
if not process.name.isdecimal():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
name = (process / "comm").read_text(
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
).strip().casefold()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if not name.endswith(".exe"):
|
||||||
|
continue
|
||||||
|
environment = _read_bounded(process / "environ", MAX_PROCESS_ENVIRONMENT_BYTES)
|
||||||
|
if environment is None:
|
||||||
|
continue
|
||||||
|
wine_prefix = next(
|
||||||
|
(
|
||||||
|
entry.removeprefix(b"WINEPREFIX=")
|
||||||
|
for entry in environment.split(b"\0")
|
||||||
|
if entry.startswith(b"WINEPREFIX=")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
wine_prefix is None
|
||||||
|
or _normalized_path(os.fsdecode(wine_prefix)) not in prefixes
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
command = _read_bounded(process / "cmdline", MAX_PROCESS_COMMAND_BYTES)
|
||||||
|
if command is None:
|
||||||
|
continue
|
||||||
|
normalized_command = os.fsdecode(command).casefold().replace("\\", "/")
|
||||||
|
if runtime_marker in normalized_command:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bounded(path: Path, maximum_bytes: int) -> bytes | None:
|
||||||
|
try:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
value = handle.read(maximum_bytes + 1)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return value if len(value) <= maximum_bytes else None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_path(path: Path | str) -> str:
|
||||||
|
value = str(path)
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
return str(Path(value).expanduser().resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
def _read_owner(handle: TextIO) -> str:
|
def _read_owner(handle: TextIO) -> str:
|
||||||
handle.seek(0)
|
handle.seek(0)
|
||||||
return handle.read().strip() or "unknown operation"
|
return handle.read().strip() or "unknown operation"
|
||||||
|
|||||||
@@ -406,6 +406,7 @@ def run_game(
|
|||||||
check: bool = True,
|
check: bool = True,
|
||||||
extra_environment: Mapping[str, str] | None = None,
|
extra_environment: Mapping[str, str] | None = None,
|
||||||
output_line_filter: OutputLineFilter | None = None,
|
output_line_filter: OutputLineFilter | None = None,
|
||||||
|
pass_fds: Sequence[int] = (),
|
||||||
) -> CommandOutcome:
|
) -> CommandOutcome:
|
||||||
_require_container_host(selection, runner)
|
_require_container_host(selection, runner)
|
||||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||||
@@ -502,6 +503,7 @@ def run_game(
|
|||||||
check=check,
|
check=check,
|
||||||
stream_output=True,
|
stream_output=True,
|
||||||
output_line_filter=output_line_filter,
|
output_line_filter=output_line_filter,
|
||||||
|
pass_fds=pass_fds,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"archive_sha256": "890443aab4cd0e33bb2e2a44ea86b3fd929488eba045b803c2c0662ce68c08ff",
|
"archive_sha256": "3e00fc6cd72b5ae79adee56642a10b90d410080e67da099292aefcd5cf406681",
|
||||||
"archive_size": 1410277,
|
"archive_size": 1413002,
|
||||||
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.9.5/pristontale-eu-linux-update-v0.9.5.tar.gz",
|
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.9.5/pristontale-eu-linux-update-v0.9.5.tar.gz",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"minimum_updater_version": "0.5.0",
|
"minimum_updater_version": "0.5.0",
|
||||||
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.9.5",
|
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.9.5",
|
||||||
"published_at": "2026-09-06T20:06:31Z",
|
"published_at": "2026-09-06T20:09:16Z",
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"version": "0.9.5"
|
"version": "0.9.5"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user