5 Commits

12 changed files with 926 additions and 66 deletions

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux # PristonTale EU for Linux
Version 0.8.1 installs, updates, repairs, and launches PristonTale EU through a Version 0.8.3 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-8-1.tar.gz.sig \ -signature pristontale-eu-linux-installer-v0-8-3.tar.gz.sig \
pristontale-eu-linux-installer-v0-8-1.tar.gz pristontale-eu-linux-installer-v0-8-3.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-8-1.tar.gz.sha256 sha256sum -c pristontale-eu-linux-installer-v0-8-3.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-8-1.tar.gz tar -xzf pristontale-eu-linux-installer-v0-8-3.tar.gz
cd pristontale-eu-linux-installer-v0-8-1 cd pristontale-eu-linux-installer-v0-8-3
./install.sh ./install.sh
``` ```

View File

@@ -12,17 +12,20 @@ export PTLINUX_APP_HOME="${app_home}"
release_root="" release_root=""
for candidate in "${app_home}/current" "${app_home}/previous" "${app_home}" for candidate in "${app_home}/current" "${app_home}/previous" "${app_home}"
do do
if [ ! -d "${candidate}/ptlinux" ] # Installed releases already pass an isolated import self-check before their
# activation link is changed. Keep only a cheap corruption/fallback check on
# every boot instead of starting Python twice and importing the full CLI twice.
if [ ! -r "${candidate}/ptlinux/__init__.py" ] || \
[ ! -r "${candidate}/ptlinux/__main__.py" ] || \
[ ! -r "${candidate}/ptlinux/cli.py" ] || \
[ ! -r "${candidate}/ptdiag/__init__.py" ] || \
[ ! -r "${candidate}/ptdiag/assbin.py" ] || \
[ ! -r "${candidate}/ptdiag/logs.py" ]
then then
continue continue
fi fi
if "$python_bin" -I -B -c \ release_root="${candidate}"
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \ break
"${candidate}" >/dev/null 2>&1
then
release_root="${candidate}"
break
fi
done done
if [ -z "${release_root}" ] if [ -z "${release_root}" ]

View File

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

View File

