3 Commits

8 changed files with 99 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux # PristonTale EU for Linux
Version 0.9.3 installs, updates, repairs, and launches PristonTale EU through a Version 0.9.5 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not native Linux dashboard. Game files come directly from the publisher and are not
included in this archive. included in this archive.
@@ -15,11 +15,11 @@ Then run:
```text ```text
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
openssl dgst -sha256 -verify launcher-update-public.pem \ openssl dgst -sha256 -verify launcher-update-public.pem \
-signature pristontale-eu-linux-installer-v0-9-3.tar.gz.sig \ -signature pristontale-eu-linux-installer-v0-9-5.tar.gz.sig \
pristontale-eu-linux-installer-v0-9-3.tar.gz pristontale-eu-linux-installer-v0-9-5.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-9-3.tar.gz.sha256 sha256sum -c pristontale-eu-linux-installer-v0-9-5.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-9-3.tar.gz tar -xzf pristontale-eu-linux-installer-v0-9-5.tar.gz
cd pristontale-eu-linux-installer-v0-9-3 cd pristontale-eu-linux-installer-v0-9-5
./install.sh ./install.sh
``` ```

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"] __all__ = ["AppPaths", "__version__"]
__version__ = "0.9.3" __version__ = "0.9.5"

View File

@@ -48,6 +48,7 @@ _NET_WM_STATE_REMOVE = 0
_NET_WM_STATE_SOURCE_APPLICATION = 1 _NET_WM_STATE_SOURCE_APPLICATION = 1
_NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING = 2 _NET_WM_BYPASS_COMPOSITOR_PREFER_COMPOSITING = 2
WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS = 1.0 WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS = 1.0
GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS = 0.25
_P_MIN_SIZE = 1 << 4 _P_MIN_SIZE = 1 << 4
_P_MAX_SIZE = 1 << 5 _P_MAX_SIZE = 1 << 5
_P_RESIZE_INC = 1 << 6 _P_RESIZE_INC = 1 << 6
@@ -971,6 +972,11 @@ class _X11:
"""Raise the mapped game client and give it keyboard focus.""" """Raise the mapped game client and give it keyboard focus."""
self.library.XMapRaised(self.display, window) self.library.XMapRaised(self.display, window)
self.restore_input_focus(window)
def restore_input_focus(self, window: int) -> None:
"""Give a mapped client keyboard focus without raising its host window."""
self.library.XSetInputFocus( self.library.XSetInputFocus(
self.display, self.display,
window, window,
@@ -1436,6 +1442,27 @@ def _repair_game_window_focus(mapper: _X11, window: int) -> bool:
return False return False
def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool:
"""Restore nested focus without asking the desktop to activate Gamescope."""
try:
if mapper.window_is_focused(window):
return True
except Exception:
return False
try:
mapper.restore_input_focus(window)
return mapper.window_is_focused(window)
except Exception as exc:
print(
f"Warning: could not restore nested game input focus ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
return False
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."""
@@ -1556,6 +1583,28 @@ def _maintain_game_window_focus(
return int(process.wait()) 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_gamescope_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( def _guard_game_window_startup(
mapper: _X11, mapper: _X11,
process: subprocess.Popen[bytes], process: subprocess.Popen[bytes],
@@ -1732,7 +1781,11 @@ def run_game_window(
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
return int(process.wait()) return _maintain_gamescope_game_window_focus(
mapper,
process,
game_window,
)
_prepare_game_window( _prepare_game_window(
mapper, mapper,
game_window, game_window,

View File

@@ -146,13 +146,13 @@ def _bundled_gamescope_host_supported() -> bool:
def _bundled_gamescope_is_usable() -> 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(): if not _bundled_gamescope_host_supported():
return False return False
path = _bundled_gamescope_path() path = _bundled_gamescope_path()
try: 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 return False
digest = hashlib.sha256() digest = hashlib.sha256()
with path.open("rb") as handle: with path.open("rb") as handle:
@@ -160,7 +160,14 @@ def _bundled_gamescope_is_usable() -> bool:
digest.update(chunk) digest.update(chunk)
except OSError: except OSError:
return False 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]: def _select_wayland_gamescope(executable: str) -> tuple[str, bool, bool]:

View File

@@ -776,6 +776,7 @@ def _stage_and_install_release(
raise LauncherUpdateError( raise LauncherUpdateError(
f"launcher release destination is not a directory: {destination}" f"launcher release destination is not a directory: {destination}"
) )
_validate_release_directory(destination)
if _tree_digest(destination) != _tree_digest(staging): if _tree_digest(destination) != _tree_digest(staging):
raise LauncherUpdateError( raise LauncherUpdateError(
f"launcher release {manifest.version} already exists with different contents" 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(): if not (release / required).is_file():
raise LauncherUpdateError(f"launcher release is missing {required}") 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 return version
@@ -1019,15 +1030,20 @@ def _archive_matches(path: Path, manifest: UpdateManifest) -> bool:
def _tree_digest(root: Path) -> str: def _tree_digest(root: Path) -> str:
digest = hashlib.sha256() 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()): 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") relative = item.relative_to(root).as_posix().encode("utf-8")
if item.is_symlink(): if item.is_symlink():
raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}") raise LauncherUpdateError(f"launcher release contains a symbolic link: {item}")
if item.is_dir(): state = item.stat()
digest.update(b"D\x00" + relative + b"\x00") mode = str(state.st_mode & 0o777).encode("ascii")
elif item.is_file(): 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(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: with item.open("rb") as handle:
for chunk in iter(lambda: handle.read(READ_SIZE), b""): for chunk in iter(lambda: handle.read(READ_SIZE), b""):
digest.update(chunk) digest.update(chunk)

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "PTEU-Linux-Launcher" name = "PTEU-Linux-Launcher"
version = "0.9.3" version = "0.9.5"
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness" description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [] dependencies = []

View File

@@ -1,11 +1,11 @@
{ {
"archive_sha256": "b60a8a3202eb2cbacee1c0accd8ced9b06f8feaf0ac19b2501fff46b221630b2", "archive_sha256": "890443aab4cd0e33bb2e2a44ea86b3fd929488eba045b803c2c0662ce68c08ff",
"archive_size": 1409799, "archive_size": 1410277,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.9.3/pristontale-eu-linux-update-v0.9.3.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.3", "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.9.5",
"published_at": "2026-09-06T19:03:48Z", "published_at": "2026-09-06T20:06:31Z",
"schema": 1, "schema": 1,
"version": "0.9.3" "version": "0.9.5"
} }

Binary file not shown.