diff --git a/ptlinux/dashboard_debug.py b/ptlinux/dashboard_debug.py new file mode 100644 index 0000000..b36ad95 --- /dev/null +++ b/ptlinux/dashboard_debug.py @@ -0,0 +1,213 @@ +"""Read-only state snapshots for the launcher's hidden debug view.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime +import json +from pathlib import Path +from typing import Callable + +from .import_state import game_import_requires_verification +from .paths import AppPaths +from .runtime import ( + RuntimeErrorPT, + RuntimeSelection, + resolve_proton_sync_mode, + select_runtime, +) +from .settings import Settings + + +@dataclass(frozen=True) +class RuntimeDebugInfo: + """Configured, selected, and last-prepared runtime state.""" + + requested_kind: str + selected_kind: str | None + executable: str | None + proton_path: str | None + prefix: str | None + sync_configured: str + sync_effective: str | None + prepared_kind: str | None + prepared_proton_path: str | None + prepared_at: str | None + marker_matches_selection: bool | None + dxvk_d3d9_override: str | None + error: str | None + + +@dataclass(frozen=True) +class VerificationDebugInfo: + """Cached trust evidence from completed depot and import operations.""" + + import_state: str + import_verified_at: str | None + import_build: int | None + last_sync_at: str | None + last_full_verification_at: str | None + build: int | None + version: str | None + mode: str | None + files_total: int | None + files_downloaded: int | None + files_deleted: int | None + files_created: int | None + files_quarantined: int | None + + +RuntimeSelector = Callable[[Settings], RuntimeSelection] +SyncResolver = Callable[[str], str] + + +def inspect_runtime_debug( + paths: AppPaths, + settings: Settings, + *, + selector: RuntimeSelector = select_runtime, + sync_resolver: SyncResolver = resolve_proton_sync_mode, +) -> RuntimeDebugInfo: + """Resolve the next runtime and compare it with the preparation marker.""" + + marker = _read_json_object(paths.game_runtime_marker) + prepared_runtime: dict[str, object] | None = None + if marker is not None: + raw_prepared_runtime = marker.get("runtime") + if isinstance(raw_prepared_runtime, dict): + prepared_runtime = raw_prepared_runtime + prepared_kind = _text(prepared_runtime.get("kind")) if prepared_runtime else None + prepared_proton_path = ( + _text(prepared_runtime.get("proton_path")) if prepared_runtime else None + ) + prepared_at = _text(marker.get("configured_at")) if marker else None + dxvk_override = _text(marker.get("dxvk_d3d9_override")) if marker else None + selection: RuntimeSelection | None = None + error: str | None = None + try: + selection = selector(settings) + except (OSError, RuntimeErrorPT, ValueError) as exc: + error = str(exc).strip() or type(exc).__name__ + selected_kind = selection.kind if selection is not None else None + prefix_kind = selected_kind or prepared_kind + prefix = None + if prefix_kind == "umu": + prefix = str(paths.umu_prefix) + elif prefix_kind in {"proton7", "proton7-container"}: + prefix = str(paths.proton_prefix) + sync_effective = None + if prefix_kind in {"proton7", "proton7-container"}: + try: + sync_effective = sync_resolver(settings.proton_sync) + except ValueError as exc: + error = error or (str(exc).strip() or type(exc).__name__) + marker_matches = ( + prepared_runtime == asdict(selection) + if prepared_runtime is not None and selection is not None + else None + ) + return RuntimeDebugInfo( + requested_kind=settings.runtime, + selected_kind=selected_kind, + executable=selection.executable if selection is not None else None, + proton_path=selection.proton_path if selection is not None else None, + prefix=prefix, + sync_configured=settings.proton_sync, + sync_effective=sync_effective, + prepared_kind=prepared_kind, + prepared_proton_path=prepared_proton_path, + prepared_at=prepared_at, + marker_matches_selection=marker_matches, + dxvk_d3d9_override=dxvk_override, + error=error, + ) + + +def read_verification_debug(paths: AppPaths) -> VerificationDebugInfo: + """Read cached verification state without hashing or changing game files.""" + + depot = _read_json_object(paths.native_depot_state) + if depot is not None and depot.get("dry_run") is not False: + depot = None + import_manifest_exists = paths.game_import_manifest.exists() + import_manifest = _read_json_object(paths.game_import_manifest) + if not import_manifest_exists: + import_state = "not applicable" + elif game_import_requires_verification(paths): + import_state = "pending" + else: + import_state = "verified" + import_verified_at = ( + _text(import_manifest.get("verification_completed_at")) + if import_state == "verified" and import_manifest is not None + else None + ) + import_build = ( + _non_negative_int(import_manifest.get("verification_build")) + if import_state == "verified" and import_manifest is not None + else None + ) + last_sync_at = _text(depot.get("completed_at")) if depot else None + full_verifications = [ + value + for value in ( + last_sync_at if depot is not None and depot.get("repair") is True else None, + import_verified_at, + ) + if value is not None + ] + last_full_verification_at = ( + max(full_verifications, key=_iso_timestamp) + if full_verifications + else None + ) + return VerificationDebugInfo( + import_state=import_state, + import_verified_at=import_verified_at, + import_build=import_build, + last_sync_at=last_sync_at, + last_full_verification_at=last_full_verification_at, + build=_non_negative_int(depot.get("build")) if depot else None, + version=_text(depot.get("version")) if depot else None, + mode=( + "full repair" + if depot is not None and depot.get("repair") is True + else "update" + if depot is not None and depot.get("repair") is False + else None + ), + files_total=_non_negative_int(depot.get("files_total")) if depot else None, + files_downloaded=( + _non_negative_int(depot.get("files_downloaded")) if depot else None + ), + files_deleted=_non_negative_int(depot.get("files_deleted")) if depot else None, + files_created=_non_negative_int(depot.get("files_created")) if depot else None, + files_quarantined=( + _non_negative_int(depot.get("files_quarantined")) if depot else None + ), + ) + + +def _read_json_object(path: Path) -> dict[str, object] | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + return None + return value if isinstance(value, dict) else None + + +def _text(value: object) -> str | None: + return value.strip() if isinstance(value, str) and value.strip() else None + + +def _non_negative_int(value: object) -> int | None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return None + return value + + +def _iso_timestamp(value: str) -> float: + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except (OverflowError, ValueError): + return float("-inf") diff --git a/ptlinux/desktop_identity.py b/ptlinux/desktop_identity.py new file mode 100644 index 0000000..2136f21 --- /dev/null +++ b/ptlinux/desktop_identity.py @@ -0,0 +1,59 @@ +"""Stable desktop identity shared by the launcher and Wine game window.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Mapping + + +APPLICATION_NAME = "PristonTale EU" +APPLICATION_WM_CLASS = "PristonTaleEU" +APPLICATION_WM_INSTANCE = "pristontale-eu" +APPLICATION_ICON_FILENAME = "eu.pristontale.PTLinux.png" +APPLICATION_ICON_VARIABLE = "PTLINUX_ICON_PATH" + + +def find_application_icon( + environment: Mapping[str, str] | None = None, +) -> Path | None: + """Find the installed icon, with source-tree fallbacks for development.""" + + env = os.environ if environment is None else environment + candidates: list[Path] = [] + explicit = env.get(APPLICATION_ICON_VARIABLE) + if explicit: + candidates.append(Path(explicit).expanduser()) + + # Some distributions bundle the icon beside this module. The installer + # publishes the same asset through the user's XDG data directory. + candidates.append(Path(__file__).with_name(APPLICATION_ICON_FILENAME)) + home_value = env.get("HOME") + if home_value: + home = Path(home_value).expanduser() + data_home_value = env.get("XDG_DATA_HOME") + data_home = ( + Path(data_home_value).expanduser() + if data_home_value + else home / ".local/share" + ) + candidates.append(data_home / "pixmaps" / APPLICATION_ICON_FILENAME) + default_icon = home / ".local/share/pixmaps" / APPLICATION_ICON_FILENAME + if default_icon not in candidates: + candidates.append(default_icon) + + # The source checkout keeps installer assets under packaging/. + candidates.append( + Path(__file__).resolve().parent.parent + / "packaging" + / APPLICATION_ICON_FILENAME + ) + seen: set[Path] = set() + for candidate in candidates: + resolved = candidate.resolve(strict=False) + if resolved in seen: + continue + seen.add(resolved) + if resolved.is_file(): + return resolved + return None diff --git a/ptlinux/server_status.py b/ptlinux/server_status.py new file mode 100644 index 0000000..16a1dd5 --- /dev/null +++ b/ptlinux/server_status.py @@ -0,0 +1,157 @@ +"""Official realm health used by the native launcher dashboard.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import Callable, Mapping, Protocol +from urllib.request import Request, urlopen + +from . import __version__ + + +# This is the same publisher endpoint embedded in Windows launcher 1.0.0.64. +SERVER_STATUS_ENDPOINT = ( + "https://pristontale.eu/api/pristontale/developer" + "?api_key=d9OlDSvworitU4ajDiH6dU6cxXApc4M0" +) +MAX_SERVER_STATUS_BYTES = 1024 * 1024 + + +class ServerStatusError(RuntimeError): + """Raised when the official status response cannot be interpreted safely.""" + + +class ServerStatusResponse(Protocol): + def read(self, size: int = -1) -> bytes: ... + + def __enter__(self) -> ServerStatusResponse: ... + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: object | None, + ) -> bool | None: ... + + +ServerStatusOpener = Callable[..., ServerStatusResponse] + + +@dataclass(frozen=True) +class RealmServerStatus: + name: str + online: bool | None + players_online: int | None + login_online: bool | None = None + game_online: bool | None = None + + +def fetch_server_status( + *, + timeout_seconds: float = 10.0, + opener: ServerStatusOpener = urlopen, +) -> tuple[RealmServerStatus, ...]: + request = Request( + SERVER_STATUS_ENDPOINT, + headers={"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"}, + method="GET", + ) + try: + response = opener(request, timeout=timeout_seconds) + with response: + payload = response.read(MAX_SERVER_STATUS_BYTES + 1) + except OSError as exc: + raise ServerStatusError("official server status is unavailable") from exc + if len(payload) > MAX_SERVER_STATUS_BYTES: + raise ServerStatusError("official server status exceeds the 1 MiB limit") + return parse_server_status(payload) + + +def parse_server_status(payload: bytes) -> tuple[RealmServerStatus, ...]: + try: + value = json.loads(payload.decode("utf-8-sig")) + except (UnicodeError, json.JSONDecodeError) as exc: + raise ServerStatusError("official server status is not valid JSON") from exc + # Historical versions returned an array containing the status object. The + # current endpoint returns that object directly. + if isinstance(value, list) and value: + value = value[0] + if not isinstance(value, dict): + raise ServerStatusError("official server status is not a realm object") + + statuses: list[RealmServerStatus] = [] + for raw_name, raw_components in value.items(): + if not isinstance(raw_name, str) or raw_name.casefold() == "website": + continue + name = raw_name.strip() + if not name or len(name) > 64 or not isinstance(raw_components, Mapping): + continue + status = _parse_realm(name, raw_components) + if status is not None: + statuses.append(status) + if not statuses: + raise ServerStatusError("official server status contains no realms") + return tuple(statuses) + + +def _parse_realm( + name: str, + components: Mapping[object, object], +) -> RealmServerStatus | None: + service_states: list[bool] = [] + login_online: bool | None = None + game_online: bool | None = None + players_online: int | None = None + for raw_key, raw_value in components.items(): + if not isinstance(raw_key, str): + continue + key = raw_key.casefold() + if key == "users.online": + players_online = _non_negative_integer(raw_value) + continue + if not key.startswith("server."): + continue + state = _online_state(raw_value) + if state is None: + continue + service_states.append(state) + if "login" in key: + login_online = state + elif "game" in key: + game_online = state + if not service_states and players_online is None: + return None + online = all(service_states) if service_states else None + display_name = name[0].upper() + name[1:] + return RealmServerStatus( + name=display_name, + online=online, + players_online=players_online, + login_online=login_online, + game_online=game_online, + ) + + +def _online_state(value: object) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value > 0 + if isinstance(value, str): + text = value.strip().casefold() + if text in {"online", "up", "true", "1"}: + return True + if text in {"offline", "down", "false", "0"}: + return False + return None + + +def _non_negative_integer(value: object) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int) and value >= 0: + return value + if isinstance(value, str) and value.isascii() and value.isdecimal(): + return int(value) + return None diff --git a/ptlinux/tk_chrome.py b/ptlinux/tk_chrome.py new file mode 100644 index 0000000..3b36230 --- /dev/null +++ b/ptlinux/tk_chrome.py @@ -0,0 +1,680 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Literal + +from .desktop_identity import APPLICATION_WM_CLASS, APPLICATION_WM_INSTANCE + + +DialogKind = Literal["info", "success", "warning", "error", "question"] + + +@dataclass(frozen=True) +class DialogPalette: + """Colors and glyph used by one launcher dialog intent.""" + + accent: str + accent_soft: str + glyph: str + + +_DIALOG_PALETTES: dict[str, DialogPalette] = { + "info": DialogPalette("#d98927", "#3a2819", "i"), + "success": DialogPalette("#55b878", "#173122", "✓"), + "warning": DialogPalette("#e0a248", "#3b2a18", "!"), + "error": DialogPalette("#cf604c", "#3a1d19", "×"), + "question": DialogPalette("#d98927", "#3a2819", "?"), +} + + +def dialog_palette(kind: DialogKind) -> DialogPalette: + """Return the stable visual palette for a themed launcher dialog.""" + + try: + return _DIALOG_PALETTES[kind] + except KeyError as error: + raise ValueError(f"unsupported launcher dialog kind: {kind}") from error + + +def square_icon_layout(width: int, height: int) -> tuple[int, int, int]: + """Return square canvas size and centered offsets for a window icon.""" + + if width <= 0 or height <= 0: + raise ValueError("icon dimensions must be positive") + size = max(width, height) + return size, (size - width) // 2, (size - height) // 2 + + +def icon_subsample_factor(width: int, height: int, max_dimension: int) -> int: + """Return the integer Tk subsample needed to fit an image within a square.""" + + if width <= 0 or height <= 0: + raise ValueError("icon dimensions must be positive") + if max_dimension <= 0: + raise ValueError("maximum icon dimension must be positive") + largest_dimension = max(width, height) + return max(1, (largest_dimension + max_dimension - 1) // max_dimension) + + +def load_square_window_icon(master: Any, path: Path) -> Any: + """Load a PNG into a square transparent Tk image for desktop shells.""" + + import tkinter as tk + + source = tk.PhotoImage(master=master, file=str(path)) + size, offset_x, offset_y = square_icon_layout(source.width(), source.height()) + if source.width() == source.height(): + return source + icon = tk.PhotoImage(master=master, width=size, height=size) + icon.tk.call( + str(icon), + "copy", + str(source), + "-to", + offset_x, + offset_y, + ) + return icon + + +def centered_window_geometry( + width: int, + height: int, + *, + parent_x: int, + parent_y: int, + parent_width: int, + parent_height: int, +) -> str: + """Return Tk geometry centered over a parent window.""" + + if width <= 0 or height <= 0: + raise ValueError("window dimensions must be positive") + x = parent_x + (max(1, parent_width) - width) // 2 + y = parent_y + (max(1, parent_height) - height) // 2 + return f"{width}x{height}{_geometry_offset(x)}{_geometry_offset(y)}" + + +def dragged_window_geometry( + pointer_x: int, + pointer_y: int, + *, + offset_x: int, + offset_y: int, +) -> str: + """Return the Tk position for a pointer-driven custom title bar.""" + + return ( + f"{_geometry_offset(pointer_x - offset_x)}" + f"{_geometry_offset(pointer_y - offset_y)}" + ) + + +def _geometry_offset(value: int) -> str: + return f"+{value}" if value >= 0 else str(value) + + +def _bind_window_drag(handle: Any, *widgets: Any) -> None: + position = [0, 0] + + def begin(event: Any) -> None: + position[0] = int(event.x_root) - int(handle.winfo_x()) + position[1] = int(event.y_root) - int(handle.winfo_y()) + + def move(event: Any) -> None: + handle.geometry( + dragged_window_geometry( + int(event.x_root), + int(event.y_root), + offset_x=position[0], + offset_y=position[1], + ) + ) + + for widget in widgets: + widget.bind("", begin, add="+") + widget.bind("", move, add="+") + + +class DashboardChrome: + """Controller for one frameless launcher window and its controls.""" + + def __init__( + self, + root: Any, + content: Any, + close_button: Any, + minimize_button: Any, + *, + managed: bool, + identity_configured: bool, + allow_close: bool, + ) -> None: + self.root = root + self.content = content + self.managed = managed + self.identity_configured = identity_configured + self._close_button = close_button + self._minimize_button = minimize_button + self._allow_close = allow_close + self._close_command: Callable[[], None] = root.destroy + if close_button is not None: + close_button.configure(command=self.close) + if minimize_button is not None: + minimize_button.configure(command=self.minimize) + root.bind("", self._close_from_event, add="+") + + def set_close_command(self, command: Callable[[], None]) -> None: + self._close_command = command + + def close(self) -> None: + if self._allow_close: + self._close_command() + + def minimize(self) -> None: + """Ask the window manager to iconify the managed dashboard.""" + + import tkinter as tk + + try: + self.root.iconify() + except tk.TclError: + pass + + def _close_from_event(self, _event: Any) -> str: + self.close() + return "break" + + + +def _configure_x11_window( + root: Any, + *, + resource_name: str, + resource_class: str, +) -> tuple[bool, bool]: + """Hide the X11 frame and give desktop shells a stable window identity.""" + + import ctypes.util + + library_path = ctypes.util.find_library("X11") + if library_path is None: + return False, False + try: + x11 = ctypes.CDLL(library_path) + except OSError: + return False, False + + class XClassHint(ctypes.Structure): + _fields_ = [ + ("res_name", ctypes.c_char_p), + ("res_class", ctypes.c_char_p), + ] + + x11.XOpenDisplay.argtypes = [ctypes.c_char_p] + x11.XOpenDisplay.restype = ctypes.c_void_p + x11.XQueryTree.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.POINTER(ctypes.c_ulong), + ctypes.POINTER(ctypes.c_ulong), + ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)), + ctypes.POINTER(ctypes.c_uint), + ] + x11.XQueryTree.restype = ctypes.c_int + x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int] + x11.XInternAtom.restype = ctypes.c_ulong + x11.XChangeProperty.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.c_int, + ctypes.c_int, + ctypes.POINTER(ctypes.c_ubyte), + ctypes.c_int, + ] + x11.XChangeProperty.restype = ctypes.c_int + x11.XSetClassHint.argtypes = [ + ctypes.c_void_p, + ctypes.c_ulong, + ctypes.POINTER(XClassHint), + ] + x11.XSetClassHint.restype = ctypes.c_int + x11.XFlush.argtypes = [ctypes.c_void_p] + x11.XFlush.restype = ctypes.c_int + x11.XFree.argtypes = [ctypes.c_void_p] + x11.XFree.restype = ctypes.c_int + x11.XCloseDisplay.argtypes = [ctypes.c_void_p] + x11.XCloseDisplay.restype = ctypes.c_int + root.update_idletasks() + display = x11.XOpenDisplay(None) + if display is None: + return False, False + children = ctypes.POINTER(ctypes.c_ulong)() + try: + root_window = ctypes.c_ulong() + wrapper_window = ctypes.c_ulong() + child_count = ctypes.c_uint() + queried = x11.XQueryTree( + display, + root.winfo_id(), + ctypes.byref(root_window), + ctypes.byref(wrapper_window), + ctypes.byref(children), + ctypes.byref(child_count), + ) + if not queried or not wrapper_window.value: + return False, False + atom = x11.XInternAtom(display, b"_MOTIF_WM_HINTS", 0) + if not atom: + return False, False + motif_hints_decorations = 1 << 1 + hints = (ctypes.c_ulong * 5)(motif_hints_decorations, 0, 0, 0, 0) + changed = x11.XChangeProperty( + display, + wrapper_window.value, + atom, + atom, + 32, + 0, + ctypes.cast(hints, ctypes.POINTER(ctypes.c_ubyte)), + len(hints), + ) + identity = XClassHint( + res_name=resource_name.encode("utf-8"), + res_class=resource_class.encode("utf-8"), + ) + identity_changed = x11.XSetClassHint( + display, + wrapper_window.value, + ctypes.byref(identity), + ) + x11.XFlush(display) + return bool(changed), bool(identity_changed) + finally: + if children: + x11.XFree(children) + x11.XCloseDisplay(display) + + +def create_window_chrome( + root: Any, + *, + title: str, + subtitle: str, + show_title_bar: bool = True, + show_close: bool = True, + show_minimize: bool = False, + allow_close: bool = True, +) -> DashboardChrome: + """Install polished managed chrome around one launcher-owned Tk window.""" + + import tkinter as tk + + managed, identity_configured = _configure_x11_window( + root, + resource_name=APPLICATION_WM_INSTANCE, + resource_class=APPLICATION_WM_CLASS, + ) + if not managed: + root.overrideredirect(True) + border = tk.Frame( + root, + background="#6a482d", + borderwidth=0, + highlightthickness=0, + ) + border.pack(fill="both", expand=True) + surface = tk.Frame( + border, + background="#0c0908", + borderwidth=0, + highlightthickness=0, + ) + surface.pack(fill="both", expand=True, padx=1, pady=1) + title_bar = tk.Frame( + surface, + background="#17100d", + height=32, + borderwidth=0, + highlightthickness=0, + cursor="fleur", + ) + if show_title_bar: + title_bar.pack(fill="x") + title_bar.pack_propagate(False) + tk_accent = tk.Frame(title_bar, background="#d98927", width=3) + tk_accent.pack(side="left", fill="y") + mark = tk.Canvas( + title_bar, + width=22, + height=22, + background="#17100d", + borderwidth=0, + highlightthickness=0, + cursor="fleur", + ) + mark.create_oval(3, 3, 19, 19, outline="#d98927", width=1) + mark.create_text(11, 11, text="PT", fill="#f5d18b", font=("Georgia", 6, "bold")) + mark.pack(side="left", padx=(9, 7)) + identity = tk.Frame(title_bar, background="#17100d", cursor="fleur") + identity.pack(side="left", fill="y") + title_label = tk.Label( + identity, + text=title, + background="#17100d", + foreground="#f2dfbd", + font=("Sans", 8, "bold"), + cursor="fleur", + ) + title_label.pack(side="left", pady=(0, 1)) + subtitle_label = tk.Label( + identity, + text=f" · {subtitle}", + background="#17100d", + foreground="#8f7b69", + font=("Sans", 7), + cursor="fleur", + ) + subtitle_label.pack(side="left", pady=(0, 1)) + close_button: Any = None + if show_close: + close_button = tk.Button( + title_bar, + text="×", + background="#17100d", + foreground="#c9b7a4", + activebackground="#a63f2f", + activeforeground="#ffffff", + font=("Sans", 12), + width=4, + borderwidth=0, + highlightthickness=0, + relief="flat", + cursor="hand2", + takefocus=False, + ) + close_button.pack(side="right", fill="y") + minimize_button: Any = None + if show_minimize: + minimize_button = tk.Button( + title_bar, + text="—", + background="#17100d", + foreground="#c9b7a4", + activebackground="#312219", + activeforeground="#ffffff", + font=("Sans", 10, "bold"), + width=4, + borderwidth=0, + highlightthickness=0, + relief="flat", + cursor="hand2", + takefocus=False, + ) + minimize_button.pack(side="right", fill="y") + content = tk.Frame( + surface, + background="#0c0908", + borderwidth=0, + highlightthickness=0, + ) + content.pack(fill="both", expand=True) + if show_title_bar: + _bind_window_drag(root, title_bar, mark, identity, title_label, subtitle_label) + return DashboardChrome( + root, + content, + close_button, + minimize_button, + managed=managed, + identity_configured=identity_configured, + allow_close=allow_close, + ) + + +def create_dashboard_chrome( + root: Any, + *, + title: str, + subtitle: str, +) -> DashboardChrome: + """Install the draggable frame and full controls used by the dashboard.""" + + return create_window_chrome( + root, + title=title, + subtitle=subtitle, + show_close=True, + show_minimize=True, + allow_close=True, + ) + + +def show_themed_dialog( + parent: Any, + *, + title: str, + message: str, + kind: DialogKind = "info", + confirm: bool = False, + accept_text: str = "OK", + cancel_text: str = "CANCEL", + window_icon: Any | None = None, +) -> bool: + """Show one frameless modal dialog matching the native launcher palette.""" + + import tkinter as tk + + palette = dialog_palette(kind) + dialog = tk.Toplevel(parent) + dialog.withdraw() + dialog.title(title) + dialog.configure(background=palette.accent) + dialog.resizable(False, False) + dialog.overrideredirect(True) + if window_icon is not None: + try: + dialog.iconphoto(False, window_icon) + except tk.TclError: + pass + try: + dialog.transient(parent) + except tk.TclError: + pass + result = False + + def finish(value: bool) -> None: + nonlocal result + result = value + try: + dialog.grab_release() + except tk.TclError: + pass + dialog.destroy() + + border = tk.Frame(dialog, background=palette.accent) + border.pack(fill="both", expand=True) + surface = tk.Frame(border, background="#191311") + surface.pack(fill="both", expand=True, padx=1, pady=1) + header = tk.Frame(surface, background="#24170f", height=66) + header.pack(fill="x") + header.pack_propagate(False) + tk_accent = tk.Frame(header, background=palette.accent, width=5) + tk_accent.pack(side="left", fill="y") + header_copy = tk.Frame(header, background="#24170f") + header_copy.pack(side="left", fill="both", expand=True, padx=16, pady=10) + eyebrow = tk.Label( + header_copy, + text="PRISTONTALE EU · LINUX LAUNCHER", + background="#24170f", + foreground=palette.accent, + font=("Sans", 7, "bold"), + anchor="w", + ) + eyebrow.pack(fill="x") + title_label = tk.Label( + header_copy, + text=title, + background="#24170f", + foreground="#fff0d2", + font=("Georgia", 15, "bold"), + anchor="w", + ) + title_label.pack(fill="x", pady=(1, 0)) + close_button = tk.Button( + header, + text="×", + command=lambda: finish(False), + background="#24170f", + foreground="#c9b7a4", + activebackground="#a63f2f", + activeforeground="#ffffff", + font=("Sans", 12), + width=4, + borderwidth=0, + highlightthickness=0, + relief="flat", + cursor="hand2", + takefocus=False, + ) + close_button.pack(side="right", fill="y") + body = tk.Frame(surface, background="#191311", padx=20, pady=20) + body.pack(fill="both", expand=True) + modal_icon: Any | None = None + if window_icon is not None: + try: + factor = icon_subsample_factor( + window_icon.width(), + window_icon.height(), + 46, + ) + modal_icon = window_icon.subsample(factor, factor) + except (tk.TclError, ValueError): + modal_icon = None + if modal_icon is not None: + icon_host = tk.Frame( + body, + width=46, + height=46, + background="#191311", + borderwidth=0, + highlightthickness=0, + ) + icon_host.pack(side="left", anchor="n", padx=(0, 16)) + icon_host.pack_propagate(False) + icon = tk.Label( + icon_host, + image=modal_icon, + background="#191311", + borderwidth=0, + highlightthickness=0, + ) + icon.place(relx=0.5, rely=0.5, anchor="center") + else: + icon = tk.Canvas( + body, + width=46, + height=46, + background="#191311", + borderwidth=0, + highlightthickness=0, + ) + icon.create_oval( + 3, + 3, + 43, + 43, + fill=palette.accent_soft, + outline=palette.accent, + width=2, + ) + icon.create_text( + 23, + 23, + text=palette.glyph, + fill=palette.accent, + font=("Sans", 17, "bold"), + ) + icon.pack(side="left", anchor="n", padx=(0, 16)) + message_label = tk.Label( + body, + text=message, + background="#191311", + foreground="#ded1c4", + font=("Sans", 10), + justify="left", + anchor="w", + wraplength=420, + ) + message_label.pack(side="left", fill="both", expand=True, anchor="n") + footer = tk.Frame(surface, background="#100d0c", padx=18, pady=12) + footer.pack(fill="x") + + def button( + container: Any, + *, + label: str, + command: Callable[[], None], + primary: bool, + ) -> Any: + background = palette.accent if primary else "#2a201c" + foreground = "#17100d" if primary else "#ead8c2" + active_background = "#efae57" if primary else "#3a2b25" + return tk.Button( + container, + text=label, + command=command, + background=background, + foreground=foreground, + activebackground=active_background, + activeforeground="#ffffff" if not primary else "#17100d", + font=("Sans", 8, "bold"), + padx=16, + pady=7, + borderwidth=0, + highlightthickness=0, + relief="flat", + cursor="hand2", + ) + + accept_button = button( + footer, + label=accept_text, + command=lambda: finish(True), + primary=True, + ) + accept_button.pack(side="right") + if confirm: + cancel_button = button( + footer, + label=cancel_text, + command=lambda: finish(False), + primary=False, + ) + cancel_button.pack(side="right", padx=(0, 8)) + _bind_window_drag(dialog, header, header_copy, eyebrow, title_label) + dialog.bind("", lambda _event: finish(False)) + dialog.bind("", lambda _event: finish(True)) + parent.update_idletasks() + dialog.update_idletasks() + width = 560 + height = max(218, dialog.winfo_reqheight()) + dialog.geometry( + centered_window_geometry( + width, + height, + parent_x=parent.winfo_rootx(), + parent_y=parent.winfo_rooty(), + parent_width=parent.winfo_width(), + parent_height=parent.winfo_height(), + ) + ) + dialog.deiconify() + dialog.lift() + dialog.grab_set() + accept_button.focus_set() + parent.wait_window(dialog) + return result