@@ -1,4 +1,28 @@
from .cli import main from __future__ import annotations
import sys
def main() -> int:
"""Dispatch the common GUI path without importing every CLI operation."""
arguments = sys.argv[1:]
if not arguments or arguments == ["gui"]:
from .paths import AppPaths
from .ui import run_dashboard
try:
run_dashboard(AppPaths.from_environment())
except KeyboardInterrupt:
return 130
except (ValueError, OSError, RuntimeError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0
from .cli import main as cli_main
return cli_main(arguments)
raise SystemExit(main()) raise SystemExit(main())

View File

@@ -1,4 +1,4 @@
"""Native X11 command construction for the clean borderless game window.""" """Native X11 command construction for the managed game window."""
from __future__ import annotations from __future__ import annotations

View File

@@ -19,6 +19,20 @@ from .desktop_identity import (
WINDOW_TITLE = APPLICATION_NAME WINDOW_TITLE = APPLICATION_NAME
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0 WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
WINDOW_READY_DELAY_SECONDS = 0.5 WINDOW_READY_DELAY_SECONDS = 0.5
WINDOW_BORDERLESS_SETTLE_SECONDS = 0.1
WINDOW_SURFACE_REFRESH_DELAY_SECONDS = 0.15
WINDOW_FOCUS_DELAY_SECONDS = 0.2
WINDOW_STARTUP_GUARD_SECONDS = 12.0
WINDOW_STARTUP_POLL_SECONDS = 0.1
WINDOW_ACTIVATION_DELAY_SECONDS = 0.2
WINDOW_STARTUP_RECOVERY_LIMIT = 3
_X_REVERT_TO_PARENT = 2
_X_CURRENT_TIME = 0
_X_CLIENT_MESSAGE = 33
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
_X_WM_STATE_ICONIC = 3
_MOTIF_HINTS_DECORATIONS = 1 << 1
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) _X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
@@ -78,6 +92,35 @@ class _XClassHint(ctypes.Structure):
] ]
class _XClientMessageData(ctypes.Union):
_fields_ = [
("bytes", ctypes.c_char * 20),
("shorts", ctypes.c_short * 10),
("longs", ctypes.c_long * 5),
]
class _XClientMessageEvent(ctypes.Structure):
_fields_ = [
("type", ctypes.c_int),
("serial", ctypes.c_ulong),
("send_event", ctypes.c_int),
("display", ctypes.c_void_p),
("window", ctypes.c_ulong),
("message_type", ctypes.c_ulong),
("format", ctypes.c_int),
("data", _XClientMessageData),
]
class _XEvent(ctypes.Union):
_fields_ = [
("type", ctypes.c_int),
("client_message", _XClientMessageEvent),
("padding", ctypes.c_long * 24),
]
class _X11: class _X11:
def __init__(self) -> None: def __init__(self) -> None:
library_path = ctypes.util.find_library("X11") or "libX11.so.6" library_path = ctypes.util.find_library("X11") or "libX11.so.6"
@@ -94,6 +137,7 @@ class _X11:
if not self.display: if not self.display:
self._restore_error_handler() self._restore_error_handler()
raise GameboxRuntimeError("could not open the host X11 display") raise GameboxRuntimeError("could not open the host X11 display")
self._atoms: dict[bytes, int] = {}
def close(self) -> None: def close(self) -> None:
try: try:
@@ -117,17 +161,24 @@ class _X11:
title = self._window_title(window) title = self._window_title(window)
if title is not None: if title is not None:
windows.append((window, title)) windows.append((window, title))
return _select_game_window(windows, title_fragment, excluded_window_ids) selected = _select_game_window(windows, title_fragment, excluded_window_ids)
if selected is None:
return None
window, title = selected
return self._client_window(window), title
def _root_windows(self) -> list[int]: def _root_windows(self) -> list[int]:
root = self.library.XDefaultRootWindow(self.display) root = self.library.XDefaultRootWindow(self.display)
return self._child_windows(root)
def _child_windows(self, window: int) -> list[int]:
root_return = ctypes.c_ulong() root_return = ctypes.c_ulong()
parent_return = ctypes.c_ulong() parent_return = ctypes.c_ulong()
children = ctypes.POINTER(ctypes.c_ulong)() children = ctypes.POINTER(ctypes.c_ulong)()
child_count = ctypes.c_uint() child_count = ctypes.c_uint()
status = self.library.XQueryTree( status = self.library.XQueryTree(
self.display, self.display,
root, window,
ctypes.byref(root_return), ctypes.byref(root_return),
ctypes.byref(parent_return), ctypes.byref(parent_return),
ctypes.byref(children), ctypes.byref(children),
@@ -141,11 +192,267 @@ class _X11:
if children: if children:
self.library.XFree(ctypes.cast(children, ctypes.c_void_p)) self.library.XFree(ctypes.cast(children, ctypes.c_void_p))
def map_window(self, window: int, *, borderless: bool) -> None: def _client_window(self, window: int) -> int:
"""Resolve a window-manager frame to the ICCCM client it contains."""
queue = [window]
visited: set[int] = set()
while queue and len(visited) < 64:
candidate = queue.pop(0)
if candidate in visited:
continue
visited.add(candidate)
if self._has_wm_state(candidate):
return candidate
queue.extend(self._child_windows(candidate))
return window
def _intern_atom(self, name: bytes, *, only_if_exists: bool) -> int:
"""Return one atom ID without repeating X-server lookups."""
try:
atoms = self._atoms
except AttributeError:
# Keep helpers usable in isolated tests that construct the mapper without
# opening a real X11 display.
atoms = {}
self._atoms = atoms
cached_atom = atoms.get(name)
if cached_atom is not None:
return cached_atom
atom = int(
self.library.XInternAtom(
self.display,
name,
int(only_if_exists),
)
)
# A missing only-if-exists lookup must remain retryable: a later call may
# create the atom or another client may publish it.
if atom != 0:
atoms[name] = atom
return atom
def _has_wm_state(self, window: int) -> bool:
wm_state = self._intern_atom(b"WM_STATE", only_if_exists=True)
if wm_state == 0:
return False
actual_type = ctypes.c_ulong()
actual_format = ctypes.c_int()
item_count = ctypes.c_ulong()
bytes_after = ctypes.c_ulong()
value = ctypes.POINTER(ctypes.c_ubyte)()
status = self.library.XGetWindowProperty(
self.display,
window,
wm_state,
0,
0,
0,
0,
ctypes.byref(actual_type),
ctypes.byref(actual_format),
ctypes.byref(item_count),
ctypes.byref(bytes_after),
ctypes.byref(value),
)
try:
return status == 0 and actual_type.value != 0
finally:
if value:
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
def window_state(self, window: int) -> int | None:
"""Return the ICCCM WM_STATE value for a managed client."""
values = self._long_property(window, b"WM_STATE", 2)
if not values:
return None
return values[0]
def window_is_borderless(self, window: int) -> bool | None:
"""Return whether the client currently requests no Motif decorations."""
hints = self._long_property(window, b"_MOTIF_WM_HINTS", 5)
if hints is None or len(hints) < 3:
return None
if hints[0] & _MOTIF_HINTS_DECORATIONS == 0:
return False
return hints[2] == 0
def _long_property(
self,
window: int,
name: bytes,
maximum_items: int,
) -> tuple[int, ...] | None:
atom = self._intern_atom(name, only_if_exists=True)
if atom == 0:
return None
actual_type = ctypes.c_ulong()
actual_format = ctypes.c_int()
item_count = ctypes.c_ulong()
bytes_after = ctypes.c_ulong()
value = ctypes.POINTER(ctypes.c_ubyte)()
status = self.library.XGetWindowProperty(
self.display,
window,
atom,
0,
maximum_items,
0,
0,
ctypes.byref(actual_type),
ctypes.byref(actual_format),
ctypes.byref(item_count),
ctypes.byref(bytes_after),
ctypes.byref(value),
)
try:
if (
status != 0
or actual_type.value == 0
or actual_format.value != 32
or not value
):
return None
longs = ctypes.cast(value, ctypes.POINTER(ctypes.c_ulong))
return tuple(
int(longs[index] & 0xFFFFFFFF)
for index in range(min(item_count.value, maximum_items))
)
finally:
if value:
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
def window_size(self, window: int) -> tuple[int, int] | None:
"""Return the current X11 client dimensions."""
root = ctypes.c_ulong()
x = ctypes.c_int()
y = ctypes.c_int()
width = ctypes.c_uint()
height = ctypes.c_uint()
border_width = ctypes.c_uint()
depth = ctypes.c_uint()
if self.library.XGetGeometry(
self.display,
window,
ctypes.byref(root),
ctypes.byref(x),
ctypes.byref(y),
ctypes.byref(width),
ctypes.byref(height),
ctypes.byref(border_width),
ctypes.byref(depth),
) == 0:
return None
return width.value, height.value
def map_window(
self,
window: int,
*,
borderless: bool,
) -> tuple[int, int] | None:
target_size = self.window_size(window) if borderless else None
if borderless: if borderless:
self._remove_decorations(window) self._remove_decorations(window)
self.library.XMapRaised(self.display, window) self.library.XMapRaised(self.display, window)
self.library.XFlush(self.display) self.library.XFlush(self.display)
return target_size
def refresh_render_surface(
self,
window: int,
*,
target_size: tuple[int, int] | None = None,
) -> bool:
"""Nudge the mapped client size once after borderless styling.
Some Wine/DXVK combinations retain the host desktop-sized render surface
after the top-level game window becomes borderless. A one-pixel resize followed
by an exact restore makes Wine reconcile the surface with the real window.
Never iconify or unmap a Wine client for this refresh. Wine can process the
resulting state change after startup and minimize the game at its login screen.
"""
if target_size is None:
target_size = self.window_size(window)
if target_size is None:
return False
original_width, original_height = target_size
if original_width > 1:
nudged_width = original_width - 1
nudged_height = original_height
elif original_height > 1:
nudged_width = original_width
nudged_height = original_height - 1
else:
return False
self.library.XResizeWindow(
self.display,
window,
nudged_width,
nudged_height,
)
self.library.XSync(self.display, 0)
try:
time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS)
finally:
# Always restore the exact starting dimensions if the short wait is
# interrupted. The game remains mapped for the entire operation.
self.library.XResizeWindow(
self.display,
window,
original_width,
original_height,
)
self.library.XSync(self.display, 0)
return True
def focus_window(self, window: int) -> None:
"""Raise the mapped game client and give it keyboard focus."""
self.library.XMapRaised(self.display, window)
self.library.XSetInputFocus(
self.display,
window,
_X_REVERT_TO_PARENT,
_X_CURRENT_TIME,
)
self.library.XSync(self.display, 0)
def activate_window(self, window: int) -> bool:
"""Ask the window manager to restore and activate one client."""
active_window = self._intern_atom(
b"_NET_ACTIVE_WINDOW",
only_if_exists=False,
)
if active_window == 0:
return False
event = _XEvent()
event.client_message.type = _X_CLIENT_MESSAGE
event.client_message.serial = 0
event.client_message.send_event = 1
event.client_message.display = self.display
event.client_message.window = window
event.client_message.message_type = active_window
event.client_message.format = 32
event.client_message.data.longs[0] = 1
event.client_message.data.longs[1] = _X_CURRENT_TIME
status = self.library.XSendEvent(
self.display,
self.library.XDefaultRootWindow(self.display),
0,
_X_SUBSTRUCTURE_NOTIFY_MASK | _X_SUBSTRUCTURE_REDIRECT_MASK,
ctypes.byref(event),
)
self.library.XSync(self.display, 0)
return status != 0
def set_window_identity( def set_window_identity(
self, self,
@@ -176,7 +483,7 @@ class _X11:
def suppress_wm_ping(self, window: int) -> bool: def suppress_wm_ping(self, window: int) -> bool:
"""Stop the WM hang prompt for one window, preserving its other protocols.""" """Stop the WM hang prompt for one window, preserving its other protocols."""
ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0)) ping_atom = self._intern_atom(b"_NET_WM_PING", only_if_exists=False)
if ping_atom == 0: if ping_atom == 0:
return False return False
protocols = self._wm_protocols(window) protocols = self._wm_protocols(window)
@@ -227,7 +534,7 @@ class _X11:
self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p)) self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p))
def _remove_decorations(self, window: int) -> None: def _remove_decorations(self, window: int) -> None:
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0) atom = self._intern_atom(b"_MOTIF_WM_HINTS", only_if_exists=False)
if atom == 0: if atom == 0:
return return
hints = _MotifWmHints( hints = _MotifWmHints(
@@ -304,6 +611,21 @@ class _X11:
ctypes.c_int, ctypes.c_int,
] ]
self.library.XChangeProperty.restype = ctypes.c_int self.library.XChangeProperty.restype = ctypes.c_int
self.library.XGetWindowProperty.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_long,
ctypes.c_long,
ctypes.c_int,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)),
]
self.library.XGetWindowProperty.restype = ctypes.c_int
self.library.XSetClassHint.argtypes = [ self.library.XSetClassHint.argtypes = [
ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_ulong, ctypes.c_ulong,
@@ -326,6 +648,40 @@ class _X11:
self.library.XSetWMProtocols.restype = ctypes.c_int self.library.XSetWMProtocols.restype = ctypes.c_int
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong] self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.library.XMapRaised.restype = ctypes.c_int self.library.XMapRaised.restype = ctypes.c_int
self.library.XGetGeometry.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_uint),
ctypes.POINTER(ctypes.c_uint),
ctypes.POINTER(ctypes.c_uint),
ctypes.POINTER(ctypes.c_uint),
]
self.library.XGetGeometry.restype = ctypes.c_int
self.library.XResizeWindow.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_uint,
ctypes.c_uint,
]
self.library.XResizeWindow.restype = ctypes.c_int
self.library.XSetInputFocus.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_int,
ctypes.c_ulong,
]
self.library.XSetInputFocus.restype = ctypes.c_int
self.library.XSendEvent.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_int,
ctypes.c_long,
ctypes.POINTER(_XEvent),
]
self.library.XSendEvent.restype = ctypes.c_int
self.library.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] self.library.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
self.library.XSync.restype = ctypes.c_int self.library.XSync.restype = ctypes.c_int
self.library.XFlush.argtypes = [ctypes.c_void_p] self.library.XFlush.argtypes = [ctypes.c_void_p]
@@ -367,7 +723,40 @@ def _prepare_game_window(
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
mapper.map_window(window, borderless=borderless) target_size = mapper.map_window(window, borderless=borderless)
if not borderless:
return
if target_size is not None:
time.sleep(WINDOW_BORDERLESS_SETTLE_SECONDS)
try:
if target_size is None:
refreshed = mapper.refresh_render_surface(window)
else:
refreshed = mapper.refresh_render_surface(
window,
target_size=target_size,
)
except Exception as exc:
print(
f"Warning: could not synchronize the borderless game surface ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
else:
if refreshed:
print(
"Borderless game surface synchronized with its window.",
file=sys.stderr,
flush=True,
)
else:
print(
"Warning: the window manager declined the borderless surface "
"refresh; continuing normally",
file=sys.stderr,
flush=True,
)
def _apply_game_window_identity(mapper: _X11, window: int) -> None: def _apply_game_window_identity(mapper: _X11, window: int) -> None:
@@ -382,6 +771,107 @@ def _apply_game_window_identity(mapper: _X11, window: int) -> None:
) )
def _focus_game_window(mapper: _X11, window: int) -> None:
"""Let the dashboard process its ready event before focusing the game."""
time.sleep(WINDOW_FOCUS_DELAY_SECONDS)
try:
mapper.focus_window(window)
except Exception as exc:
print(
f"Warning: could not focus the game window ({exc}); continuing normally",
file=sys.stderr,
flush=True,
)
def _guard_game_window_startup(
mapper: _X11,
process: subprocess.Popen[bytes],
window: int,
*,
borderless: bool,
) -> None:
"""Recover Wine's login-screen window transition without hiding the client."""
deadline = time.monotonic() + WINDOW_STARTUP_GUARD_SECONDS
try:
initial_borderless = (
mapper.window_is_borderless(window) if borderless else None
)
# A readable decorated value can already be Wine's fast login reset. The
# initial preparation immediately before this guard requested borderless.
borderless_was_applied = borderless and initial_borderless is not None
except Exception as exc:
print(
f"Warning: could not monitor the game startup window ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
return
recovery_count = 0
while process.poll() is None and time.monotonic() < deadline:
try:
state = mapper.window_state(window)
borderless_now = (
mapper.window_is_borderless(window) if borderless else None
)
except Exception as exc:
print(
f"Warning: could not inspect the game startup window ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
return
became_iconic = state == _X_WM_STATE_ICONIC
login_style_reset = borderless_was_applied and borderless_now is False
if not became_iconic and not login_style_reset:
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
continue
recovery_count += 1
reason = "was minimized" if became_iconic else "changed its window layout"
print(
f"Game startup window {reason}; restoring and refocusing it.",
file=sys.stderr,
flush=True,
)
try:
mapper.activate_window(window)
time.sleep(WINDOW_ACTIVATION_DELAY_SECONDS)
_prepare_game_window(
mapper,
window,
borderless=borderless,
suppress_wm_ping=False,
)
mapper.activate_window(window)
mapper.focus_window(window)
if borderless:
borderless_was_applied = (
mapper.window_is_borderless(window) is True
)
except Exception as exc:
print(
f"Warning: could not recover the game startup window ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
return
if recovery_count >= WINDOW_STARTUP_RECOVERY_LIMIT:
print(
"Warning: the game repeatedly changed its startup window; "
"stopped automatic recovery.",
file=sys.stderr,
flush=True,
)
return
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
def run_game_window( def run_game_window(
command: Sequence[str], command: Sequence[str],
window_title: str = WINDOW_TITLE, window_title: str = WINDOW_TITLE,
@@ -450,6 +940,13 @@ def run_game_window(
file=sys.stderr, file=sys.stderr,
flush=True, flush=True,
) )
_focus_game_window(mapper, game_window)
_guard_game_window_startup(
mapper,
process,
game_window,
borderless=borderless,
)
return int(process.wait()) return int(process.wait())
time.sleep(0.1) time.sleep(0.1)
if process.poll() is None: if process.poll() is None:

View File

@@ -44,15 +44,46 @@ PATCHED_V21_GAME_DLL_SHA256 = (
RETIRED_POST_AUTH_GAME_DLL_SHA256 = ( RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd" "93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
) )
RUNTIME_VIEW_CONFIGURATION_VERSION = 1 RUNTIME_VIEW_CONFIGURATION_VERSION = 2
class OverlayError(RuntimeError): class OverlayError(RuntimeError):
"""Raised when a safe runtime view cannot be built.""" """Raised when a safe runtime view cannot be built."""
@dataclass(frozen=True)
class RuntimeFileIdentity:
"""Cheap change detector for one previously hash-verified runtime input."""
name: str
device: int
inode: int
mode: int
size: int
mtime_ns: int
ctime_ns: int
@dataclass(frozen=True) @dataclass(frozen=True)
class OverlayResult: class OverlayResult:
source_game: str
runtime_view: str
source_scene_sha256: str
runtime_scene_sha256: str
scene_action: str
binary_action: str
game_exe_sha256: str
game_dll_sha256: str
input_identities: tuple[RuntimeFileIdentity, ...]
runtime_scene_identity: RuntimeFileIdentity
created_at: str
links_created: int
@dataclass(frozen=True)
class LegacyOverlayResult:
"""Version-one runtime manifest retained only for a one-time fast migration."""
source_game: str source_game: str
runtime_view: str runtime_view: str
source_scene_sha256: str source_scene_sha256: str
@@ -94,8 +125,9 @@ def inspect_runtime_compatibility(
This preflight is intentionally read-only: it never normalizes a scene, This preflight is intentionally read-only: it never normalizes a scene,
builds the verified runtime DLL, prepares save data, or replaces the runtime builds the verified runtime DLL, prepares save data, or replaces the runtime
view. The normal preparation path still performs every authoritative check view. Preparation reuses a prior authoritative result only while every input
immediately before launch. identity and runtime-view link remains unchanged; otherwise it repeats the
full checks before launch.
""" """
requested_source = source_game.expanduser() requested_source = source_game.expanduser()
@@ -105,6 +137,9 @@ def inspect_runtime_compatibility(
try: try:
source = requested_source.resolve(strict=True) source = requested_source.resolve(strict=True)
_validate_game_tree(source) _validate_game_tree(source)
cached = _cached_runtime_compatibility(paths, source)
if cached is not None:
return cached
source_scene = source / WHISPERING_VALE_RELATIVE source_scene = source / WHISPERING_VALE_RELATIVE
inspection = inspect_wrapped_assbin(source_scene) inspection = inspect_wrapped_assbin(source_scene)
source_scene_sha256 = inspection.file_sha256 source_scene_sha256 = inspection.file_sha256
@@ -139,10 +174,14 @@ def prepare_runtime_view(
source = source_game.expanduser().resolve(strict=True) source = source_game.expanduser().resolve(strict=True)
_validate_game_tree(source) _validate_game_tree(source)
_validate_runtime_target(paths) _validate_runtime_target(paths)
game_save = _prepare_persistent_save(paths, source)
reused = _reuse_runtime_view(paths, source, game_save)
if reused is not None:
return reused
input_identities = _runtime_input_identities(paths, source)
source_scene = source / WHISPERING_VALE_RELATIVE source_scene = source / WHISPERING_VALE_RELATIVE
inspection = inspect_wrapped_assbin(source_scene) inspection = inspect_wrapped_assbin(source_scene)
action = _scene_action(inspection) action = _scene_action(inspection)
game_save = _prepare_persistent_save(paths, source)
( (
binary_action, binary_action,
game_exe_source, game_exe_source,
@@ -152,7 +191,7 @@ def prepare_runtime_view(
paths, paths,
source, source,
) )
reused = _reuse_runtime_view( legacy_reuse = _reuse_legacy_runtime_view(
paths, paths,
source, source,
inspection, inspection,
@@ -162,9 +201,10 @@ def prepare_runtime_view(
game_exe_source, game_exe_source,
game_dll_source, game_dll_source,
source_binary_hashes, source_binary_hashes,
input_identities,
) )
if reused is not None: if legacy_reuse is not None:
return reused return legacy_reuse
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}" build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}" backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}"
@@ -213,6 +253,10 @@ def prepare_runtime_view(
validate_scene_normalization(normalization) validate_scene_normalization(normalization)
else: else:
shutil.copy2(source_scene, runtime_scene) shutil.copy2(source_scene, runtime_scene)
if _runtime_input_identities(paths, source) != input_identities:
raise OverlayError(
"game files changed while the managed runtime view was being prepared"
)
if paths.runtime_view.exists() or paths.runtime_view.is_symlink(): if paths.runtime_view.exists() or paths.runtime_view.is_symlink():
paths.runtime_view.replace(backup) paths.runtime_view.replace(backup)
@@ -234,6 +278,11 @@ def prepare_runtime_view(
binary_action=binary_action, binary_action=binary_action,
game_exe_sha256=_sha256(paths.runtime_view / "Game.exe"), game_exe_sha256=_sha256(paths.runtime_view / "Game.exe"),
game_dll_sha256=_sha256(paths.runtime_view / "game.dll"), game_dll_sha256=_sha256(paths.runtime_view / "game.dll"),
input_identities=input_identities,
runtime_scene_identity=_runtime_file_identity(
"runtime-scene",
paths.runtime_view / WHISPERING_VALE_RELATIVE,
),
created_at=datetime.now().astimezone().isoformat(), created_at=datetime.now().astimezone().isoformat(),
links_created=links_created, links_created=links_created,
) )
@@ -470,7 +519,99 @@ def _reject_save_symlinks(root: Path) -> None:
raise OverlayError(f"persistent game save contains a symbolic link: {path}") raise OverlayError(f"persistent game save contains a symbolic link: {path}")
def _reuse_runtime_view( def _runtime_file_identity(
name: str,
path: Path,
*,
follow_symlinks: bool = True,
) -> RuntimeFileIdentity:
state = path.stat(follow_symlinks=follow_symlinks)
return RuntimeFileIdentity(
name=name,
device=state.st_dev,
inode=state.st_ino,
mode=state.st_mode,
size=state.st_size,
mtime_ns=state.st_mtime_ns,
ctime_ns=state.st_ctime_ns,
)
def _runtime_input_identities(
paths: AppPaths,
source: Path,
) -> tuple[RuntimeFileIdentity, ...]:
"""Snapshot inputs whose hashes established the cached runtime profile.
Nanosecond mtime plus ctime/inode/device prevents ordinary edits, replacements,
or timestamp restoration from bypassing the next authoritative hash check.
"""
identities = [
_runtime_file_identity("source-game-exe", source / "Game.exe"),
_runtime_file_identity("source-game-dll", source / "game.dll"),
_runtime_file_identity("source-scene", source / WHISPERING_VALE_RELATIVE),
]
cache = paths.compatibility_binaries
if cache.exists() or cache.is_symlink():
identities.append(
_runtime_file_identity(
"compatibility-cache",
cache,
follow_symlinks=False,
)
)
if cache.is_dir():
for name in COMPATIBILITY_BINARY_HASHES:
path = cache / name
if path.exists() or path.is_symlink():
identities.append(
_runtime_file_identity(
f"compatibility-cache-{name.casefold()}",
path,
)
)
return tuple(identities)
def _matching_cached_runtime_result(
paths: AppPaths,
source: Path,
) -> OverlayResult | None:
result = _read_overlay_result(paths.runtime_manifest)
if result is None:
return None
if result.source_game != str(source):
return None
if result.runtime_view != str(paths.runtime_view):
return None
try:
current_identities = _runtime_input_identities(paths, source)
except OSError:
return None
if current_identities != result.input_identities:
return None
return result
def _cached_runtime_compatibility(
paths: AppPaths,
source: Path,
) -> RuntimeCompatibility | None:
result = _matching_cached_runtime_result(paths, source)
if result is None:
return None
return RuntimeCompatibility(
compatible=True,
source_game=str(source),
source_scene_sha256=result.source_scene_sha256,
scene_action=result.scene_action,
binary_action=result.binary_action,
reason=None,
)
def _reuse_legacy_runtime_view(
paths: AppPaths, paths: AppPaths,
source: Path, source: Path,
inspection: SceneInspection, inspection: SceneInspection,
@@ -480,22 +621,82 @@ def _reuse_runtime_view(
game_exe_source: Path, game_exe_source: Path,
game_dll_source: Path, game_dll_source: Path,
source_binary_hashes: Mapping[str, str], source_binary_hashes: Mapping[str, str],
input_identities: tuple[RuntimeFileIdentity, ...],
) -> OverlayResult | None: ) -> OverlayResult | None:
result = _read_overlay_result(paths.runtime_manifest) """Upgrade an unchanged v1 view after its original hash checks pass once."""
legacy = _read_legacy_overlay_result(paths.runtime_manifest)
if legacy is None:
return None
if legacy.source_game != str(source):
return None
if legacy.runtime_view != str(paths.runtime_view):
return None
if legacy.source_scene_sha256 != inspection.file_sha256:
return None
if legacy.scene_action != scene_action or legacy.binary_action != binary_action:
return None
if legacy.game_exe_sha256 != source_binary_hashes.get("Game.exe"):
return None
if legacy.game_dll_sha256 != source_binary_hashes.get("game.dll"):
return None
if not _runtime_layout_ready(
paths,
source,
game_save,
game_exe_source,
game_dll_source,
legacy.links_created,
):
return None
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
try:
if _sha256(runtime_scene) != legacy.runtime_scene_sha256:
return None
if _runtime_input_identities(paths, source) != input_identities:
raise OverlayError(
"game files changed while the legacy runtime view was being verified"
)
runtime_scene_identity = _runtime_file_identity(
"runtime-scene",
runtime_scene,
)
except OSError:
return None
result = OverlayResult(
source_game=legacy.source_game,
runtime_view=legacy.runtime_view,
source_scene_sha256=legacy.source_scene_sha256,
runtime_scene_sha256=legacy.runtime_scene_sha256,
scene_action=legacy.scene_action,
binary_action=legacy.binary_action,
game_exe_sha256=legacy.game_exe_sha256,
game_dll_sha256=legacy.game_dll_sha256,
input_identities=input_identities,
runtime_scene_identity=runtime_scene_identity,
created_at=legacy.created_at,
links_created=legacy.links_created,
)
_write_json(
paths.runtime_manifest,
{
"configuration_version": RUNTIME_VIEW_CONFIGURATION_VERSION,
**asdict(result),
},
)
return result
def _reuse_runtime_view(
paths: AppPaths,
source: Path,
game_save: Path,
) -> OverlayResult | None:
result = _matching_cached_runtime_result(paths, source)
if result is None: if result is None:
return None return None
if result.source_game != str(source): game_exe_source = source / "Game.exe"
return None game_dll_source = source / "game.dll"
if result.runtime_view != str(paths.runtime_view):
return None
if result.source_scene_sha256 != inspection.file_sha256:
return None
if result.scene_action != scene_action or result.binary_action != binary_action:
return None
if result.game_exe_sha256 != source_binary_hashes.get("Game.exe"):
return None
if result.game_dll_sha256 != source_binary_hashes.get("game.dll"):
return None
if not _runtime_layout_ready( if not _runtime_layout_ready(
paths, paths,
source, source,
@@ -507,7 +708,12 @@ def _reuse_runtime_view(
return None return None
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
try: try:
if _sha256(runtime_scene) != result.runtime_scene_sha256: if (
_runtime_file_identity("runtime-scene", runtime_scene)
!= result.runtime_scene_identity
):
return None
if _runtime_input_identities(paths, source) != result.input_identities:
return None return None
except OSError: except OSError:
return None return None
@@ -651,7 +857,101 @@ def _read_overlay_result(path: Path) -> OverlayResult | None:
return None return None
if links_created < 0: if links_created < 0:
return None return None
input_values = value.get("input_identities")
if not isinstance(input_values, list) or not input_values:
return None
input_identities: list[RuntimeFileIdentity] = []
for input_value in input_values:
identity = _parse_runtime_file_identity(input_value)
if identity is None:
return None
input_identities.append(identity)
input_names = {identity.name for identity in input_identities}
if len(input_names) != len(input_identities) or not {
"source-game-exe",
"source-game-dll",
"source-scene",
}.issubset(input_names):
return None
runtime_scene_identity = _parse_runtime_file_identity(
value.get("runtime_scene_identity")
)
if (
runtime_scene_identity is None
or runtime_scene_identity.name != "runtime-scene"
):
return None
return OverlayResult( return OverlayResult(
source_game=value["source_game"],
runtime_view=value["runtime_view"],
source_scene_sha256=value["source_scene_sha256"],
runtime_scene_sha256=value["runtime_scene_sha256"],
scene_action=value["scene_action"],
binary_action=value["binary_action"],
game_exe_sha256=value["game_exe_sha256"],
game_dll_sha256=value["game_dll_sha256"],
input_identities=tuple(input_identities),
runtime_scene_identity=runtime_scene_identity,
created_at=value["created_at"],
links_created=links_created,
)
def _parse_runtime_file_identity(value: object) -> RuntimeFileIdentity | None:
if not isinstance(value, dict):
return None
name = value.get("name")
if not isinstance(name, str) or not name:
return None
fields: dict[str, int] = {}
for field in ("device", "inode", "mode", "size", "mtime_ns", "ctime_ns"):
field_value = value.get(field)
if (
isinstance(field_value, bool)
or not isinstance(field_value, int)
or field_value < 0
):
return None
fields[field] = field_value
return RuntimeFileIdentity(
name=name,
device=fields["device"],
inode=fields["inode"],
mode=fields["mode"],
size=fields["size"],
mtime_ns=fields["mtime_ns"],
ctime_ns=fields["ctime_ns"],
)
def _read_legacy_overlay_result(path: Path) -> LegacyOverlayResult | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not isinstance(value, dict) or value.get("configuration_version") != 1:
return None
string_fields = (
"source_game",
"runtime_view",
"source_scene_sha256",
"runtime_scene_sha256",
"scene_action",
"binary_action",
"game_exe_sha256",
"game_dll_sha256",
"created_at",
)
if not all(isinstance(value.get(name), str) for name in string_fields):
return None
links_created = value.get("links_created")
if (
isinstance(links_created, bool)
or not isinstance(links_created, int)
or links_created < 0
):
return None
return LegacyOverlayResult(
source_game=value["source_game"], source_game=value["source_game"],
runtime_view=value["runtime_view"], runtime_view=value["runtime_view"],
source_scene_sha256=value["source_scene_sha256"], source_scene_sha256=value["source_scene_sha256"],

View File

@@ -15,6 +15,7 @@ from .paths import AppPaths
SETTINGS_SCHEMA = 1 SETTINGS_SCHEMA = 1
_TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -26,7 +27,7 @@ class Settings:
output_height: int = 900 output_height: int = 900
refresh_rate: int = 60 refresh_rate: int = 60
fullscreen: bool = False fullscreen: bool = False
borderless: bool = True borderless: bool = False
suppress_unresponsive_warnings: bool = True suppress_unresponsive_warnings: bool = True
scaler: str = "fit" scaler: str = "fit"
filter: str = "linear" filter: str = "linear"
@@ -141,7 +142,7 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
output_height=_integer(value, "output_height", 900), output_height=_integer(value, "output_height", 900),
refresh_rate=_integer(value, "refresh_rate", 60), refresh_rate=_integer(value, "refresh_rate", 60),
fullscreen=_boolean(value, "fullscreen", False), fullscreen=_boolean(value, "fullscreen", False),
borderless=_boolean(value, "borderless", True), borderless=_migrated_borderless(value),
suppress_unresponsive_warnings=_boolean( suppress_unresponsive_warnings=_boolean(
value, value,
"suppress_unresponsive_warnings", "suppress_unresponsive_warnings",
@@ -158,6 +159,22 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
return settings return settings
def _migrated_borderless(value: dict[str, object]) -> bool:
"""Retire the old implicit borderless default exactly once.
Older releases wrote ``borderless=true`` for every default installation, so
they cannot distinguish an inherited value from an explicit choice. The
migration marker keeps schema 1 backward compatible while letting users opt
back into Borderless after the decorated default has been applied.
"""
borderless = _boolean(value, "borderless", False)
migrated = _boolean(value, _TITLE_FRAME_DEFAULT_MIGRATION_KEY, False)
if not migrated:
return False
return borderless
def _decode_settings(content: bytes) -> str: def _decode_settings(content: bytes) -> str:
"""Accept native UTF-8 plus settings saved by common Windows editors.""" """Accept native UTF-8 plus settings saved by common Windows editors."""
@@ -174,6 +191,7 @@ def _settings_need_migration(value: dict[str, object]) -> bool:
return ( return (
value.get("schema") != SETTINGS_SCHEMA value.get("schema") != SETTINGS_SCHEMA
or not _SETTINGS_FIELD_NAMES.issubset(value) or not _SETTINGS_FIELD_NAMES.issubset(value)
or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True
) )
@@ -185,6 +203,7 @@ def _write_settings(
payload = dict(existing or {}) payload = dict(existing or {})
payload.update(asdict(settings)) payload.update(asdict(settings))
payload["schema"] = SETTINGS_SCHEMA payload["schema"] = SETTINGS_SCHEMA
payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True
temporary = paths.settings_file.with_name( temporary = paths.settings_file.with_name(
f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}." f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}."
f"{secrets.token_hex(8)}.tmp" f"{secrets.token_hex(8)}.tmp"

View File

@@ -105,7 +105,7 @@ ACTIVITY_LOG_TRIM_LINES = 500
TRANSFER_RATE_WINDOW_SECONDS = 3.0 TRANSFER_RATE_WINDOW_SECONDS = 3.0
TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2 TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2
TRANSFER_RATE_IDLE_SECONDS = 2.0 TRANSFER_RATE_IDLE_SECONDS = 2.0
DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 650 DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 300
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000 DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000
DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16 DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16
DASHBOARD_TRANSITION_STEPS = 9 DASHBOARD_TRANSITION_STEPS = 9
@@ -120,6 +120,7 @@ SERVER_STATUS_STALE_SECONDS = 5 * 60
DASHBOARD_STARTUP_COMPONENTS = frozenset( DASHBOARD_STARTUP_COMPONENTS = frozenset(
{ {
"broadcast", "broadcast",
"cached_content",
"compatibility", "compatibility",
"launcher_update", "launcher_update",
"logo", "logo",
@@ -130,6 +131,7 @@ DASHBOARD_STARTUP_COMPONENTS = frozenset(
"version", "version",
} }
) )
DASHBOARD_STARTUP_REQUIRED_COMPONENTS = frozenset({"cached_content"})
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -559,9 +561,9 @@ def dashboard_preloader_delay(
def dashboard_content_ready(ready_components: set[str] | frozenset[str]) -> bool: def dashboard_content_ready(ready_components: set[str] | frozenset[str]) -> bool:
"""Return whether every user-visible startup component has settled.""" """Return whether cached content is ready for an interactive dashboard."""
return DASHBOARD_STARTUP_COMPONENTS.issubset(ready_components) return DASHBOARD_STARTUP_REQUIRED_COMPONENTS.issubset(ready_components)
def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float: def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float:
@@ -1284,6 +1286,7 @@ def run_dashboard(paths: AppPaths) -> None:
startup_timeout_after: str | None = None startup_timeout_after: str | None = None
startup_progress_after: str | None = None startup_progress_after: str | None = None
startup_progress_target = 0.0 startup_progress_target = 0.0
remote_refresh_started = False
root.deiconify() root.deiconify()
root.lift() root.lift()
startup_overlay.lift() startup_overlay.lift()
@@ -1375,6 +1378,7 @@ def run_dashboard(paths: AppPaths) -> None:
set_root_opacity(1.0) set_root_opacity(1.0)
root.lift() root.lift()
root.focus_set() root.focus_set()
root.after_idle(start_remote_content_refresh)
def fade_dashboard_in(step: int = 0) -> None: def fade_dashboard_in(step: int = 0) -> None:
if not set_root_opacity( if not set_root_opacity(
@@ -3389,6 +3393,13 @@ def run_dashboard(paths: AppPaths) -> None:
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start() threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
refresh_launcher_update_check() refresh_launcher_update_check()
def start_remote_content_refresh() -> None:
nonlocal remote_refresh_started
if remote_refresh_started:
return
remote_refresh_started = True
refresh_remote_content()
def release_game_launch_controls( def release_game_launch_controls(
client_id: int, client_id: int,
*, *,
@@ -3411,12 +3422,14 @@ def run_dashboard(paths: AppPaths) -> None:
speed_text.set("") speed_text.set("")
progress_bar.stop() progress_bar.stop()
close_game_preloader() close_game_preloader()
root.deiconify() if not ready:
root.deiconify()
set_busy(False) set_busy(False)
refresh_primary_action(update_activity=False) refresh_primary_action(update_activity=False)
if ready: if ready:
status.set("Ready for another account.") status.set("Ready for another account.")
progress_text.set("Click Play to open another game window.") progress_text.set("Click Play to open another game window.")
dashboard_chrome.minimize()
elif cancelled: elif cancelled:
root.lift() root.lift()
status.set("Game launch cancelled.") status.set("Game launch cancelled.")
@@ -4093,8 +4106,8 @@ def run_dashboard(paths: AppPaths) -> None:
ttk.Label( ttk.Label(
display_card, display_card,
text=( text=(
"The launcher uses the proven client resolution and can remove the " "The launcher keeps the game window's title bar by default. Enable "
"new game window's host decorations." "Borderless only when you want to remove its host decorations."
), ),
style="SettingsHint.TLabel", style="SettingsHint.TLabel",
wraplength=360, wraplength=360,
@@ -4103,7 +4116,7 @@ def run_dashboard(paths: AppPaths) -> None:
presentation.grid(row=1, column=0, columnspan=2, sticky="w") presentation.grid(row=1, column=0, columnspan=2, sticky="w")
ttk.Checkbutton( ttk.Checkbutton(
presentation, presentation,
text="Borderless", text="Remove title bar and borders (Borderless)",
variable=borderless_value, variable=borderless_value,
style="Settings.TCheckbutton", style="Settings.TCheckbutton",
).pack(anchor="w") ).pack(anchor="w")
@@ -4280,15 +4293,19 @@ def run_dashboard(paths: AppPaths) -> None:
if cached_launcher_update is not None: if cached_launcher_update is not None:
show_launcher_update_check(cached_launcher_update, log_reason=False) show_launcher_update_check(cached_launcher_update, log_reason=False)
refresh_runtime_compatibility() refresh_runtime_compatibility()
mark_startup_component_ready(
"cached_content",
"Cached dashboard content ready",
)
poll_events() poll_events()
load_official_logo() load_official_logo()
update_startup_progress("Loading online dashboard content…") update_startup_progress("Loading online dashboard content…")
startup_overlay.update_idletasks() startup_overlay.update_idletasks()
startup_timeout_after = root.after( if not startup_transition_started:
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS, startup_timeout_after = root.after(
timeout_startup_preloader, DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS,
) timeout_startup_preloader,
refresh_remote_content() )
maybe_start_dashboard_transition() maybe_start_dashboard_transition()
try: try:
root.mainloop() root.mainloop()

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "PTEU-Linux-Launcher" name = "PTEU-Linux-Launcher"
version = "0.8.1" version = "0.8.3"
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": "bdf72fdb9db79b2372b16feef5935fff7fd654f81e89b376c59f71dac81cc64f", "archive_sha256": "dfaddcbda762eaf34fd80f9592d6173176cfbd41189a96fae023f5459fd6492d",
"archive_size": 255328, "archive_size": 260645,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.8.1/pristontale-eu-linux-update-v0.8.1.tar.gz", "archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.8.3/pristontale-eu-linux-update-v0.8.3.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.8.1", "notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.8.3",
"published_at": "2026-09-01T18:09:16Z", "published_at": "2026-09-01T20:24:03Z",
"schema": 1, "schema": 1,
"version": "0.8.1" "version": "0.8.3"
} }

Binary file not shown.