Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55b2292bfe | |||
| 15dbd90dbb | |||
| c2400db19e | |||
| 1a17c1a197 | |||
| 25f490c8b8 |
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
included in this archive.
|
||||
|
||||
@@ -15,11 +15,11 @@ Then run:
|
||||
```text
|
||||
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
|
||||
openssl dgst -sha256 -verify launcher-update-public.pem \
|
||||
-signature pristontale-eu-linux-installer-v0-8-1.tar.gz.sig \
|
||||
pristontale-eu-linux-installer-v0-8-1.tar.gz
|
||||
sha256sum -c pristontale-eu-linux-installer-v0-8-1.tar.gz.sha256
|
||||
tar -xzf pristontale-eu-linux-installer-v0-8-1.tar.gz
|
||||
cd pristontale-eu-linux-installer-v0-8-1
|
||||
-signature pristontale-eu-linux-installer-v0-8-3.tar.gz.sig \
|
||||
pristontale-eu-linux-installer-v0-8-3.tar.gz
|
||||
sha256sum -c pristontale-eu-linux-installer-v0-8-3.tar.gz.sha256
|
||||
tar -xzf pristontale-eu-linux-installer-v0-8-3.tar.gz
|
||||
cd pristontale-eu-linux-installer-v0-8-3
|
||||
./install.sh
|
||||
```
|
||||
|
||||
|
||||
@@ -12,17 +12,20 @@ export PTLINUX_APP_HOME="${app_home}"
|
||||
release_root=""
|
||||
for candidate in "${app_home}/current" "${app_home}/previous" "${app_home}"
|
||||
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
|
||||
continue
|
||||
fi
|
||||
if "$python_bin" -I -B -c \
|
||||
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \
|
||||
"${candidate}" >/dev/null 2>&1
|
||||
then
|
||||
release_root="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "${release_root}" ]
|
||||
|
||||
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "0.8.1"
|
||||
__version__ = "0.8.3"
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -19,6 +19,20 @@ from .desktop_identity import (
|
||||
WINDOW_TITLE = APPLICATION_NAME
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
||||
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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
def __init__(self) -> None:
|
||||
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
|
||||
@@ -94,6 +137,7 @@ class _X11:
|
||||
if not self.display:
|
||||
self._restore_error_handler()
|
||||
raise GameboxRuntimeError("could not open the host X11 display")
|
||||
self._atoms: dict[bytes, int] = {}
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
@@ -117,17 +161,24 @@ class _X11:
|
||||
title = self._window_title(window)
|
||||
if title is not None:
|
||||
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]:
|
||||
root = self.library.XDefaultRootWindow(self.display)
|
||||
return self._child_windows(root)
|
||||
|
||||
def _child_windows(self, window: int) -> list[int]:
|
||||
root_return = ctypes.c_ulong()
|
||||
parent_return = ctypes.c_ulong()
|
||||
children = ctypes.POINTER(ctypes.c_ulong)()
|
||||
child_count = ctypes.c_uint()
|
||||
status = self.library.XQueryTree(
|
||||
self.display,
|
||||
root,
|
||||
window,
|
||||
ctypes.byref(root_return),
|
||||
ctypes.byref(parent_return),
|
||||
ctypes.byref(children),
|
||||
@@ -141,11 +192,267 @@ class _X11:
|
||||
if children:
|
||||
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:
|
||||
self._remove_decorations(window)
|
||||
self.library.XMapRaised(self.display, window)
|
||||
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(
|
||||
self,
|
||||
@@ -176,7 +483,7 @@ class _X11:
|
||||
|
||||
def suppress_wm_ping(self, window: int) -> bool:
|
||||
"""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:
|
||||
return False
|
||||
protocols = self._wm_protocols(window)
|
||||
@@ -227,7 +534,7 @@ class _X11:
|
||||
self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p))
|
||||
|
||||
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:
|
||||
return
|
||||
hints = _MotifWmHints(
|
||||
@@ -304,6 +611,21 @@ class _X11:
|
||||
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 = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
@@ -326,6 +648,40 @@ class _X11:
|
||||
self.library.XSetWMProtocols.restype = ctypes.c_int
|
||||
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
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.restype = ctypes.c_int
|
||||
self.library.XFlush.argtypes = [ctypes.c_void_p]
|
||||
@@ -367,7 +723,40 @@ def _prepare_game_window(
|
||||
file=sys.stderr,
|
||||
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:
|
||||
@@ -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(
|
||||
command: Sequence[str],
|
||||
window_title: str = WINDOW_TITLE,
|
||||
@@ -450,6 +940,13 @@ def run_game_window(
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
_focus_game_window(mapper, game_window)
|
||||
_guard_game_window_startup(
|
||||
mapper,
|
||||
process,
|
||||
game_window,
|
||||
borderless=borderless,
|
||||
)
|
||||
return int(process.wait())
|
||||
time.sleep(0.1)
|
||||
if process.poll() is None:
|
||||
|
||||
@@ -44,15 +44,46 @@ PATCHED_V21_GAME_DLL_SHA256 = (
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
|
||||
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
|
||||
)
|
||||
RUNTIME_VIEW_CONFIGURATION_VERSION = 1
|
||||
RUNTIME_VIEW_CONFIGURATION_VERSION = 2
|
||||
|
||||
|
||||
class OverlayError(RuntimeError):
|
||||
"""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)
|
||||
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
|
||||
runtime_view: str
|
||||
source_scene_sha256: str
|
||||
@@ -94,8 +125,9 @@ def inspect_runtime_compatibility(
|
||||
|
||||
This preflight is intentionally read-only: it never normalizes a scene,
|
||||
builds the verified runtime DLL, prepares save data, or replaces the runtime
|
||||
view. The normal preparation path still performs every authoritative check
|
||||
immediately before launch.
|
||||
view. Preparation reuses a prior authoritative result only while every input
|
||||
identity and runtime-view link remains unchanged; otherwise it repeats the
|
||||
full checks before launch.
|
||||
"""
|
||||
|
||||
requested_source = source_game.expanduser()
|
||||
@@ -105,6 +137,9 @@ def inspect_runtime_compatibility(
|
||||
try:
|
||||
source = requested_source.resolve(strict=True)
|
||||
_validate_game_tree(source)
|
||||
cached = _cached_runtime_compatibility(paths, source)
|
||||
if cached is not None:
|
||||
return cached
|
||||
source_scene = source / WHISPERING_VALE_RELATIVE
|
||||
inspection = inspect_wrapped_assbin(source_scene)
|
||||
source_scene_sha256 = inspection.file_sha256
|
||||
@@ -139,10 +174,14 @@ def prepare_runtime_view(
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
_validate_game_tree(source)
|
||||
_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
|
||||
inspection = inspect_wrapped_assbin(source_scene)
|
||||
action = _scene_action(inspection)
|
||||
game_save = _prepare_persistent_save(paths, source)
|
||||
(
|
||||
binary_action,
|
||||
game_exe_source,
|
||||
@@ -152,7 +191,7 @@ def prepare_runtime_view(
|
||||
paths,
|
||||
source,
|
||||
)
|
||||
reused = _reuse_runtime_view(
|
||||
legacy_reuse = _reuse_legacy_runtime_view(
|
||||
paths,
|
||||
source,
|
||||
inspection,
|
||||
@@ -162,9 +201,10 @@ def prepare_runtime_view(
|
||||
game_exe_source,
|
||||
game_dll_source,
|
||||
source_binary_hashes,
|
||||
input_identities,
|
||||
)
|
||||
if reused is not None:
|
||||
return reused
|
||||
if legacy_reuse is not None:
|
||||
return legacy_reuse
|
||||
|
||||
build = paths.data / f".{paths.runtime_view.name}.build-{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)
|
||||
else:
|
||||
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():
|
||||
paths.runtime_view.replace(backup)
|
||||
@@ -234,6 +278,11 @@ def prepare_runtime_view(
|
||||
binary_action=binary_action,
|
||||
game_exe_sha256=_sha256(paths.runtime_view / "Game.exe"),
|
||||
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(),
|
||||
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}")
|
||||
|
||||
|
||||
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,
|
||||
source: Path,
|
||||
inspection: SceneInspection,
|
||||
@@ -480,22 +621,82 @@ def _reuse_runtime_view(
|
||||
game_exe_source: Path,
|
||||
game_dll_source: Path,
|
||||
source_binary_hashes: Mapping[str, str],
|
||||
input_identities: tuple[RuntimeFileIdentity, ...],
|
||||
) -> 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:
|
||||
return None
|
||||
if result.source_game != str(source):
|
||||
return None
|
||||
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
|
||||
game_exe_source = source / "Game.exe"
|
||||
game_dll_source = source / "game.dll"
|
||||
if not _runtime_layout_ready(
|
||||
paths,
|
||||
source,
|
||||
@@ -507,7 +708,12 @@ def _reuse_runtime_view(
|
||||
return None
|
||||
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
|
||||
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
|
||||
except OSError:
|
||||
return None
|
||||
@@ -651,7 +857,101 @@ def _read_overlay_result(path: Path) -> OverlayResult | None:
|
||||
return None
|
||||
if links_created < 0:
|
||||
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(
|
||||
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"],
|
||||
runtime_view=value["runtime_view"],
|
||||
source_scene_sha256=value["source_scene_sha256"],
|
||||
|
||||
@@ -15,6 +15,7 @@ from .paths import AppPaths
|
||||
|
||||
|
||||
SETTINGS_SCHEMA = 1
|
||||
_TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -26,7 +27,7 @@ class Settings:
|
||||
output_height: int = 900
|
||||
refresh_rate: int = 60
|
||||
fullscreen: bool = False
|
||||
borderless: bool = True
|
||||
borderless: bool = False
|
||||
suppress_unresponsive_warnings: bool = True
|
||||
scaler: str = "fit"
|
||||
filter: str = "linear"
|
||||
@@ -141,7 +142,7 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
|
||||
output_height=_integer(value, "output_height", 900),
|
||||
refresh_rate=_integer(value, "refresh_rate", 60),
|
||||
fullscreen=_boolean(value, "fullscreen", False),
|
||||
borderless=_boolean(value, "borderless", True),
|
||||
borderless=_migrated_borderless(value),
|
||||
suppress_unresponsive_warnings=_boolean(
|
||||
value,
|
||||
"suppress_unresponsive_warnings",
|
||||
@@ -158,6 +159,22 @@ def _settings_from_object(value: dict[str, object]) -> 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:
|
||||
"""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 (
|
||||
value.get("schema") != SETTINGS_SCHEMA
|
||||
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.update(asdict(settings))
|
||||
payload["schema"] = SETTINGS_SCHEMA
|
||||
payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True
|
||||
temporary = paths.settings_file.with_name(
|
||||
f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}."
|
||||
f"{secrets.token_hex(8)}.tmp"
|
||||
|
||||
@@ -105,7 +105,7 @@ ACTIVITY_LOG_TRIM_LINES = 500
|
||||
TRANSFER_RATE_WINDOW_SECONDS = 3.0
|
||||
TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2
|
||||
TRANSFER_RATE_IDLE_SECONDS = 2.0
|
||||
DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 650
|
||||
DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 300
|
||||
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000
|
||||
DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16
|
||||
DASHBOARD_TRANSITION_STEPS = 9
|
||||
@@ -120,6 +120,7 @@ SERVER_STATUS_STALE_SECONDS = 5 * 60
|
||||
DASHBOARD_STARTUP_COMPONENTS = frozenset(
|
||||
{
|
||||
"broadcast",
|
||||
"cached_content",
|
||||
"compatibility",
|
||||
"launcher_update",
|
||||
"logo",
|
||||
@@ -130,6 +131,7 @@ DASHBOARD_STARTUP_COMPONENTS = frozenset(
|
||||
"version",
|
||||
}
|
||||
)
|
||||
DASHBOARD_STARTUP_REQUIRED_COMPONENTS = frozenset({"cached_content"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -559,9 +561,9 @@ def dashboard_preloader_delay(
|
||||
|
||||
|
||||
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:
|
||||
@@ -1284,6 +1286,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
startup_timeout_after: str | None = None
|
||||
startup_progress_after: str | None = None
|
||||
startup_progress_target = 0.0
|
||||
remote_refresh_started = False
|
||||
root.deiconify()
|
||||
root.lift()
|
||||
startup_overlay.lift()
|
||||
@@ -1375,6 +1378,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
set_root_opacity(1.0)
|
||||
root.lift()
|
||||
root.focus_set()
|
||||
root.after_idle(start_remote_content_refresh)
|
||||
|
||||
def fade_dashboard_in(step: int = 0) -> None:
|
||||
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()
|
||||
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(
|
||||
client_id: int,
|
||||
*,
|
||||
@@ -3411,12 +3422,14 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
speed_text.set("")
|
||||
progress_bar.stop()
|
||||
close_game_preloader()
|
||||
if not ready:
|
||||
root.deiconify()
|
||||
set_busy(False)
|
||||
refresh_primary_action(update_activity=False)
|
||||
if ready:
|
||||
status.set("Ready for another account.")
|
||||
progress_text.set("Click Play to open another game window.")
|
||||
dashboard_chrome.minimize()
|
||||
elif cancelled:
|
||||
root.lift()
|
||||
status.set("Game launch cancelled.")
|
||||
@@ -4093,8 +4106,8 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
ttk.Label(
|
||||
display_card,
|
||||
text=(
|
||||
"The launcher uses the proven client resolution and can remove the "
|
||||
"new game window's host decorations."
|
||||
"The launcher keeps the game window's title bar by default. Enable "
|
||||
"Borderless only when you want to remove its host decorations."
|
||||
),
|
||||
style="SettingsHint.TLabel",
|
||||
wraplength=360,
|
||||
@@ -4103,7 +4116,7 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
presentation.grid(row=1, column=0, columnspan=2, sticky="w")
|
||||
ttk.Checkbutton(
|
||||
presentation,
|
||||
text="Borderless",
|
||||
text="Remove title bar and borders (Borderless)",
|
||||
variable=borderless_value,
|
||||
style="Settings.TCheckbutton",
|
||||
).pack(anchor="w")
|
||||
@@ -4280,15 +4293,19 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
if cached_launcher_update is not None:
|
||||
show_launcher_update_check(cached_launcher_update, log_reason=False)
|
||||
refresh_runtime_compatibility()
|
||||
mark_startup_component_ready(
|
||||
"cached_content",
|
||||
"Cached dashboard content ready",
|
||||
)
|
||||
poll_events()
|
||||
load_official_logo()
|
||||
update_startup_progress("Loading online dashboard content…")
|
||||
startup_overlay.update_idletasks()
|
||||
if not startup_transition_started:
|
||||
startup_timeout_after = root.after(
|
||||
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS,
|
||||
timeout_startup_preloader,
|
||||
)
|
||||
refresh_remote_content()
|
||||
maybe_start_dashboard_transition()
|
||||
try:
|
||||
root.mainloop()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "PTEU-Linux-Launcher"
|
||||
version = "0.8.1"
|
||||
version = "0.8.3"
|
||||
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"archive_sha256": "bdf72fdb9db79b2372b16feef5935fff7fd654f81e89b376c59f71dac81cc64f",
|
||||
"archive_size": 255328,
|
||||
"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_sha256": "dfaddcbda762eaf34fd80f9592d6173176cfbd41189a96fae023f5459fd6492d",
|
||||
"archive_size": 260645,
|
||||
"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",
|
||||
"minimum_updater_version": "0.5.0",
|
||||
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.8.1",
|
||||
"published_at": "2026-09-01T18:09:16Z",
|
||||
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.8.3",
|
||||
"published_at": "2026-09-01T20:24:03Z",
|
||||
"schema": 1,
|
||||
"version": "0.8.1"
|
||||
"version": "0.8.3"
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user