Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55b2292bfe | |||
| 15dbd90dbb | |||
| c2400db19e | |||
| 1a17c1a197 | |||
| 25f490c8b8 | |||
| e8d77f3779 |
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 \
|
|
||||||
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \
|
|
||||||
"${candidate}" >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
release_root="${candidate}"
|
release_root="${candidate}"
|
||||||
break
|
break
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ -z "${release_root}" ]
|
if [ -z "${release_root}" ]
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
|||||||
|
|
||||||
__all__ = ["AppPaths", "__version__"]
|
__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())
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
if not ready:
|
||||||
root.deiconify()
|
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()
|
||||||
|
if not startup_transition_started:
|
||||||
startup_timeout_after = root.after(
|
startup_timeout_after = root.after(
|
||||||
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS,
|
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS,
|
||||||
timeout_startup_preloader,
|
timeout_startup_preloader,
|
||||||
)
|
)
|
||||||
refresh_remote_content()
|
|
||||||
maybe_start_dashboard_transition()
|
maybe_start_dashboard_transition()
|
||||||
try:
|
try:
|
||||||
root.mainloop()
|
root.mainloop()
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"archive_sha256": "fac790b8bdca1689999de0562c360f299df6a78f542f86ada86b997b0091eafb",
|
"archive_sha256": "dfaddcbda762eaf34fd80f9592d6173176cfbd41189a96fae023f5459fd6492d",
|
||||||
"archive_size": 254243,
|
"archive_size": 260645,
|
||||||
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.8.0/pristontale-eu-linux-update-v0.8.0.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.0",
|
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.8.3",
|
||||||
"published_at": "2026-08-31T21:27:27Z",
|
"published_at": "2026-09-01T20:24:03Z",
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"version": "0.8.0"
|
"version": "0.8.3"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user