diff --git a/install.sh b/install.sh index a88ff46..628aa65 100644 --- a/install.sh +++ b/install.sh @@ -3,11 +3,12 @@ set -euo pipefail SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -SOURCE_VERSION="$(sed -n 's/^__version__ = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"$/\1/p' "${SOURCE_ROOT}/ptlinux/__init__.py")" +SEMVER_PATTERN='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(beta|rc)\.(0|[1-9][0-9]*))?' +SOURCE_VERSION="$(sed -n 's/^__version__ = "\([^"]*\)"$/\1/p' "${SOURCE_ROOT}/ptlinux/__init__.py")" VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}" -if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +if [[ ! "$VERSION" =~ ^${SEMVER_PATTERN}$ ]] then - printf 'Invalid launcher version: %s (expected X.Y.Z)\n' "$VERSION" >&2 + printf 'Invalid launcher version: %s (expected X.Y.Z, X.Y.Z-beta.N, or X.Y.Z-rc.N)\n' "$VERSION" >&2 exit 2 fi if [[ "$VERSION" != "$SOURCE_VERSION" ]] @@ -15,6 +16,14 @@ then printf 'Launcher version %s does not match source version %s.\n' "$VERSION" "$SOURCE_VERSION" >&2 exit 2 fi +case "$VERSION" in + *-beta.*|*-rc.*) + RELEASE_CHANNEL="prerelease" + ;; + *) + RELEASE_CHANNEL="stable" + ;; +esac DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}" BIN_HOME="${HOME}/.local/bin" APPLICATION_HOME="${DATA_HOME}/applications" @@ -212,6 +221,13 @@ then printf '%s\n' "Python 3.11 or newer is required." >&2 exit 1 fi +if ! "$PYTHON_BIN" -I -B -c \ + 'import sys; sys.path.insert(0, sys.argv[1]); from ptlinux.update_resolver import validate_channel_version; validate_channel_version(sys.argv[2], sys.argv[3])' \ + "$SOURCE_ROOT" "$RELEASE_CHANNEL" "$VERSION" +then + printf '%s\n' "Launcher version and release channel validation failed." >&2 + exit 1 +fi install -d -m 0755 "$APP_ROOT" "$RELEASES_ROOT" "$BIN_HOME" "$APPLICATION_HOME" "$PIXMAP_HOME" STAGING_RELEASE="${RELEASES_ROOT}/.${VERSION}.$$.staging" @@ -260,7 +276,8 @@ install -m 0644 \ install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py" install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py" install -m 0644 "${SOURCE_ROOT}/ptdiag/logs.py" "${STAGING_RELEASE}/ptdiag/logs.py" -printf '{\n "schema": 1,\n "version": "%s"\n}\n' "$VERSION" > "${STAGING_RELEASE}/release.json" +printf '{\n "channel": "%s",\n "config_schema": 1,\n "minimum_config_schema": 1,\n "schema": 1,\n "tag": "v%s",\n "version": "%s"\n}\n' \ + "$RELEASE_CHANNEL" "$VERSION" "$VERSION" > "${STAGING_RELEASE}/release.json" chmod 0644 "${STAGING_RELEASE}/release.json" if ! "$PYTHON_BIN" -I -B -c \ @@ -301,7 +318,7 @@ CURRENT_TARGET="" if [[ -L "${APP_ROOT}/current" ]] then CURRENT_TARGET="$(readlink -- "${APP_ROOT}/current")" - if [[ ! "$CURRENT_TARGET" =~ ^releases/[0-9]+\.[0-9]+\.[0-9]+$ ]] + if [[ ! "$CURRENT_TARGET" =~ ^releases/${SEMVER_PATTERN}$ ]] then printf 'Existing launcher current link is unsafe: %s\n' "$CURRENT_TARGET" >&2 exit 1 @@ -319,7 +336,7 @@ fi if [[ -L "${APP_ROOT}/previous" ]] then PREVIOUS_TARGET="$(readlink -- "${APP_ROOT}/previous")" - if [[ ! "$PREVIOUS_TARGET" =~ ^releases/[0-9]+\.[0-9]+\.[0-9]+$ \ + if [[ ! "$PREVIOUS_TARGET" =~ ^releases/${SEMVER_PATTERN}$ \ || ! -d "${APP_ROOT}/${PREVIOUS_TARGET}" ]] then printf 'Existing launcher previous link is unsafe or broken: %s\n' "$PREVIOUS_TARGET" >&2 diff --git a/ptlinux/cli.py b/ptlinux/cli.py index c265f96..6152db1 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -14,6 +14,7 @@ import sys import threading import time from typing import Callable, Sequence +from uuid import uuid4 from ptdiag.logs import ( LogFileState, @@ -78,6 +79,7 @@ from .self_update import ( update_check_to_dict, verify_launcher_update_channel, ) +from .update_resolver import STABLE_CHANNEL from .telemetry import ( TelemetryService, classify_network_failure, @@ -92,6 +94,11 @@ from .umu import ( provision_umu, provision_umu_for_startup, ) +from .window_diagnostics import ( + WINDOW_DIAGNOSTICS_ENV, + WINDOW_DIAGNOSTICS_SESSION_ENV, + parse_window_diagnostic, +) def build_parser() -> argparse.ArgumentParser: @@ -262,8 +269,13 @@ def build_parser() -> argparse.ArgumentParser: ) launcher_action.add_argument( "--rollback", - action="store_true", - help="switch back to the previously installed launcher release", + nargs="?", + const="", + metavar="VERSION", + help=( + "switch to VERSION from signed release history; without VERSION, " + "switch to the locally retained previous release" + ), ) launcher_update.add_argument( "--manifest-url", @@ -274,6 +286,10 @@ def build_parser() -> argparse.ArgumentParser: type=Path, help="override the pinned launcher-update public key", ) + launcher_update.add_argument( + "--history-url", + help="override the signed launcher release-history URL", + ) return parser @@ -496,6 +512,7 @@ def _dispatch_command( arguments.manifest_url, arguments.public_key, arguments.json, + history_url=arguments.history_url, ) parser.error(f"unknown command: {command}") @@ -516,7 +533,7 @@ def _command_telemetry_events( if command == "launcher-update" and not ( bool(getattr(arguments, "check", False)) or bool(getattr(arguments, "verify", False)) - or bool(getattr(arguments, "rollback", False)) + or getattr(arguments, "rollback", None) is not None ): return ( "launcher_update", @@ -1282,9 +1299,21 @@ def _play_locked( ) -> int: telemetry = current_telemetry() launch_requested_at = time.monotonic() + window_diagnostics_active = ( + telemetry is not None + and not dry_run + and bool(getattr(telemetry, "enabled", True)) + ) + launch_session_id = uuid4().hex if window_diagnostics_active else None + launch_session_metadata = ( + {"launch_session_id": launch_session_id} + if launch_session_id is not None + else {} + ) if telemetry is not None and not dry_run: telemetry.send_event( "game_launch_requested", + metadata=launch_session_metadata, context={"operation": "launch_game", "stage": "preflight"}, ) if game_dir is None and game_import_requires_verification(paths): @@ -1387,6 +1416,7 @@ def _play_locked( telemetry.send_event( "game_launch_started", metadata={ + **launch_session_metadata, "launch_duration_ms": round( (game_started_at - launch_requested_at) * 1000 ), @@ -1404,10 +1434,31 @@ def _play_locked( else None ) - def observe_game_output(line: str) -> str: + def observe_game_output(line: str) -> str | None: """Timestamp launch success when the supervisor first confirms readiness.""" nonlocal launch_success_reported + window_diagnostic = parse_window_diagnostic(line) + if window_diagnostic is not None: + diagnostic_session_id = window_diagnostic.get("session_id") + if launch_session_id is None or diagnostic_session_id != launch_session_id: + return line + if telemetry is not None and window_diagnostics_active: + with launch_success_lock: + telemetry.send_event( + "game_window_diagnostic", + severity=_window_diagnostic_severity(window_diagnostic), + metadata={ + "runtime": selection.kind, + "realm": start_option.name, + **window_diagnostic, + }, + context={ + "operation": "launch_game", + "stage": "window_diagnostics", + }, + ) + return None live_readiness_signal = _game_readiness_signal(line) if telemetry is None or dry_run or live_readiness_signal is None: return line @@ -1418,6 +1469,7 @@ def _play_locked( launch_success_reported = telemetry.send_event( "game_launch_success", metadata={ + **launch_session_metadata, "launch_duration_ms": round( (ready_at - launch_requested_at) * 1000 ), @@ -1444,6 +1496,14 @@ def _play_locked( concurrent_prefix_launch=reuse_prepared, check=False, output_line_filter=observe_game_output, + extra_environment=( + { + WINDOW_DIAGNOSTICS_ENV: "1", + WINDOW_DIAGNOSTICS_SESSION_ENV: launch_session_id, + } + if launch_session_id is not None + else None + ), pass_fds=game_lease_fds, ) except KeyboardInterrupt: @@ -1453,6 +1513,7 @@ def _play_locked( severity="warning", message="Game launch was interrupted", metadata={ + **launch_session_metadata, "runtime_seconds": round(time.monotonic() - game_started_at), "clean_exit": False, "session_duration_class": _session_duration_class( @@ -1524,6 +1585,7 @@ def _play_locked( game_runtime_seconds = max(0.0, time.monotonic() - game_started_at) if telemetry is not None and not dry_run: common_game_metadata: dict[str, object] = { + **launch_session_metadata, "runtime_seconds": round(game_runtime_seconds), "clean_exit": clean_exit, "timed_out": outcome.timed_out, @@ -1547,6 +1609,7 @@ def _play_locked( telemetry.send_event( "game_launch_success", metadata={ + **launch_session_metadata, "detection_duration_ms": round(game_runtime_seconds * 1000), "detected_after_exit": True, "game_window_ready": game_window_ready, @@ -1858,6 +1921,33 @@ def _game_readiness_signal(output: str) -> str | None: return None +def _window_diagnostic_severity(diagnostic: dict[str, object]) -> str: + name = diagnostic.get("diagnostic") + details = diagnostic.get("details") + if isinstance(name, str) and ( + name.endswith(("_failed", "_unavailable")) + or name in { + "host_hidden_state_cleared", + "host_inactive_remap", + "host_minimize_latched", + } + ): + return "warning" + if isinstance(details, dict): + if name == "host_scaling_configured" and not all( + details.get(key) is True + for key in ( + "resize_increments_applied", + "scaling_enabled", + "compositing_preferred", + ) + ): + return "warning" + if name == "scaling_configured" and details.get("enabled") is not True: + return "warning" + return "debug" + + def _print_game_outcome( outcome: CommandOutcome, current_game_log: dict[str, object] | None, @@ -2015,13 +2105,28 @@ def _launcher_update( paths: AppPaths, check_only: bool, verify: bool, - rollback: bool, + rollback: str | bool | None, manifest_url: str | None, public_key: Path | None, as_json: bool, + *, + history_url: str | None = None, ) -> int: - if rollback: - result = rollback_launcher_update(paths) + try: + selected_channel = load_settings(paths).update_channel + except (OSError, TypeError, ValueError): + # Keep update recovery usable when settings are absent or damaged. + selected_channel = STABLE_CHANNEL + rollback_requested = rollback is not None and rollback is not False + if rollback_requested: + target_version = rollback if isinstance(rollback, str) and rollback else None + result = rollback_launcher_update( + paths, + target_version=target_version, + selected_channel=selected_channel, + history_url=history_url, + public_key_path=public_key, + ) _emit( { "ok": True, @@ -2034,6 +2139,7 @@ def _launcher_update( check = check_for_launcher_update( manifest_url=manifest_url, public_key_path=public_key, + channel=selected_channel, ) _emit( { @@ -2048,6 +2154,7 @@ def _launcher_update( result = verify_launcher_update_channel( manifest_url=manifest_url, public_key_path=public_key, + channel=selected_channel, ) _print_operation_status(as_json, 2, 2, "Published launcher release verified.") _emit( @@ -2063,6 +2170,7 @@ def _launcher_update( paths, manifest_url=manifest_url, public_key_path=public_key, + channel=selected_channel, ) _print_operation_status( as_json, diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index 475b7ca..7d6a1f1 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -18,6 +18,7 @@ from .desktop_identity import ( APPLICATION_WM_CLASS, APPLICATION_WM_INSTANCE, ) +from .window_diagnostics import WindowDiagnosticRecorder WINDOW_TITLE = APPLICATION_NAME WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0 @@ -120,6 +121,22 @@ def _motif_value_with_enabled_action(value: int, action: int) -> int: return value | action +def _motif_value_allows_action(value: int, action: int) -> bool: + """Interpret a Motif inclusive or all-except action mask.""" + + if value & _MOTIF_ALL: + return value & action == 0 + return value & action != 0 + + +def _wm_state_name(value: int | None) -> str: + return { + _X_WM_STATE_WITHDRAWN: "withdrawn", + _X_WM_STATE_NORMAL: "normal", + _X_WM_STATE_ICONIC: "iconic", + }.get(value, "unknown" if value is None else f"other_{value}") + + def _motif_hints_with_scaling(hints: Sequence[int]) -> tuple[int, ...]: """Add native resize/maximize actions while retaining every other hint.""" @@ -483,6 +500,147 @@ class _X11: for atom in self._screen_filling_state_atoms() ) + def window_net_wm_state_flags(self, window: int) -> dict[str, bool | None]: + """Read EWMH state flags that distinguish minimize, remap, and raise.""" + + states = self._long_property(window, b"_NET_WM_STATE", 64) + + def state(name: bytes) -> bool | None: + atom = self._intern_atom(name, only_if_exists=True) + if states is None or atom == 0: + return None + return atom in states + + return { + "net_wm_state_present": states is not None, + "fullscreen": state(b"_NET_WM_STATE_FULLSCREEN"), + "maximized_horizontal": state(b"_NET_WM_STATE_MAXIMIZED_HORZ"), + "maximized_vertical": state(b"_NET_WM_STATE_MAXIMIZED_VERT"), + "hidden": state(b"_NET_WM_STATE_HIDDEN"), + "above": state(b"_NET_WM_STATE_ABOVE"), + "demands_attention": state(b"_NET_WM_STATE_DEMANDS_ATTENTION"), + "skip_taskbar": state(b"_NET_WM_STATE_SKIP_TASKBAR"), + } + + def window_diagnostic_snapshot(self, window: int) -> dict[str, object]: + """Read the WM properties needed to diagnose focus and maximize behavior.""" + + state = self.window_state(window) + size = self.window_size(window) + net_state_flags = self.window_net_wm_state_flags(window) + allowed_actions = self._long_property( + window, + b"_NET_WM_ALLOWED_ACTIONS", + 64, + ) + + def contains(values: tuple[int, ...] | None, atom: int) -> bool | None: + if values is None or atom == 0: + return None + return atom in values + + resize_action = self._intern_atom( + b"_NET_WM_ACTION_RESIZE", + only_if_exists=True, + ) + maximize_horizontal_action = self._intern_atom( + b"_NET_WM_ACTION_MAXIMIZE_HORZ", + only_if_exists=True, + ) + maximize_vertical_action = self._intern_atom( + b"_NET_WM_ACTION_MAXIMIZE_VERT", + only_if_exists=True, + ) + minimize_action = self._intern_atom( + b"_NET_WM_ACTION_MINIMIZE", + only_if_exists=True, + ) + normal_hints = self._long_property(window, b"WM_NORMAL_HINTS", 18) + normal_flags = normal_hints[0] if normal_hints else None + has_minimum = bool(normal_flags & _P_MIN_SIZE) if normal_flags is not None else None + has_maximum = bool(normal_flags & _P_MAX_SIZE) if normal_flags is not None else None + fixed_size = ( + bool( + has_minimum + and has_maximum + and normal_hints is not None + and len(normal_hints) >= 9 + and normal_hints[5:7] == normal_hints[7:9] + ) + if normal_flags is not None + else None + ) + motif_hints = self._long_property(window, b"_MOTIF_WM_HINTS", 5) + motif_flags = motif_hints[0] if motif_hints and len(motif_hints) >= 3 else None + motif_functions = motif_hints[1] if motif_flags is not None else None + motif_decorations = motif_hints[2] if motif_flags is not None else None + return { + "wm_state": _wm_state_name(state), + "active": self.window_is_active(window), + "focused": self.window_is_focused(window), + "width": size[0] if size is not None else None, + "height": size[1] if size is not None else None, + "borderless": self.window_is_borderless(window), + **net_state_flags, + "allowed_actions_present": allowed_actions is not None, + "allows_resize": contains(allowed_actions, resize_action), + "allows_minimize": contains(allowed_actions, minimize_action), + "allows_maximize_horizontal": contains( + allowed_actions, + maximize_horizontal_action, + ), + "allows_maximize_vertical": contains( + allowed_actions, + maximize_vertical_action, + ), + "normal_hints_present": normal_hints is not None, + "normal_minimum_size": has_minimum, + "normal_maximum_size": has_maximum, + "normal_fixed_size": fixed_size, + "normal_resize_increment": ( + bool(normal_flags & _P_RESIZE_INC) + if normal_flags is not None + else None + ), + "motif_hints_present": motif_flags is not None, + "motif_allows_resize": ( + _motif_value_allows_action(motif_functions, _MOTIF_FUNC_RESIZE) + if ( + motif_flags is not None + and motif_flags & _MOTIF_HINTS_FUNCTIONS + and motif_functions is not None + ) + else None + ), + "motif_allows_maximize": ( + _motif_value_allows_action(motif_functions, _MOTIF_FUNC_MAXIMIZE) + if ( + motif_flags is not None + and motif_flags & _MOTIF_HINTS_FUNCTIONS + and motif_functions is not None + ) + else None + ), + "motif_resize_decoration": ( + _motif_value_allows_action(motif_decorations, _MOTIF_DECOR_RESIZE) + if ( + motif_flags is not None + and motif_flags & _MOTIF_HINTS_DECORATIONS + and motif_decorations is not None + ) + else None + ), + "motif_maximize_decoration": ( + _motif_value_allows_action(motif_decorations, _MOTIF_DECOR_MAXIMIZE) + if ( + motif_flags is not None + and motif_flags & _MOTIF_HINTS_DECORATIONS + and motif_decorations is not None + ) + else None + ), + } + def force_windowed_state(self, window: int) -> bool: """Remove fullscreen/maximized EWMH state and wait for WM acknowledgement. @@ -1283,6 +1441,25 @@ class _X11: self.library.XCloseDisplay.restype = ctypes.c_int +def _window_diagnostic_details(mapper: _X11, window: int) -> dict[str, object]: + """Keep diagnostic reads from affecting window supervision.""" + + try: + return mapper.window_diagnostic_snapshot(window) + except Exception as exc: + return {"snapshot_error": type(exc).__name__} + + +def _sample_window_diagnostic_details( + diagnostics: WindowDiagnosticRecorder | None, + mapper: _X11, + window: int, +) -> dict[str, object]: + if diagnostics is None or not diagnostics.accepts_samples: + return {} + return _window_diagnostic_details(mapper, window) + + def _prepare_game_window( mapper: _X11, window: int, @@ -1404,22 +1581,54 @@ def _focus_game_window(mapper: _X11, window: int) -> None: ) -def _repair_game_window_focus(mapper: _X11, window: int) -> bool: +def _repair_game_window_focus( + mapper: _X11, + window: int, + *, + diagnostics: WindowDiagnosticRecorder | None = None, +) -> bool: """Repair Wine's input focus only while its desktop window remains active.""" try: - if mapper.window_state(window) == _X_WM_STATE_ICONIC: + state = mapper.window_state(window) + active = mapper.window_is_active(window) + focused = mapper.window_is_focused(window) if active else False + if diagnostics is not None: + diagnostics.transition( + "focus_gate", + (state, active, focused), + "focus_state_changed", + window_id=window, + wm_state=_wm_state_name(state), + active=active, + focused=focused, + ) + if state == _X_WM_STATE_ICONIC: return False - if not mapper.window_is_active(window): + if not active: return False - if mapper.window_is_focused(window): + if focused: return True except Exception: return False try: mapper.restore_input_focus(window) - return mapper.window_is_focused(window) + repaired = mapper.window_is_focused(window) + if diagnostics is not None: + diagnostics.record( + "focus_repair", + window_id=window, + succeeded=repaired, + **_sample_window_diagnostic_details(diagnostics, mapper, window), + ) + return repaired except Exception as exc: + if diagnostics is not None: + diagnostics.record( + "focus_repair_failed", + window_id=window, + error_type=type(exc).__name__, + ) print( f"Warning: could not repair game-window focus ({exc}); continuing " "normally", @@ -1429,22 +1638,62 @@ def _repair_game_window_focus(mapper: _X11, window: int) -> bool: return False -def _repair_gamescope_game_window_focus(mapper: _X11, window: int) -> bool: +def _repair_gamescope_game_window_focus( + mapper: _X11, + window: int, + *, + diagnostics: WindowDiagnosticRecorder | None = None, +) -> bool: """Restore nested focus without asking the desktop to activate Gamescope.""" - if not _gamescope_host_focus_is_allowed(): + host_focus = _gamescope_host_focus_status() + if diagnostics is not None: + diagnostics.transition( + "host_focus_bridge", + (host_focus.get("allowed"), host_focus.get("bridge")), + "host_focus_bridge_changed", + **host_focus, + ) + if host_focus["allowed"] is not True: return False try: - if mapper.window_state(window) == _X_WM_STATE_ICONIC: + state = mapper.window_state(window) + focused = mapper.window_is_focused(window) + if diagnostics is not None: + diagnostics.transition( + "nested_focus_gate", + (state, focused), + "nested_focus_state_changed", + window_id=window, + wm_state=_wm_state_name(state), + focused=focused, + ) + if state == _X_WM_STATE_ICONIC: return False - if mapper.window_is_focused(window): + if focused: return True except Exception: return False try: mapper.restore_input_focus(window) - return mapper.window_is_focused(window) + repaired = mapper.window_is_focused(window) + if diagnostics is not None: + diagnostics.record( + "nested_focus_repair", + window_id=window, + succeeded=repaired, + host_focus=host_focus, + **_sample_window_diagnostic_details(diagnostics, mapper, window), + ) + return repaired except Exception as exc: + if diagnostics is not None: + diagnostics.record( + "nested_focus_repair_failed", + window_id=window, + error_type=type(exc).__name__, + host_focus=host_focus, + ) print( f"Warning: could not restore nested game input focus ({exc}); " "continuing normally", @@ -1461,22 +1710,47 @@ def _gamescope_host_focus_is_allowed( ) -> bool: """Honor the outer X11 window's current focus without trusting stale state.""" + return _gamescope_host_focus_status( + environment=environment, + now=now, + )["allowed"] is True + + +def _gamescope_host_focus_status( + *, + environment: Mapping[str, str] | None = None, + now: float | None = None, +) -> dict[str, object]: + """Explain whether the nested supervisor may repair input focus.""" + env = os.environ if environment is None else environment state_path = env.get(GAMESCOPE_HOST_FOCUS_STATE_ENV, "").strip() if not state_path: # Direct invocations and native Wayland Gamescope have no X11 host-state # bridge. Retain their existing nested focus behavior. - return True + return {"allowed": True, "bridge": "absent"} + if state_path == "unavailable": + return {"allowed": False, "bridge": "unavailable"} try: fields = Path(state_path).read_text(encoding="ascii").split() - if len(fields) != 2 or fields[0] != "1": - return False + if len(fields) != 2 or fields[0] not in {"0", "1"}: + return {"allowed": False, "bridge": "invalid"} published_at = float(fields[1]) except (OSError, UnicodeError, ValueError): - return False + return {"allowed": False, "bridge": "unreadable"} observed_at = time.monotonic() if now is None else now age = observed_at - published_at - return 0.0 <= age <= GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS + age_ms = round(age * 1000) + if age < 0.0: + return {"allowed": False, "bridge": "future", "age_ms": age_ms} + if age > GAMESCOPE_HOST_FOCUS_STATE_MAX_AGE_SECONDS: + return {"allowed": False, "bridge": "stale", "age_ms": age_ms} + active = fields[0] == "1" + return { + "allowed": active, + "bridge": "active" if active else "inactive", + "age_ms": age_ms, + } def _resolve_gamescope_game_window( @@ -1485,6 +1759,7 @@ def _resolve_gamescope_game_window( *, window_title: str, excluded_window_ids: Collection[int], + diagnostics: WindowDiagnosticRecorder | None = None, ) -> int | None: """Return a valid nested client across Wine's login-window transition.""" @@ -1492,6 +1767,14 @@ def _resolve_gamescope_game_window( state = mapper.window_state(window) except Exception: state = None + if diagnostics is not None: + diagnostics.transition( + "nested_window_state", + (window, state), + "nested_window_state_changed", + window_id=window, + wm_state=_wm_state_name(state), + ) if state == _X_WM_STATE_ICONIC: # IconicState can be a real desktop minimize request. Monitoring must never # turn that user-controlled state back into a visible window. @@ -1510,6 +1793,13 @@ def _resolve_gamescope_game_window( return None replacement, title = found _apply_game_window_identity(mapper, replacement) + if diagnostics is not None: + diagnostics.record( + "nested_window_replaced", + previous_window_id=window, + window_id=replacement, + **_sample_window_diagnostic_details(diagnostics, mapper, replacement), + ) print( f"Game login window changed; following it inside Gamescope: {title}", file=sys.stderr, @@ -1518,12 +1808,24 @@ def _resolve_gamescope_game_window( return replacement -def _enable_game_window_scaling(mapper: _X11, window: int) -> bool: +def _enable_game_window_scaling( + mapper: _X11, + window: int, + *, + diagnostics: WindowDiagnosticRecorder | None = None, +) -> bool: """Enable confirmed resize/maximize actions without risking game startup.""" try: enabled = mapper.enable_window_scaling(window) except Exception as exc: + if diagnostics is not None: + diagnostics.record( + "scaling_configuration_failed", + window_id=window, + error_type=type(exc).__name__, + **_sample_window_diagnostic_details(diagnostics, mapper, window), + ) print( f"Warning: could not enable game-window scaling ({exc}); " "the fixed-size window will continue normally", @@ -1531,6 +1833,13 @@ def _enable_game_window_scaling(mapper: _X11, window: int) -> bool: flush=True, ) return False + if diagnostics is not None: + diagnostics.record( + "scaling_configured", + window_id=window, + enabled=enabled, + **_sample_window_diagnostic_details(diagnostics, mapper, window), + ) if enabled: print( "Game window is resizable with persistent maximize support.", @@ -1551,12 +1860,20 @@ def _maintain_game_window_scaling( mapper: _X11, process: subprocess.Popen[bytes], window: int, + *, + diagnostics: WindowDiagnosticRecorder | None = None, ) -> int: """Keep host scaling hints across Wine maximize/restore transitions.""" try: mapper.select_scaling_hint_events(window) except Exception as exc: + if diagnostics is not None: + diagnostics.record( + "scaling_monitor_failed", + window_id=window, + error_type=type(exc).__name__, + ) print( f"Warning: could not monitor game-window scaling hints ({exc}); " "leaving the game window fixed-size for consistency", @@ -1565,7 +1882,16 @@ def _maintain_game_window_scaling( ) return int(process.wait()) - if not _enable_game_window_scaling(mapper, window): + scaling_enabled = ( + _enable_game_window_scaling(mapper, window) + if diagnostics is None + else _enable_game_window_scaling( + mapper, + window, + diagnostics=diagnostics, + ) + ) + if not scaling_enabled: return int(process.wait()) repair_failures = 0 @@ -1584,7 +1910,14 @@ def _maintain_game_window_scaling( break now = time.monotonic() if now >= next_focus_repair_at: - _repair_game_window_focus(mapper, window) + if diagnostics is None: + _repair_game_window_focus(mapper, window) + else: + _repair_game_window_focus( + mapper, + window, + diagnostics=diagnostics, + ) next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS if not hints_changed and now < next_scaling_repair_at: continue @@ -1595,6 +1928,13 @@ def _maintain_game_window_scaling( ) except Exception as exc: repair_failures += 1 + if diagnostics is not None: + diagnostics.record( + "scaling_maintenance_failed", + window_id=window, + error_type=type(exc).__name__, + repair_failures=repair_failures, + ) print( f"Warning: could not restore game-window scaling hints ({exc})", file=sys.stderr, @@ -1610,6 +1950,12 @@ def _maintain_game_window_scaling( break else: repair_failures = 0 + if hints_changed and diagnostics is not None: + diagnostics.record( + "scaling_hints_repaired", + window_id=window, + **_sample_window_diagnostic_details(diagnostics, mapper, window), + ) next_scaling_repair_at = time.monotonic() + WINDOW_SCALING_REPAIR_INTERVAL_SECONDS return int(process.wait()) @@ -1618,6 +1964,8 @@ def _maintain_game_window_focus( mapper: _X11, process: subprocess.Popen[bytes], window: int, + *, + diagnostics: WindowDiagnosticRecorder | None = None, ) -> int: """Keep the keyboard-focus repair while retaining Wine's fixed-size hints.""" @@ -1633,7 +1981,14 @@ def _maintain_game_window_focus( now = time.monotonic() if now < next_focus_repair_at: continue - _repair_game_window_focus(mapper, window) + if diagnostics is None: + _repair_game_window_focus(mapper, window) + else: + _repair_game_window_focus( + mapper, + window, + diagnostics=diagnostics, + ) next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS return int(process.wait()) @@ -1645,6 +2000,7 @@ def _maintain_gamescope_game_window_focus( *, window_title: str = WINDOW_TITLE, existing_window_ids: Collection[int] = (), + diagnostics: WindowDiagnosticRecorder | None = None, ) -> int: """Keep a valid, focused nested client through login and host resizing.""" @@ -1658,12 +2014,21 @@ def _maintain_gamescope_game_window_focus( now = time.monotonic() if now < next_focus_repair_at: continue - resolved_window = _resolve_gamescope_game_window( - mapper, - active_window, - window_title=window_title, - excluded_window_ids=retired_window_ids, - ) + if diagnostics is None: + resolved_window = _resolve_gamescope_game_window( + mapper, + active_window, + window_title=window_title, + excluded_window_ids=retired_window_ids, + ) + else: + resolved_window = _resolve_gamescope_game_window( + mapper, + active_window, + window_title=window_title, + excluded_window_ids=retired_window_ids, + diagnostics=diagnostics, + ) if resolved_window is None: next_focus_repair_at = ( time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS @@ -1673,7 +2038,14 @@ def _maintain_gamescope_game_window_focus( if resolved_window_id != active_window: retired_window_ids.add(active_window) active_window = resolved_window_id - _repair_gamescope_game_window_focus(mapper, active_window) + if diagnostics is None: + _repair_gamescope_game_window_focus(mapper, active_window) + else: + _repair_gamescope_game_window_focus( + mapper, + active_window, + diagnostics=diagnostics, + ) next_focus_repair_at = ( time.monotonic() + GAMESCOPE_FOCUS_REPAIR_INTERVAL_SECONDS ) @@ -1686,6 +2058,7 @@ def _guard_game_window_startup( window: int, *, borderless: bool, + diagnostics: WindowDiagnosticRecorder | None = None, ) -> bool: """Recover Wine's login-screen window transition without hiding the client.""" @@ -1698,6 +2071,12 @@ def _guard_game_window_startup( # initial preparation immediately before this guard requested borderless. borderless_was_applied = borderless and initial_borderless is not None except Exception as exc: + if diagnostics is not None: + diagnostics.record( + "startup_monitor_failed", + window_id=window, + error_type=type(exc).__name__, + ) print( f"Warning: could not monitor the game startup window ({exc}); " "continuing normally", @@ -1705,6 +2084,13 @@ def _guard_game_window_startup( flush=True, ) return False + if diagnostics is not None: + diagnostics.record( + "startup_guard_started", + window_id=window, + borderless_requested=borderless, + **_sample_window_diagnostic_details(diagnostics, mapper, window), + ) recovery_count = 0 while process.poll() is None: if time.monotonic() >= deadline: @@ -1716,6 +2102,12 @@ def _guard_game_window_startup( mapper.window_is_borderless(window) if borderless else None ) except Exception as exc: + if diagnostics is not None: + diagnostics.record( + "startup_monitor_failed", + window_id=window, + error_type=type(exc).__name__, + ) print( f"Warning: could not inspect the game startup window ({exc}); " "continuing normally", @@ -1726,6 +2118,16 @@ def _guard_game_window_startup( became_iconic = state == _X_WM_STATE_ICONIC entered_screen_filling_mode = screen_filling is True login_style_reset = borderless_was_applied and borderless_now is False + if diagnostics is not None: + diagnostics.transition( + "startup_window_state", + (state, screen_filling, borderless_now), + "startup_state_changed", + window_id=window, + wm_state=_wm_state_name(state), + screen_filling=screen_filling, + borderless=borderless_now, + ) if became_iconic: print( "Game startup window was minimized; leaving it minimized.", @@ -1750,6 +2152,14 @@ def _guard_game_window_startup( file=sys.stderr, flush=True, ) + if diagnostics is not None: + diagnostics.record( + "startup_recovery_requested", + window_id=window, + reason=reason, + recovery_count=recovery_count, + **_sample_window_diagnostic_details(diagnostics, mapper, window), + ) try: mapper.activate_window(window) time.sleep(WINDOW_ACTIVATION_DELAY_SECONDS) @@ -1766,6 +2176,13 @@ def _guard_game_window_startup( mapper.window_is_borderless(window) is True ) except Exception as exc: + if diagnostics is not None: + diagnostics.record( + "startup_recovery_failed", + window_id=window, + reason=reason, + error_type=type(exc).__name__, + ) print( f"Warning: could not recover the game startup window ({exc}); " "continuing normally", @@ -1773,6 +2190,13 @@ def _guard_game_window_startup( flush=True, ) return False + if diagnostics is not None: + diagnostics.record( + "startup_recovery_completed", + window_id=window, + reason=reason, + **_sample_window_diagnostic_details(diagnostics, mapper, window), + ) if recovery_count >= WINDOW_STARTUP_RECOVERY_LIMIT: print( "Warning: the game repeatedly changed its startup window; " @@ -1796,6 +2220,16 @@ def run_game_window( ) -> int: if not command: raise GameboxRuntimeError("game command cannot be empty") + diagnostics = WindowDiagnosticRecorder("game_window") + diagnostics.record( + "runtime_configured", + borderless=borderless, + allow_window_resizing=allow_window_resizing, + gamescope_scaling=gamescope_scaling, + suppress_wm_ping=suppress_wm_ping, + display_available=bool(os.environ.get("DISPLAY")), + ) + diagnostic_sink = diagnostics if diagnostics.enabled else None mapper: _X11 | None = None existing_window_ids: set[int] = set() try: @@ -1803,19 +2237,32 @@ def run_game_window( existing_window_ids = created_mapper.root_window_ids() mapper = created_mapper except GameboxRuntimeError as error: + diagnostics.record( + "x11_unavailable", + error_type=type(error).__name__, + ) print( f"Warning: {error}; continuing without X11 window styling", file=sys.stderr, flush=True, ) - process = subprocess.Popen([str(value) for value in command]) + try: + process = subprocess.Popen([str(value) for value in command]) + except BaseException: + if mapper is not None: + mapper.close() + diagnostics.close(process_started=False, managed=mapper is not None) + raise if mapper is None: print( f"Game window ready: unmanaged {window_title}", file=sys.stderr, flush=True, ) - return int(process.wait()) + try: + return int(process.wait()) + finally: + diagnostics.close(process_started=True, managed=False) game_window: int | None = None game_title: str | None = None game_window_created_at: float | None = None @@ -1834,6 +2281,16 @@ def run_game_window( game_title = title game_window_created_at = now _apply_game_window_identity(mapper, window) + if diagnostic_sink is not None: + diagnostic_sink.record( + "game_window_discovered", + window_id=window, + **_sample_window_diagnostic_details( + diagnostic_sink, + mapper, + window, + ), + ) print( f"Game window created; preparing it: {title}", file=sys.stderr, @@ -1853,6 +2310,18 @@ def run_game_window( file=sys.stderr, flush=True, ) + if diagnostic_sink is not None: + diagnostic_sink.record( + "game_window_ready", + window_id=game_window, + gamescope_scaling=True, + host_focus=_gamescope_host_focus_status(), + **_sample_window_diagnostic_details( + diagnostic_sink, + mapper, + game_window, + ), + ) if _gamescope_host_focus_is_allowed(): _focus_game_window(mapper, game_window) print( @@ -1867,6 +2336,11 @@ def run_game_window( game_window, window_title=window_title, existing_window_ids=existing_window_ids, + **( + {"diagnostics": diagnostic_sink} + if diagnostic_sink is not None + else {} + ), ) _prepare_game_window( mapper, @@ -1879,12 +2353,28 @@ def run_game_window( file=sys.stderr, flush=True, ) + if diagnostic_sink is not None: + diagnostic_sink.record( + "game_window_ready", + window_id=game_window, + gamescope_scaling=False, + **_sample_window_diagnostic_details( + diagnostic_sink, + mapper, + game_window, + ), + ) _focus_game_window(mapper, game_window) startup_stable = _guard_game_window_startup( mapper, process, game_window, borderless=borderless, + **( + {"diagnostics": diagnostic_sink} + if diagnostic_sink is not None + else {} + ), ) if ( allow_window_resizing @@ -1896,6 +2386,11 @@ def run_game_window( mapper, process, game_window, + **( + {"diagnostics": diagnostic_sink} + if diagnostic_sink is not None + else {} + ), ) if not borderless: if allow_window_resizing and not startup_stable: @@ -1909,6 +2404,11 @@ def run_game_window( mapper, process, game_window, + **( + {"diagnostics": diagnostic_sink} + if diagnostic_sink is not None + else {} + ), ) return int(process.wait()) time.sleep(0.1) @@ -1927,6 +2427,12 @@ def run_game_window( ) return int(process.wait()) finally: + diagnostics.close( + process_started=True, + managed=True, + window_discovered=game_window is not None, + gamescope_scaling=gamescope_scaling, + ) mapper.close() diff --git a/ptlinux/gamescope_runtime.py b/ptlinux/gamescope_runtime.py index b7f3f6e..7c37420 100644 --- a/ptlinux/gamescope_runtime.py +++ b/ptlinux/gamescope_runtime.py @@ -20,7 +20,10 @@ from .gamebox_runtime import ( _X11, _X_WM_STATE_ICONIC, _X_WM_STATE_NORMAL, + _sample_window_diagnostic_details, + _wm_state_name, ) +from .window_diagnostics import WindowDiagnosticRecorder WINDOW_DISCOVERY_TIMEOUT_SECONDS = 10.0 WINDOW_DISCOVERY_POLL_SECONDS = 0.05 @@ -54,18 +57,38 @@ class GamescopeRuntimeError(RuntimeError): class _HostFocusPublisher: """Publish the host WM's focus decision to the nested game supervisor.""" - def __init__(self, file_descriptor: int, path: Path) -> None: + def __init__( + self, + file_descriptor: int, + path: Path, + diagnostics: WindowDiagnosticRecorder | None = None, + ) -> None: self.file_descriptor = file_descriptor self.path = path + self._diagnostics = diagnostics self._last_active: bool | None = None self._last_published_at = 0.0 self._last_reiconify_at = 0.0 self._seen_normal_state = False self._minimized_latched = False + self._updates = 0 + self._monitor_errors = 0 + self._iconic_observations = 0 + self._inactive_remaps = 0 + self._hidden_observations = 0 + self._hidden_state_clears = 0 + self._reiconify_attempts = 0 + self._reiconify_accepted = 0 + self._last_state: int | None = None + self._last_hidden: bool | None = None + self._last_diagnostic_state: tuple[object, ...] | None = None self._publish(False, force=True) @classmethod - def create(cls) -> _HostFocusPublisher: + def create( + cls, + diagnostics: WindowDiagnosticRecorder | None = None, + ) -> _HostFocusPublisher: runtime_directory = os.environ.get("XDG_RUNTIME_DIR") try: file_descriptor, path = tempfile.mkstemp( @@ -77,7 +100,7 @@ class _HostFocusPublisher: prefix="ptlinux-gamescope-focus-", ) try: - return cls(file_descriptor, Path(path)) + return cls(file_descriptor, Path(path), diagnostics) except BaseException: os.close(file_descriptor) Path(path).unlink(missing_ok=True) @@ -85,14 +108,68 @@ class _HostFocusPublisher: def update(self, mapper: _X11, window: int) -> None: now = time.monotonic() + self._updates += 1 try: state = mapper.window_state(window) active = state == _X_WM_STATE_NORMAL and mapper.window_is_active(window) - except Exception: + except Exception as exc: state = None active = False + self._monitor_errors += 1 + if self._diagnostics is not None: + self._diagnostics.transition( + "host_monitor_error", + type(exc).__name__, + "host_monitor_error", + window_id=window, + error_type=type(exc).__name__, + ) + net_wm_state: dict[str, bool | None] = {} + if self._diagnostics is not None: + try: + net_wm_state = mapper.window_net_wm_state_flags(window) + except Exception as exc: + self._monitor_errors += 1 + self._diagnostics.transition( + "host_ewmh_monitor_error", + type(exc).__name__, + "host_ewmh_monitor_error", + window_id=window, + error_type=type(exc).__name__, + ) + hidden = net_wm_state.get("hidden") + if hidden is True: + self._hidden_observations += 1 + if self._last_hidden is True and hidden is False and not active: + self._hidden_state_clears += 1 + if self._diagnostics is not None: + self._diagnostics.record( + "host_hidden_state_cleared", + window_id=window, + observed_active=active, + minimized_latched=self._minimized_latched, + **_sample_window_diagnostic_details( + self._diagnostics, + mapper, + window, + ), + ) + self._last_hidden = hidden + self._last_state = state if state == _X_WM_STATE_ICONIC and self._seen_normal_state: - self._minimized_latched = True + self._iconic_observations += 1 + if not self._minimized_latched: + self._minimized_latched = True + if self._diagnostics is not None: + self._diagnostics.record( + "host_minimize_latched", + window_id=window, + **_sample_window_diagnostic_details( + self._diagnostics, + mapper, + window, + ), + ) elif state == _X_WM_STATE_NORMAL: self._seen_normal_state = True if self._minimized_latched: @@ -100,18 +177,95 @@ class _HostFocusPublisher: # A normal, active window was explicitly restored by the # desktop. Resume nested focus repair for it. self._minimized_latched = False + if self._diagnostics is not None: + self._diagnostics.record( + "host_restore_observed", + window_id=window, + **_sample_window_diagnostic_details( + self._diagnostics, + mapper, + window, + ), + ) elif now - self._last_reiconify_at >= HOST_REICONIFY_INTERVAL_SECONDS: # Gamescope's SDL visibility handoff uses XMapRaised. If it # remaps an inactive window after a user minimize, preserve # the user's state instead of letting it cover other apps. + self._inactive_remaps += 1 + self._reiconify_attempts += 1 + error_type: str | None = None try: - mapper.minimize_window(window) - except Exception: - pass + accepted = mapper.minimize_window(window) + except Exception as exc: + accepted = False + error_type = type(exc).__name__ + if accepted: + self._reiconify_accepted += 1 self._last_reiconify_at = now active = False + if self._diagnostics is not None: + details: dict[str, object] = { + "window_id": window, + "observed_state": "normal", + "active": False, + "minimize_request_accepted": accepted, + **_sample_window_diagnostic_details( + self._diagnostics, + mapper, + window, + ), + } + if error_type is not None: + details["error_type"] = error_type + self._diagnostics.record( + "host_inactive_remap", + **details, + ) + diagnostic_state = ( + state, + active, + self._minimized_latched, + hidden, + net_wm_state.get("above"), + net_wm_state.get("demands_attention"), + net_wm_state.get("maximized_horizontal"), + net_wm_state.get("maximized_vertical"), + ) + if ( + self._diagnostics is not None + and diagnostic_state != self._last_diagnostic_state + ): + self._last_diagnostic_state = diagnostic_state + self._diagnostics.record( + "host_state_changed", + window_id=window, + observed_wm_state=_wm_state_name(state), + observed_active=active, + minimized_latched=self._minimized_latched, + **_sample_window_diagnostic_details( + self._diagnostics, + mapper, + window, + ), + ) self._publish(active, now=now) + def summary_details(self) -> dict[str, object]: + return { + "focus_updates": self._updates, + "monitor_errors": self._monitor_errors, + "iconic_observations": self._iconic_observations, + "inactive_remaps": self._inactive_remaps, + "hidden_observations": self._hidden_observations, + "hidden_state_clears": self._hidden_state_clears, + "reiconify_attempts": self._reiconify_attempts, + "reiconify_accepted": self._reiconify_accepted, + "seen_normal_state": self._seen_normal_state, + "minimized_latched": self._minimized_latched, + "last_wm_state": _wm_state_name(self._last_state), + "last_active": self._last_active, + } + def _publish( self, active: bool, @@ -404,13 +558,27 @@ def _tune_process_window( height: int, *, host_focus_publisher: _HostFocusPublisher | None = None, + diagnostics: WindowDiagnosticRecorder | None = None, ) -> tuple[int, int] | None: """Stabilize resizing once the SDL host window becomes available.""" deadline = time.monotonic() + WINDOW_DISCOVERY_TIMEOUT_SECONDS + reported_window: int | None = None + last_configuration: tuple[bool, bool, bool] | None = None while process.poll() is None and time.monotonic() < deadline: window = mapper.find_window_by_pid(process.pid) if window is not None: + if diagnostics is not None and window != reported_window: + reported_window = window + diagnostics.record( + "host_window_discovered", + window_id=window, + **_sample_window_diagnostic_details( + diagnostics, + mapper, + window, + ), + ) if host_focus_publisher is not None: host_focus_publisher.update(mapper, window) increments = resize_increments(width, height) @@ -425,6 +593,27 @@ def _tune_process_window( if host_focus_publisher is not None: host_focus_publisher.update(mapper, window) compositing_preferred = mapper.prefer_composited_presentation(window) + configuration = ( + increments_applied, + scaling_enabled, + compositing_preferred, + ) + if diagnostics is not None and configuration != last_configuration: + last_configuration = configuration + diagnostics.record( + "host_scaling_configured", + window_id=window, + resize_increments_applied=increments_applied, + scaling_enabled=scaling_enabled, + compositing_preferred=compositing_preferred, + width_increment=increments[0], + height_increment=increments[1], + **_sample_window_diagnostic_details( + diagnostics, + mapper, + window, + ), + ) if scaling_enabled and increments_applied and compositing_preferred: return increments time.sleep(WINDOW_DISCOVERY_POLL_SECONDS) @@ -437,6 +626,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: if not command: raise GamescopeRuntimeError("Gamescope command cannot be empty") resize_increments(width, height) + diagnostics = WindowDiagnosticRecorder("gamescope_host") environment = os.environ.copy() backend = _select_host_backend(environment) selected_command = list(map(str, command)) @@ -449,6 +639,18 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: using_bundled_gamescope, ) = _select_wayland_gamescope(selected_command[0]) mapper = _open_host_x11() if backend == "sdl" else None + diagnostics.record( + "runtime_configured", + backend=backend, + force_composition=force_composition, + using_bundled_gamescope=using_bundled_gamescope, + display_available=bool(environment.get("DISPLAY")), + wayland_display_available=bool(environment.get("WAYLAND_DISPLAY")), + host_x11_monitor_available=mapper is not None, + canvas_width=width, + canvas_height=height, + ) + diagnostic_sink = diagnostics if diagnostics.enabled else None status_file: Path | None = None host_focus_publisher: _HostFocusPublisher | None = None try: @@ -464,9 +666,14 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: candidate_status_file.unlink(missing_ok=True) if mapper is not None: mapper.close() + diagnostics.close(process_started=False, backend=backend) raise status_file = candidate_status_file except OSError as exc: + diagnostics.record( + "child_supervision_unavailable", + error_type=type(exc).__name__, + ) launch_command = [ selected_command[0], "--backend", @@ -493,8 +700,16 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: environment[GAMESCOPE_HOST_FOCUS_STATE_ENV] = "unavailable" if mapper is not None: try: - publisher = _HostFocusPublisher.create() + publisher = ( + _HostFocusPublisher.create() + if diagnostic_sink is None + else _HostFocusPublisher.create(diagnostics=diagnostic_sink) + ) except OSError as exc: + diagnostics.record( + "host_focus_monitor_unavailable", + error_type=type(exc).__name__, + ) print( f"Warning: Gamescope host-focus monitoring is unavailable: " f"{exc}; nested focus repair will remain disabled.", @@ -512,12 +727,17 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: try: process = subprocess.Popen(launch_command, env=environment) except OSError as exc: + diagnostics.record( + "process_start_failed", + error_type=type(exc).__name__, + ) if mapper is not None: mapper.close() if host_focus_publisher is not None: host_focus_publisher.close() if status_file is not None: status_file.unlink(missing_ok=True) + diagnostics.close(process_started=False, backend=backend) raise GamescopeRuntimeError(f"could not start Gamescope: {exc}") from exc if backend == "wayland" and not force_composition: @@ -541,15 +761,29 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: ) elif mapper is not None: try: - increments = _tune_process_window( - mapper, - process, - width, - height, - host_focus_publisher=host_focus_publisher, - ) + if diagnostic_sink is None: + increments = _tune_process_window( + mapper, + process, + width, + height, + host_focus_publisher=host_focus_publisher, + ) + else: + increments = _tune_process_window( + mapper, + process, + width, + height, + host_focus_publisher=host_focus_publisher, + diagnostics=diagnostic_sink, + ) except Exception as exc: increments = None + diagnostics.record( + "host_scaling_configuration_failed", + error_type=type(exc).__name__, + ) print( f"Warning: Gamescope resize tuning failed: {exc}; " "continuing normally.", @@ -565,6 +799,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: flush=True, ) elif process.poll() is None: + diagnostics.record("host_scaling_unavailable") print( "Warning: Gamescope's host window could not be resize-tuned; " "continuing normally.", @@ -572,6 +807,7 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: flush=True, ) else: + diagnostics.record("host_x11_monitor_unavailable") print( "Gamescope is using its SDL presentation fallback; host X11 resize " "tuning is unavailable.", @@ -595,6 +831,17 @@ def run_gamescope(command: Sequence[str], width: int, height: int) -> int: process.wait() raise finally: + host_summary = ( + host_focus_publisher.summary_details() + if host_focus_publisher is not None + else {} + ) + diagnostics.close( + process_started=True, + backend=backend, + host_focus_monitor_available=host_focus_publisher is not None, + **host_summary, + ) if mapper is not None: mapper.close() if host_focus_publisher is not None: diff --git a/ptlinux/paths.py b/ptlinux/paths.py index 4899c33..710dbc7 100644 --- a/ptlinux/paths.py +++ b/ptlinux/paths.py @@ -120,6 +120,30 @@ class AppPaths: return self.cache / "launcher-update-check.json" + @property + def launcher_release_history_cache(self) -> Path: + """Display-only cache of the last authenticated release history.""" + + return self.cache / "launcher-release-history.json" + + @property + def launcher_update_operations(self) -> Path: + """Bounded local audit trail for launcher updates and rollbacks.""" + + return self.state / "launcher-update-history.json" + + @property + def launcher_update_transaction(self) -> Path: + """Crash-recovery journal for launcher release-link changes.""" + + return self.state / "launcher-update-transaction.json" + + @property + def launcher_release_integrity(self) -> Path: + """Authenticated file inventories retained for installed releases.""" + + return self.state / "launcher-release-integrity" + @property def container_host_repair_state(self) -> Path: """One-shot migration state for the Ubuntu Bubblewrap host repair.""" diff --git a/ptlinux/self_update.py b/ptlinux/self_update.py index c18fe94..9055d2e 100644 --- a/ptlinux/self_update.py +++ b/ptlinux/self_update.py @@ -2,7 +2,8 @@ from __future__ import annotations -from dataclasses import asdict, dataclass +from contextlib import contextmanager +from dataclasses import asdict, dataclass, replace from datetime import datetime import hashlib import json @@ -16,48 +17,84 @@ import sys import tarfile import tempfile import time -from typing import Mapping +from typing import Iterator, Mapping from urllib.error import HTTPError, URLError from urllib.parse import urlsplit, urlunsplit from urllib.request import Request, urlopen from . import __version__ -from .locking import launcher_operation_lock +from .locking import launcher_operation_lock, managed_game_process_is_active from .paths import AppPaths from .telemetry import ( classify_network_failure, current_telemetry, network_http_status, ) +from .update_resolver import ( + ACTIVE_STATUS, + PRERELEASE_CHANNEL, + STABLE_CHANNEL, + SUPPORTED_RELEASE_STATUSES, + ReleaseModelError, + ReleaseRecord, + SemanticVersion, + UpdateResolver, + validate_channel, + validate_channel_version, +) DEFAULT_UPDATE_REPOSITORY_URL = ( "https://git.m4ko.net/m4ko/pristontale-eu-linux" ) -DEFAULT_UPDATE_MANIFEST_URL = ( +DEFAULT_STABLE_UPDATE_MANIFEST_URL = ( f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/stable.json" ) +DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL = ( + f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/prerelease.json" +) +DEFAULT_UPDATE_HISTORY_URL = ( + f"{DEFAULT_UPDATE_REPOSITORY_URL}/raw/branch/main/updates/history.json" +) +# Retain the original name for callers importing the stable default. +DEFAULT_UPDATE_MANIFEST_URL = DEFAULT_STABLE_UPDATE_MANIFEST_URL UPDATE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_MANIFEST_URL" +UPDATE_STABLE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_STABLE_MANIFEST_URL" +UPDATE_PRERELEASE_MANIFEST_URL_VARIABLE = "PTLINUX_UPDATE_PRERELEASE_MANIFEST_URL" +UPDATE_HISTORY_URL_VARIABLE = "PTLINUX_UPDATE_HISTORY_URL" UPDATE_PUBLIC_KEY_VARIABLE = "PTLINUX_UPDATE_PUBLIC_KEY" PUBLIC_KEY_FILENAME = "launcher-update-public.pem" +UPDATE_APPLICATION = "PristonTale-EU" USER_AGENT = f"PT-Linux/{__version__} launcher-updater" READ_SIZE = 256 * 1024 MAX_MANIFEST_BYTES = 128 * 1024 +MAX_HISTORY_BYTES = 512 * 1024 MAX_SIGNATURE_BYTES = 16 * 1024 +MAX_EMBEDDED_RELEASE_NOTES_BYTES = 64 * 1024 MAX_ARCHIVE_BYTES = 32 * 1024 * 1024 MAX_EXTRACTED_BYTES = 64 * 1024 * 1024 MAX_ARCHIVE_MEMBERS = 512 UPDATE_ARCHIVE_PREFIX = "pristontale-eu-linux-update-v" -_VERSION_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z") _SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z") _ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"} -UPDATE_CHECK_CACHE_SCHEMA = 1 +UPDATE_CHECK_CACHE_SCHEMA = 2 +RELEASE_HISTORY_SCHEMA = 1 +MAX_RELEASE_HISTORY = 200 +LOCAL_OPERATION_HISTORY_SCHEMA = 1 +MAX_LOCAL_OPERATION_HISTORY = 30 class LauncherUpdateError(RuntimeError): """Raised when a launcher update cannot be trusted or installed safely.""" +@dataclass(frozen=True) +class ManifestFile: + path: str + size: int + sha256: str + + @dataclass(frozen=True) class UpdateManifest: schema: int @@ -69,6 +106,17 @@ class UpdateManifest: archive_size: int published_at: str notes_url: str | None + tag: str | None = None + commit: str | None = None + status: str = ACTIVE_STATUS + title: str | None = None + summary: str | None = None + notes_markdown: str | None = None + archive_name: str | None = None + files: tuple[ManifestFile, ...] = () + manifest_sha256: str | None = None + config_schema: int = 1 + minimum_config_schema: int = 1 @dataclass(frozen=True) @@ -81,6 +129,7 @@ class LauncherUpdateCheck: manifest_url: str manifest: UpdateManifest checked_at: str | None = None + selected_channel: str = STABLE_CHANNEL @dataclass(frozen=True) @@ -95,6 +144,11 @@ class LauncherUpdateResult: restart_required: bool state_recorded: bool warning: str | None + selected_channel: str = STABLE_CHANNEL + installed_channel: str = STABLE_CHANNEL + tag: str | None = None + manifest_schema: int | None = None + manifest_sha256: str | None = None @dataclass(frozen=True) @@ -106,6 +160,9 @@ class LauncherUpdateVerification: archive_size: int archive_sha256: str payload_files: int + channel: str = STABLE_CHANNEL + tag: str | None = None + manifest_sha256: str | None = None @dataclass(frozen=True) @@ -115,6 +172,10 @@ class LauncherRollbackResult: current_release: str previous_release: str restart_required: bool + from_channel: str = STABLE_CHANNEL + to_channel: str = STABLE_CHANNEL + state_recorded: bool = True + warning: str | None = None @dataclass(frozen=True) @@ -126,6 +187,7 @@ class LauncherInstalledRelease: directory: str active: bool previous: bool + channel: str = STABLE_CHANNEL @dataclass(frozen=True) @@ -137,15 +199,94 @@ class LauncherUpdateHistory: releases: tuple[LauncherInstalledRelease, ...] +@dataclass(frozen=True) +class LauncherRemoteRelease: + """One independently installable release from signed remote history.""" + + version: str + channel: str + tag: str + manifest_url: str + manifest_signature_url: str + release_date: str + release_notes_url: str + status: str + title: str + release_id: int | None = None + config_schema: int | None = None + minimum_config_schema: int | None = None + + def resolver_record(self) -> ReleaseRecord: + return ReleaseRecord( + version=self.version, + channel=self.channel, + status=self.status, + tag=self.tag, + manifest_url=self.manifest_url, + manifest_signature_url=self.manifest_signature_url, + release_date=self.release_date, + release_notes_url=self.release_notes_url, + compatibility={ + "config_schema": self.config_schema, + "minimum_config_schema": self.minimum_config_schema, + }, + ) + + +@dataclass(frozen=True) +class LauncherRemoteHistory: + """Authenticated release catalog used for history and remote rollback.""" + + generated_at: str + history_url: str + releases: tuple[LauncherRemoteRelease, ...] + history_sha256: str + + def default_public_key_path() -> Path: return Path(__file__).with_name(PUBLIC_KEY_FILENAME) def configured_manifest_url( environment: Mapping[str, str] | None = None, + *, + channel: str = STABLE_CHANNEL, ) -> str: + """Return the direct channel URL retained for legacy callers.""" + + urls = configured_manifest_urls(channel=channel, environment=environment) + return urls[-1][1] + + +def configured_manifest_urls( + *, + channel: str = STABLE_CHANNEL, + environment: Mapping[str, str] | None = None, +) -> tuple[tuple[str, str, bool], ...]: + """Return channel, URL, and optionality for authenticated discovery.""" + + try: + validate_channel(channel) + except ReleaseModelError as exc: + raise LauncherUpdateError(str(exc)) from exc env = os.environ if environment is None else environment - return env.get(UPDATE_MANIFEST_URL_VARIABLE, DEFAULT_UPDATE_MANIFEST_URL) + generic_override = env.get(UPDATE_MANIFEST_URL_VARIABLE) + if generic_override: + return ((channel, generic_override, False),) + stable_url = env.get( + UPDATE_STABLE_MANIFEST_URL_VARIABLE, + DEFAULT_STABLE_UPDATE_MANIFEST_URL, + ) + if channel == STABLE_CHANNEL: + return ((STABLE_CHANNEL, stable_url, False),) + prerelease_url = env.get( + UPDATE_PRERELEASE_MANIFEST_URL_VARIABLE, + DEFAULT_PRERELEASE_UPDATE_MANIFEST_URL, + ) + return ( + (STABLE_CHANNEL, stable_url, False), + (PRERELEASE_CHANNEL, prerelease_url, True), + ) def configured_public_key_path( @@ -156,6 +297,140 @@ def configured_public_key_path( return Path(configured).expanduser() if configured else default_public_key_path() +def configured_history_url( + environment: Mapping[str, str] | None = None, +) -> str: + env = os.environ if environment is None else environment + return env.get(UPDATE_HISTORY_URL_VARIABLE, DEFAULT_UPDATE_HISTORY_URL) + + +def fetch_launcher_release_history( + *, + history_url: str | None = None, + public_key_path: Path | None = None, + environment: Mapping[str, str] | None = None, + timeout_seconds: float = 20.0, +) -> LauncherRemoteHistory: + """Fetch and authenticate the immutable-release catalog used for rollback.""" + + url = history_url or configured_history_url(environment) + _require_https_url(url, "release history URL") + key = public_key_path or configured_public_key_path(environment) + history_bytes = _fetch_small_file( + url, + maximum_bytes=MAX_HISTORY_BYTES, + timeout_seconds=timeout_seconds, + label="release history", + ) + signature_bytes = _fetch_small_file( + _signature_url(url), + maximum_bytes=MAX_SIGNATURE_BYTES, + timeout_seconds=timeout_seconds, + label="release history signature", + ) + _verify_manifest_signature(history_bytes, signature_bytes, key) + return parse_launcher_release_history(history_bytes, history_url=url) + + +def cache_launcher_release_history( + cache_path: Path, + history: LauncherRemoteHistory, + *, + public_key_path: Path | None = None, + environment: Mapping[str, str] | None = None, +) -> bool: + """Cache authenticated history for display; installation always re-fetches it.""" + + key = public_key_path or configured_public_key_path(environment) + try: + _write_json( + cache_path, + { + "schema": RELEASE_HISTORY_SCHEMA, + "history_url": history.history_url, + "history_sha256": history.history_sha256, + "public_key_sha256": _sha256_file(key.expanduser().resolve()), + "generated_at": history.generated_at, + "releases": [ + _remote_release_to_history_dict(release) + for release in history.releases + ], + }, + ) + except (OSError, RuntimeError): + return False + return True + + +def read_cached_launcher_release_history( + cache_path: Path, + *, + history_url: str | None = None, + public_key_path: Path | None = None, + environment: Mapping[str, str] | None = None, +) -> LauncherRemoteHistory | None: + """Read display-only history previously authenticated with the current key.""" + + url = history_url or configured_history_url(environment) + key = public_key_path or configured_public_key_path(environment) + try: + _require_https_url(url, "release history URL") + value = json.loads(cache_path.read_text(encoding="utf-8")) + key_sha256 = _sha256_file(key.expanduser().resolve()) + except ( + OSError, + UnicodeError, + json.JSONDecodeError, + LauncherUpdateError, + RuntimeError, + ): + return None + if not isinstance(value, dict): + return None + if value.get("schema") != RELEASE_HISTORY_SCHEMA: + return None + if value.get("history_url") != url or value.get("public_key_sha256") != key_sha256: + return None + stored_digest = value.get("history_sha256") + if not isinstance(stored_digest, str) or _SHA256_PATTERN.fullmatch(stored_digest) is None: + return None + try: + serialized = { + "application": UPDATE_APPLICATION, + "generated_at": value.get("generated_at"), + "releases": value.get("releases"), + "schema": RELEASE_HISTORY_SCHEMA, + } + history = parse_launcher_release_history( + (json.dumps(serialized, sort_keys=True) + "\n").encode("utf-8"), + history_url=url, + ) + except (LauncherUpdateError, TypeError, ValueError): + return None + return replace(history, history_sha256=stored_digest) + + +def _remote_release_to_history_dict( + release: LauncherRemoteRelease, +) -> dict[str, object]: + return { + "channel": release.channel, + "compatibility": { + "config_schema": release.config_schema, + "minimum_config_schema": release.minimum_config_schema, + }, + "manifest_signature_url": release.manifest_signature_url, + "manifest_url": release.manifest_url, + "release_date": release.release_date, + "release_id": release.release_id, + "release_notes_url": release.release_notes_url, + "status": release.status, + "tag": release.tag, + "title": release.title, + "version": release.version, + } + + def check_for_launcher_update( *, current_version: str = __version__, @@ -163,13 +438,97 @@ def check_for_launcher_update( public_key_path: Path | None = None, environment: Mapping[str, str] | None = None, timeout_seconds: float = 20.0, + channel: str = STABLE_CHANNEL, ) -> LauncherUpdateCheck: - """Fetch and authenticate the stable update manifest.""" + """Fetch authenticated channel metadata and resolve the greatest candidate.""" _parse_version(current_version, "current launcher version") - url = manifest_url or configured_manifest_url(environment) - _require_https_url(url, "update manifest URL") + try: + validate_channel(channel) + except ReleaseModelError as exc: + raise LauncherUpdateError(str(exc)) from exc + configured = ( + ((channel, manifest_url, False),) + if manifest_url is not None + else configured_manifest_urls(channel=channel, environment=environment) + ) key = public_key_path or configured_public_key_path(environment) + candidates: list[tuple[UpdateManifest, str]] = [] + for expected_channel, url, optional in configured: + _require_https_url(url, "update manifest URL") + try: + manifest = _fetch_authenticated_manifest( + url, + key, + timeout_seconds=timeout_seconds, + ) + except LauncherUpdateError as exc: + if optional and "HTTP 404" in str(exc): + continue + raise + if manifest.channel != expected_channel: + raise LauncherUpdateError( + f"{expected_channel} channel URL returned a {manifest.channel} manifest" + ) + candidates.append((manifest, url)) + if not candidates: + raise LauncherUpdateError(f"no authenticated {channel} release metadata is available") + try: + resolver = UpdateResolver( + ReleaseRecord( + version=manifest.version, + channel=manifest.channel, + status=manifest.status, + tag=manifest.tag, + manifest_url=url, + ) + for manifest, url in candidates + ) + selected = resolver.latest_eligible(channel) + except ReleaseModelError as exc: + raise LauncherUpdateError(str(exc)) from exc + if selected is None: + # Preserve authenticated metadata for a clear withdrawn/blocked status. + manifest, url = max( + candidates, + key=lambda item: _parse_version(item[0].version, "manifest version"), + ) + else: + manifest, url = next( + item for item in candidates if item[0].version == selected.version + ) + check = _evaluate_launcher_update( + manifest, + current_version, + url, + checked_at=datetime.now().astimezone().isoformat(timespec="seconds"), + selected_channel=channel, + ) + _send_launcher_release_event( + "update_release_discovered", + metadata=_launcher_release_metadata( + current_version=current_version, + target_version=manifest.version, + current_channel=SemanticVersion.parse(current_version).channel, + target_channel=manifest.channel, + selected_channel=channel, + tag=manifest.tag, + manifest_schema=manifest.schema, + manifest_sha256=manifest.manifest_sha256, + rollback=False, + ), + operation="launcher_update", + stage="discovery", + ) + return check + + +def _fetch_authenticated_manifest( + url: str, + public_key_path: Path, + *, + timeout_seconds: float, +) -> UpdateManifest: manifest_bytes = _fetch_small_file( url, maximum_bytes=MAX_MANIFEST_BYTES, @@ -182,14 +541,54 @@ def check_for_launcher_update( timeout_seconds=timeout_seconds, label="update manifest signature", ) - _verify_manifest_signature(manifest_bytes, signature_bytes, key) - manifest = parse_update_manifest(manifest_bytes) - return _evaluate_launcher_update( - manifest, - current_version, - url, - checked_at=datetime.now().astimezone().isoformat(timespec="seconds"), + _verify_manifest_signature(manifest_bytes, signature_bytes, public_key_path) + return parse_update_manifest(manifest_bytes) + + +def _fetch_historical_manifest( + release: LauncherRemoteRelease, + public_key_path: Path, + *, + timeout_seconds: float, +) -> UpdateManifest: + manifest_bytes = _fetch_small_file( + release.manifest_url, + maximum_bytes=MAX_MANIFEST_BYTES, + timeout_seconds=timeout_seconds, + label=f"launcher {release.version} manifest", ) + signature_bytes = _fetch_small_file( + release.manifest_signature_url, + maximum_bytes=MAX_SIGNATURE_BYTES, + timeout_seconds=timeout_seconds, + label=f"launcher {release.version} manifest signature", + ) + _verify_manifest_signature(manifest_bytes, signature_bytes, public_key_path) + manifest = parse_update_manifest(manifest_bytes) + for field, actual, expected in ( + ("version", manifest.version, release.version), + ("channel", manifest.channel, release.channel), + ("tag", manifest.tag, release.tag), + ): + if actual != expected: + raise LauncherUpdateError( + f"historical manifest {field} {actual!r} does not match " + f"signed history {expected!r}" + ) + if ( + release.config_schema is not None + or release.minimum_config_schema is not None + ): + if release.config_schema is None or release.minimum_config_schema is None: + raise LauncherUpdateError( + "signed history has incomplete config compatibility metadata" + ) + manifest = replace( + manifest, + config_schema=release.config_schema, + minimum_config_schema=release.minimum_config_schema, + ) + return manifest def read_cached_launcher_update_check( @@ -199,14 +598,27 @@ def read_cached_launcher_update_check( manifest_url: str | None = None, public_key_path: Path | None = None, environment: Mapping[str, str] | None = None, + channel: str = STABLE_CHANNEL, ) -> LauncherUpdateCheck | None: """Read a display-only result from the last authenticated live check.""" - url = manifest_url or configured_manifest_url(environment) + try: + validate_channel(channel) + except ReleaseModelError: + return None + allowed_urls = ( + {manifest_url} + if manifest_url is not None + else {url for _source_channel, url, _optional in configured_manifest_urls( + channel=channel, + environment=environment, + )} + ) key = public_key_path or configured_public_key_path(environment) try: _parse_version(current_version, "current launcher version") - _require_https_url(url, "update manifest URL") + for allowed_url in allowed_urls: + _require_https_url(allowed_url, "update manifest URL") key_sha256 = _sha256_file(key.expanduser().resolve()) value = json.loads(cache_path.read_text(encoding="utf-8")) except ( @@ -223,7 +635,10 @@ def read_cached_launcher_update_check( return None if value.get("current_version") != current_version: return None - if value.get("manifest_url") != url: + url = value.get("manifest_url") + if not isinstance(url, str) or url not in allowed_urls: + return None + if value.get("selected_channel") != channel: return None if value.get("public_key_sha256") != key_sha256: return None @@ -237,6 +652,15 @@ def read_cached_launcher_update_check( manifest = parse_update_manifest( (json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8") ) + cached_manifest_sha256 = manifest_value.get("manifest_sha256") + if ( + isinstance(cached_manifest_sha256, str) + and _SHA256_PATTERN.fullmatch(cached_manifest_sha256) is not None + ): + manifest = replace( + manifest, + manifest_sha256=cached_manifest_sha256, + ) except (LauncherUpdateError, TypeError, ValueError): return None return _evaluate_launcher_update( @@ -244,6 +668,7 @@ def read_cached_launcher_update_check( current_version, url, checked_at=checked_at, + selected_channel=channel, ) @@ -267,6 +692,7 @@ def cache_launcher_update_check( or datetime.now().astimezone().isoformat(timespec="seconds"), "current_version": check.current_version, "manifest_url": check.manifest_url, + "selected_channel": check.selected_channel, "public_key_sha256": key_sha256, "manifest": asdict(check.manifest), }, @@ -282,18 +708,25 @@ def _evaluate_launcher_update( manifest_url: str, *, checked_at: str | None = None, + selected_channel: str = STABLE_CHANNEL, ) -> LauncherUpdateCheck: + try: + validate_channel(selected_channel) + except ReleaseModelError as exc: + raise LauncherUpdateError(str(exc)) from exc current = _parse_version(current_version, "current launcher version") latest = _parse_version(manifest.version, "manifest version") minimum = _parse_version( manifest.minimum_updater_version, "minimum updater version", ) - available = latest > current + available = latest > current and manifest.status == ACTIVE_STATUS supported = current >= minimum - if not available: + if manifest.status != ACTIVE_STATUS: + reason = f"Launcher {manifest.version} is {manifest.status} and will not be offered." + elif not available: reason = ( - "This launcher is newer than the stable update channel." + "The selected channel will not downgrade this launcher." if latest < current else "The launcher is up to date." ) @@ -313,6 +746,7 @@ def _evaluate_launcher_update( manifest_url=manifest_url, manifest=manifest, checked_at=checked_at, + selected_channel=selected_channel, ) @@ -324,6 +758,7 @@ def install_launcher_update( public_key_path: Path | None = None, environment: Mapping[str, str] | None = None, timeout_seconds: float = 60.0, + channel: str = STABLE_CHANNEL, ) -> LauncherUpdateResult: """Authenticate, stage, validate, and atomically activate one update.""" @@ -333,6 +768,7 @@ def install_launcher_update( public_key_path=public_key_path, environment=environment, timeout_seconds=min(timeout_seconds, 30.0), + channel=channel, ) if not check.available: return LauncherUpdateResult( @@ -346,20 +782,80 @@ def install_launcher_update( restart_required=False, state_recorded=False, warning=None, + selected_channel=check.selected_channel, + installed_channel=SemanticVersion.parse(check.current_version).channel, ) if not check.supported: raise LauncherUpdateError(check.reason) + from_channel = SemanticVersion.parse(check.current_version).channel + event_metadata = _launcher_release_metadata( + current_version=check.current_version, + target_version=check.manifest.version, + current_channel=from_channel, + target_channel=check.manifest.channel, + selected_channel=check.selected_channel, + tag=check.manifest.tag, + manifest_schema=check.manifest.schema, + manifest_sha256=check.manifest.manifest_sha256, + rollback=False, + ) paths.create_base_directories() - with launcher_operation_lock(paths.launcher_update_lock, "launcher-update"): + with _launcher_mutation_lock(paths, "launcher-update"): + _recover_interrupted_activation(paths) + _precheck_release_install(paths, check.manifest) archive = paths.launcher_update_archive(check.manifest.version) _download_archive( check.manifest, archive, timeout_seconds=timeout_seconds, + event_family="update", + event_metadata=event_metadata, + ) + _send_launcher_release_event( + "update_apply_started", + metadata=event_metadata, + operation="launcher_update", + stage="apply", + ) + try: + release_directory = _stage_and_install_release(paths, archive, check.manifest) + previous = _activate_release( + paths, + release_directory, + telemetry_metadata=event_metadata, + restore_reason="failed_update", + ) + except BaseException as exc: + _send_launcher_release_event( + "update_apply_failed", + metadata=event_metadata, + operation="launcher_update", + stage="apply", + severity="error", + message=str(exc), + ) + _record_launcher_operation( + paths, + from_version=check.current_version, + to_version=check.manifest.version, + from_channel=from_channel, + to_channel=check.manifest.channel, + selected_channel=check.selected_channel, + result="failed", + rollback=False, + reason="automatic_update", + tag=check.manifest.tag, + manifest_schema=check.manifest.schema, + manifest_sha256=check.manifest.manifest_sha256, + ) + raise + _send_launcher_release_event( + "update_apply_success", + metadata=event_metadata, + operation="launcher_update", + stage="apply", ) - release_directory = _stage_and_install_release(paths, archive, check.manifest) - previous = _activate_release(paths, release_directory) state_warning = None try: _write_json( @@ -369,6 +865,12 @@ def install_launcher_update( "updated_at": datetime.now().astimezone().isoformat(), "from_version": check.current_version, "to_version": check.manifest.version, + "selected_channel": check.selected_channel, + "from_channel": from_channel, + "to_channel": check.manifest.channel, + "tag": check.manifest.tag, + "manifest_schema": check.manifest.schema, + "manifest_sha256": check.manifest.manifest_sha256, "manifest_url": check.manifest_url, "archive_url": check.manifest.archive_url, "archive_sha256": check.manifest.archive_sha256, @@ -380,6 +882,22 @@ def install_launcher_update( # Activation is the durable user-facing operation. A diagnostics-only # state record must not strand the dashboard on the old release. state_warning = f"launcher updated, but update history could not be saved: {exc}" + history_recorded = _record_launcher_operation( + paths, + from_version=check.current_version, + to_version=check.manifest.version, + from_channel=from_channel, + to_channel=check.manifest.channel, + selected_channel=check.selected_channel, + result="success", + rollback=False, + reason="automatic_update", + tag=check.manifest.tag, + manifest_schema=check.manifest.schema, + manifest_sha256=check.manifest.manifest_sha256, + ) + if not history_recorded and state_warning is None: + state_warning = "launcher updated, but the local operation history could not be saved" return LauncherUpdateResult( changed=True, current_version=check.current_version, @@ -391,6 +909,11 @@ def install_launcher_update( restart_required=True, state_recorded=state_warning is None, warning=state_warning, + selected_channel=check.selected_channel, + installed_channel=check.manifest.channel, + tag=check.manifest.tag, + manifest_schema=check.manifest.schema, + manifest_sha256=check.manifest.manifest_sha256, ) @@ -401,6 +924,7 @@ def verify_launcher_update_channel( public_key_path: Path | None = None, environment: Mapping[str, str] | None = None, timeout_seconds: float = 60.0, + channel: str = STABLE_CHANNEL, ) -> LauncherUpdateVerification: """Authenticate, download, extract, and import a release without activating it.""" @@ -410,6 +934,7 @@ def verify_launcher_update_channel( public_key_path=public_key_path, environment=environment, timeout_seconds=min(timeout_seconds, 30.0), + channel=channel, ) with tempfile.TemporaryDirectory(prefix="ptlinux-update-verification-") as temporary: root = Path(temporary) @@ -418,10 +943,23 @@ def verify_launcher_update_channel( check.manifest, archive, timeout_seconds=timeout_seconds, + event_family="update", + event_metadata=_launcher_release_metadata( + current_version=current_version, + target_version=check.manifest.version, + current_channel=SemanticVersion.parse(current_version).channel, + target_channel=check.manifest.channel, + selected_channel=check.selected_channel, + tag=check.manifest.tag, + manifest_schema=check.manifest.schema, + manifest_sha256=check.manifest.manifest_sha256, + rollback=False, + ), ) staging = root / "release" staging.mkdir(mode=0o700) _extract_release_archive(archive, staging, check.manifest.version) + _verify_manifest_files(staging, check.manifest) staged_version = _validate_release_directory(staging) if staged_version != check.manifest.version: raise LauncherUpdateError( @@ -439,53 +977,304 @@ def verify_launcher_update_channel( archive_size=check.manifest.archive_size, archive_sha256=check.manifest.archive_sha256, payload_files=payload_files, + channel=check.manifest.channel, + tag=check.manifest.tag, + manifest_sha256=check.manifest.manifest_sha256, ) -def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult: - """Swap the current and previous authenticated launcher releases.""" +def launcher_rollback_candidates( + history: LauncherRemoteHistory, + *, + installed_version: str, + selected_channel: str, + show_prereleases: bool | None = None, +) -> tuple[LauncherRemoteRelease, ...]: + """Resolve valid rollback choices from authenticated release history.""" - with launcher_operation_lock(paths.launcher_update_lock, "launcher-rollback"): - current = _release_from_link(paths.launcher_current, paths.launcher_releases) - previous = _release_from_link(paths.launcher_previous, paths.launcher_releases) - if current is None: - raise LauncherUpdateError("the current launcher release link is missing") - if previous is None: - raise LauncherUpdateError("no previous launcher release is available") - current_release = current - previous_release = previous - current_version = _validate_release_directory(current_release) - previous_version = _validate_release_directory(previous_release) - if current_release == previous_release: - raise LauncherUpdateError("current and previous launcher releases are identical") - _replace_release_link( - paths.launcher_previous, - current_release, - paths.launcher_releases, + try: + resolver = UpdateResolver(release.resolver_record() for release in history.releases) + resolved = resolver.rollback_candidates( + installed_version, + selected_channel, + show_prereleases=show_prereleases, ) - _replace_release_link( + except ReleaseModelError as exc: + raise LauncherUpdateError(str(exc)) from exc + by_version = {release.version: release for release in history.releases} + return tuple(by_version[release.version] for release in resolved) + + +def rollback_launcher_update( + paths: AppPaths, + *, + target_version: str | None = None, + selected_channel: str = STABLE_CHANNEL, + history_url: str | None = None, + public_key_path: Path | None = None, + environment: Mapping[str, str] | None = None, + timeout_seconds: float = 60.0, + reason: str = "manual", +) -> LauncherRollbackResult: + """Activate a retained or authenticated historical launcher release.""" + + try: + validate_channel(selected_channel) + except ReleaseModelError as exc: + raise LauncherUpdateError(str(exc)) from exc + if reason not in { + "manual", + "failed_update", + "failed_launch", + "support_recommendation", + "hotfix_recovery", + }: + raise LauncherUpdateError(f"unsupported rollback reason: {reason!r}") + paths.create_base_directories() + with _launcher_mutation_lock(paths, "launcher-rollback"): + _recover_interrupted_activation(paths) + current_release = _release_from_link( paths.launcher_current, - previous_release, paths.launcher_releases, ) - _fsync_directory(paths.app_home) - _write_json( - paths.launcher_update_state, - { - "schema": 1, - "rolled_back_at": datetime.now().astimezone().isoformat(), - "from_version": current_version, - "to_version": previous_version, - "release_directory": str(previous_release), - "previous_release": str(current_release), - }, + if current_release is None: + raise LauncherUpdateError("the current launcher release link is missing") + current_version = _validate_release_directory(current_release) + current_semantic = _parse_version(current_version, "current launcher version") + manifest: UpdateManifest | None = None + archive: Path | None = None + release_id: int | None = None + remote_target = False + retained_compatibility: tuple[int, int] | None = None + if target_version is None: + target_release = _release_from_link( + paths.launcher_previous, + paths.launcher_releases, + ) + if target_release is None: + raise LauncherUpdateError("no previous launcher release is available") + target_version = _validate_release_directory(target_release) + retained_compatibility = _verify_retained_release_integrity( + paths, + target_release, + target_version, + ) + else: + try: + target_semantic = SemanticVersion.parse(target_version) + except ReleaseModelError as exc: + raise LauncherUpdateError(f"invalid rollback version: {target_version!r}") from exc + if target_semantic >= current_semantic: + raise LauncherUpdateError( + f"rollback target {target_version} must be older than {current_version}" + ) + target_release = paths.launcher_releases / target_version + if target_release.is_dir() and not target_release.is_symlink(): + if _validate_release_directory(target_release) != target_version: + raise LauncherUpdateError("retained rollback release version does not match") + retained_compatibility = _verify_retained_release_integrity( + paths, + target_release, + target_version, + ) + else: + remote_target = True + target_semantic = _parse_version(target_version, "rollback target version") + if target_semantic >= current_semantic: + raise LauncherUpdateError( + f"rollback target {target_version} must be older than {current_version}" + ) + from_channel = current_semantic.channel + to_channel = target_semantic.channel + event_metadata = _launcher_release_metadata( + current_version=current_version, + target_version=target_version, + current_channel=from_channel, + target_channel=to_channel, + selected_channel=selected_channel, + tag=manifest.tag if manifest is not None else target_semantic.tag, + manifest_schema=manifest.schema if manifest is not None else None, + manifest_sha256=( + manifest.manifest_sha256 if manifest is not None else None + ), + rollback=True, + reason=reason, + release_id=release_id, ) + _send_launcher_release_event( + "rollback_requested", + metadata=event_metadata, + operation="launcher_rollback", + stage="requested", + ) + if remote_target: + history = fetch_launcher_release_history( + history_url=history_url, + public_key_path=public_key_path, + environment=environment, + timeout_seconds=min(timeout_seconds, 30.0), + ) + candidates = launcher_rollback_candidates( + history, + installed_version=current_version, + selected_channel=selected_channel, + show_prereleases=target_semantic.is_prerelease, + ) + record = next( + (release for release in candidates if release.version == target_version), + None, + ) + if record is None: + raise LauncherUpdateError( + f"release {target_version} is not an active rollback candidate" + ) + release_id = record.release_id + fetched_manifest = _fetch_historical_manifest( + record, + public_key_path or configured_public_key_path(environment), + timeout_seconds=min(timeout_seconds, 30.0), + ) + _validate_rollback_config_compatibility(paths, fetched_manifest) + _precheck_release_install(paths, fetched_manifest) + manifest = fetched_manifest + archive = paths.launcher_update_archive(fetched_manifest.version) + event_metadata = _launcher_release_metadata( + current_version=current_version, + target_version=target_version, + current_channel=from_channel, + target_channel=to_channel, + selected_channel=selected_channel, + tag=fetched_manifest.tag, + manifest_schema=fetched_manifest.schema, + manifest_sha256=fetched_manifest.manifest_sha256, + rollback=True, + reason=reason, + release_id=release_id, + ) + if manifest is not None and archive is not None: + _download_archive( + manifest, + archive, + timeout_seconds=timeout_seconds, + event_family="rollback", + event_metadata=event_metadata, + ) + _send_launcher_release_event( + "rollback_apply_started", + metadata=event_metadata, + operation="launcher_rollback", + stage="apply", + ) + try: + if manifest is not None and archive is not None: + target_release = _stage_and_install_release(paths, archive, manifest) + if current_release == target_release.resolve(): + raise LauncherUpdateError("current and rollback releases are identical") + if manifest is not None: + _validate_rollback_config_compatibility(paths, manifest) + elif retained_compatibility is not None: + config_schema, minimum_config_schema = retained_compatibility + _validate_config_schema_range( + _current_config_schema(paths), + minimum_config_schema, + config_schema, + f"launcher {target_version}", + ) + else: + _validate_rollback_release_compatibility(paths, target_release) + previous = _activate_release( + paths, + target_release, + telemetry_metadata=event_metadata, + restore_reason=reason, + ) + if previous is None: + raise LauncherUpdateError("the current launcher release disappeared") + except BaseException as exc: + failed_tag = event_metadata.get("release_tag") + if not isinstance(failed_tag, str): + failed_tag = None + _send_launcher_release_event( + "rollback_apply_failed", + metadata=event_metadata, + operation="launcher_rollback", + stage="apply", + severity="error", + message=str(exc), + ) + _record_launcher_operation( + paths, + from_version=current_version, + to_version=target_version, + from_channel=from_channel, + to_channel=to_channel, + selected_channel=selected_channel, + result="failed", + rollback=True, + reason=reason, + tag=failed_tag, + manifest_schema=manifest.schema if manifest is not None else None, + manifest_sha256=( + manifest.manifest_sha256 if manifest is not None else None + ), + ) + raise + _send_launcher_release_event( + "rollback_apply_success", + metadata=event_metadata, + operation="launcher_rollback", + stage="apply", + ) + state_warning = None + try: + _write_json( + paths.launcher_update_state, + { + "schema": 1, + "rolled_back_at": datetime.now().astimezone().isoformat(), + "from_version": current_version, + "to_version": target_version, + "selected_channel": selected_channel, + "from_channel": from_channel, + "to_channel": to_channel, + "reason": reason, + "rollback": True, + "release_directory": str(target_release), + "previous_release": str(previous), + }, + ) + except OSError as exc: + # The symlink transaction is already durable. Diagnostics must not make + # a completed rollback appear to have failed or encourage a retry. + state_warning = f"launcher rolled back, but update history could not be saved: {exc}" + history_recorded = _record_launcher_operation( + paths, + from_version=current_version, + to_version=target_version, + from_channel=from_channel, + to_channel=to_channel, + selected_channel=selected_channel, + result="success", + rollback=True, + reason=reason, + tag=manifest.tag if manifest is not None else target_semantic.tag, + manifest_schema=manifest.schema if manifest is not None else None, + manifest_sha256=( + manifest.manifest_sha256 if manifest is not None else None + ), + ) + if not history_recorded and state_warning is None: + state_warning = "launcher rolled back, but the local operation history could not be saved" return LauncherRollbackResult( from_version=current_version, - to_version=previous_version, - current_release=str(previous_release), - previous_release=str(current_release), + to_version=target_version, + current_release=str(target_release), + previous_release=str(previous), restart_required=True, + from_channel=from_channel, + to_channel=to_channel, + state_recorded=state_warning is None, + warning=state_warning, ) @@ -502,7 +1291,9 @@ def inspect_launcher_update_history(paths: AppPaths) -> LauncherUpdateHistory: raise LauncherUpdateError(f"cannot inspect installed launcher releases: {exc}") from exc releases: list[LauncherInstalledRelease] = [] for candidate in candidates: - if _VERSION_PATTERN.fullmatch(candidate.name) is None: + try: + SemanticVersion.parse(candidate.name) + except ReleaseModelError: continue try: version = _validate_release_directory(candidate) @@ -521,6 +1312,7 @@ def inspect_launcher_update_history(paths: AppPaths) -> LauncherUpdateHistory: directory=str(resolved), active=resolved == current, previous=resolved == previous, + channel=SemanticVersion.parse(version).channel, ) ) releases.sort( @@ -534,6 +1326,141 @@ def inspect_launcher_update_history(paths: AppPaths) -> LauncherUpdateHistory: ) +def parse_launcher_release_history( + data: bytes, + *, + history_url: str, +) -> LauncherRemoteHistory: + """Strictly parse an already authenticated remote release catalog.""" + + if not data or len(data) > MAX_HISTORY_BYTES: + raise LauncherUpdateError("release history has an invalid size") + _require_https_url(history_url, "release history URL") + try: + value = json.loads( + data.decode("utf-8"), + object_pairs_hook=_unique_json_object, + ) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise LauncherUpdateError(f"release history is invalid JSON: {exc}") from exc + if not isinstance(value, dict): + raise LauncherUpdateError("release history must contain a JSON object") + if value.get("application") != UPDATE_APPLICATION: + raise LauncherUpdateError( + f"release history application must be {UPDATE_APPLICATION!r}" + ) + if _required_integer(value, "schema") != RELEASE_HISTORY_SCHEMA: + raise LauncherUpdateError("unsupported release history schema") + generated_at = _required_string(value, "generated_at") + _parse_published_at(generated_at) + releases_value = value.get("releases") + if not isinstance(releases_value, list) or len(releases_value) > MAX_RELEASE_HISTORY: + raise LauncherUpdateError("release history releases must be a bounded array") + releases: list[LauncherRemoteRelease] = [] + seen: set[SemanticVersion] = set() + last_version: SemanticVersion | None = None + for item in releases_value: + if not isinstance(item, dict): + raise LauncherUpdateError("release history entry must be an object") + version = _required_string(item, "version") + channel = _required_string(item, "channel") + tag = _required_string(item, "tag") + try: + parsed = validate_channel_version(channel, version) + except ReleaseModelError as exc: + raise LauncherUpdateError(f"invalid release history entry: {exc}") from exc + if tag != parsed.tag: + raise LauncherUpdateError( + f"release history tag {tag!r} does not match version {version}" + ) + if parsed in seen: + raise LauncherUpdateError(f"duplicate release history version: {version}") + if last_version is not None and parsed >= last_version: + raise LauncherUpdateError( + "release history entries must be sorted by descending semantic version" + ) + seen.add(parsed) + last_version = parsed + status = _required_string(item, "status") + if status not in SUPPORTED_RELEASE_STATUSES: + raise LauncherUpdateError(f"unsupported release status: {status!r}") + manifest_url = _required_string(item, "manifest_url") + manifest_signature_url = _required_string(item, "manifest_signature_url") + release_date = _required_string(item, "release_date") + release_notes_url = _required_string(item, "release_notes_url") + for url, label in ( + (manifest_url, "historical manifest URL"), + (manifest_signature_url, "historical manifest signature URL"), + (release_notes_url, "historical release notes URL"), + ): + _require_https_url(url, label) + if _resource_url(manifest_signature_url) != _resource_url( + _signature_url(manifest_url) + ): + raise LauncherUpdateError( + f"release history signature URL does not match manifest {version}" + ) + _parse_published_at(release_date) + title = _required_string(item, "title") + release_id_value = item.get("release_id") + if release_id_value is not None and ( + isinstance(release_id_value, bool) + or not isinstance(release_id_value, int) + or release_id_value <= 0 + ): + raise LauncherUpdateError("release history release_id must be positive or null") + config_schema, minimum_config_schema = _parse_history_compatibility( + item.get("compatibility") + ) + releases.append( + LauncherRemoteRelease( + version=version, + channel=channel, + tag=tag, + manifest_url=manifest_url, + manifest_signature_url=manifest_signature_url, + release_date=release_date, + release_notes_url=release_notes_url, + status=status, + title=title, + release_id=release_id_value, + config_schema=config_schema, + minimum_config_schema=minimum_config_schema, + ) + ) + return LauncherRemoteHistory( + generated_at=generated_at, + history_url=history_url, + releases=tuple(releases), + history_sha256=hashlib.sha256(data).hexdigest(), + ) + + +def _parse_history_compatibility(value: object) -> tuple[int | None, int | None]: + if value is None: + return None, None + if not isinstance(value, dict): + raise LauncherUpdateError("release history compatibility must be an object") + config_schema = _optional_positive_integer(value, "config_schema") + minimum_config_schema = _optional_positive_integer( + value, + "minimum_config_schema", + ) + if (config_schema is None) != (minimum_config_schema is None): + raise LauncherUpdateError( + "release history compatibility must include both config schema bounds" + ) + if ( + config_schema is not None + and minimum_config_schema is not None + and minimum_config_schema > config_schema + ): + raise LauncherUpdateError( + "release history minimum_config_schema exceeds config_schema" + ) + return config_schema, minimum_config_schema + + def parse_update_manifest(data: bytes) -> UpdateManifest: """Strictly parse one already-authenticated manifest.""" @@ -548,15 +1475,21 @@ def parse_update_manifest(data: bytes) -> UpdateManifest: raise LauncherUpdateError(f"update manifest is invalid JSON: {exc}") from exc if not isinstance(value, dict): raise LauncherUpdateError("update manifest must contain a JSON object") + application = value.get("application") + if application is not None and application != UPDATE_APPLICATION: + raise LauncherUpdateError( + f"update manifest application must be {UPDATE_APPLICATION!r}" + ) schema = _required_integer(value, "schema") if schema != 1: raise LauncherUpdateError(f"unsupported update manifest schema: {schema}") channel = _required_string(value, "channel") - if channel != "stable": - raise LauncherUpdateError(f"unsupported update channel: {channel}") version = _required_string(value, "version") minimum = _required_string(value, "minimum_updater_version") - _parse_version(version, "manifest version") + try: + parsed_version = validate_channel_version(channel, version) + except ReleaseModelError as exc: + raise LauncherUpdateError(f"invalid update manifest release: {exc}") from exc _parse_version(minimum, "minimum updater version") archive_url = _required_string(value, "archive_url") _require_https_url(archive_url, "update archive URL") @@ -575,6 +1508,90 @@ def parse_update_manifest(data: bytes) -> UpdateManifest: raise LauncherUpdateError("notes_url must be a string or null") if notes_value: _require_https_url(notes_value, "release notes URL") + tag_value = value.get("tag", parsed_version.tag) + if not isinstance(tag_value, str) or tag_value != parsed_version.tag: + raise LauncherUpdateError("manifest tag does not match its version") + commit_value = value.get("commit") + if commit_value is not None and ( + not isinstance(commit_value, str) + or re.fullmatch(r"[0-9a-f]{7,64}", commit_value) is None + ): + raise LauncherUpdateError("manifest commit must be a lowercase Git object ID") + status_value = value.get("status", ACTIVE_STATUS) + if not isinstance(status_value, str) or status_value not in SUPPORTED_RELEASE_STATUSES: + raise LauncherUpdateError(f"unsupported release status: {status_value!r}") + title_value: str | None = None + summary_value: str | None = None + notes_markdown_value: str | None = None + release_value = value.get("release") + if release_value is not None: + if not isinstance(release_value, dict): + raise LauncherUpdateError("manifest release field must be an object") + title_value = _required_string(release_value, "title") + summary_value = _required_string(release_value, "summary") + notes_markdown_candidate = release_value.get("notes_markdown") + if notes_markdown_candidate is not None: + if ( + not isinstance(notes_markdown_candidate, str) + or not notes_markdown_candidate + or "\x00" in notes_markdown_candidate + or len(notes_markdown_candidate.encode("utf-8")) + > MAX_EMBEDDED_RELEASE_NOTES_BYTES + ): + raise LauncherUpdateError( + "manifest release.notes_markdown is invalid or too large" + ) + notes_markdown_value = notes_markdown_candidate + release_notes_value = _required_string(release_value, "release_notes_url") + _require_https_url(release_notes_value, "release notes URL") + if notes_value and notes_value != release_notes_value: + raise LauncherUpdateError("manifest release notes URLs do not match") + notes_value = release_notes_value + archive_name = PurePosixPath(urlsplit(archive_url).path).name + archive_value = value.get("archive") + if archive_value is not None: + if not isinstance(archive_value, dict): + raise LauncherUpdateError("manifest archive field must be an object") + archive_name = _required_string(archive_value, "name") + if PurePosixPath(archive_name).name != archive_name: + raise LauncherUpdateError("manifest archive name must be a filename") + if PurePosixPath(urlsplit(archive_url).path).name != archive_name: + raise LauncherUpdateError("manifest archive name does not match its URL") + if ( + _required_string(archive_value, "url") != archive_url + or _required_string(archive_value, "sha256") != archive_sha256 + or _required_integer(archive_value, "size") != archive_size + ): + raise LauncherUpdateError("manifest archive fields do not match legacy metadata") + config_schema = 1 + minimum_config_schema = 1 + compatibility_value = value.get("compatibility") + if compatibility_value is not None: + if not isinstance(compatibility_value, dict): + raise LauncherUpdateError("manifest compatibility field must be an object") + config_schema = _required_positive_integer( + compatibility_value, + "config_schema", + ) + minimum_config_schema = _required_positive_integer( + compatibility_value, + "minimum_config_schema", + ) + if minimum_config_schema > config_schema: + raise LauncherUpdateError( + "manifest minimum_config_schema exceeds config_schema" + ) + compatibility_manifest_schema = compatibility_value.get("manifest_schema") + if compatibility_manifest_schema is not None and compatibility_manifest_schema != schema: + raise LauncherUpdateError( + "manifest compatibility schema does not match its schema" + ) + compatibility_minimum = compatibility_value.get("minimum_updater_version") + if compatibility_minimum is not None and compatibility_minimum != minimum: + raise LauncherUpdateError( + "manifest compatibility minimum updater version does not match" + ) + files = _parse_manifest_files(value.get("files")) return UpdateManifest( schema=schema, channel=channel, @@ -585,9 +1602,55 @@ def parse_update_manifest(data: bytes) -> UpdateManifest: archive_size=archive_size, published_at=published_at, notes_url=notes_value or None, + tag=tag_value, + commit=commit_value, + status=status_value, + title=title_value, + summary=summary_value, + notes_markdown=notes_markdown_value, + archive_name=archive_name, + files=files, + manifest_sha256=hashlib.sha256(data).hexdigest(), + config_schema=config_schema, + minimum_config_schema=minimum_config_schema, ) +def _parse_manifest_files(value: object) -> tuple[ManifestFile, ...]: + if value is None: + return () + if not isinstance(value, list) or len(value) > MAX_ARCHIVE_MEMBERS: + raise LauncherUpdateError("manifest files must be a bounded array") + files: list[ManifestFile] = [] + last_path = "" + for item in value: + if not isinstance(item, dict): + raise LauncherUpdateError("manifest file entry must be an object") + path_value = _required_string(item, "path") + raw_parts = path_value.split("/") + path = PurePosixPath(path_value) + if ( + "\\" in path_value + or "\x00" in path_value + or any(part in {"", ".", ".."} for part in raw_parts) + or path.is_absolute() + or not path.parts + or path.parts[0] not in _ALLOWED_RELEASE_ROOTS + ): + raise LauncherUpdateError(f"unsafe manifest file path: {path_value!r}") + if path_value <= last_path: + raise LauncherUpdateError("manifest files must be uniquely sorted by path") + size = _required_integer(item, "size") + if size < 0 or size > MAX_EXTRACTED_BYTES: + raise LauncherUpdateError(f"invalid manifest file size: {path_value}") + digest = _required_string(item, "sha256") + if _SHA256_PATTERN.fullmatch(digest) is None: + raise LauncherUpdateError(f"invalid manifest file SHA-256: {path_value}") + files.append(ManifestFile(path_value, size, digest)) + last_path = path_value + return tuple(files) + + def _fetch_small_file( url: str, *, @@ -650,33 +1713,112 @@ def _verify_manifest_signature( raise LauncherUpdateError("OpenSSL is required to verify launcher updates") with tempfile.TemporaryDirectory(prefix="ptlinux-update-signature-") as temporary: root = Path(temporary) - manifest_path = root / "stable.json" - signature_path = root / "stable.json.sig" + manifest_path = root / "manifest.json" + signature_path = root / "manifest.json.sig" manifest_path.write_bytes(manifest) signature_path.write_bytes(signature) - try: - result = subprocess.run( - [ - openssl, - "dgst", - "-sha256", - "-verify", - str(key), - "-signature", - str(signature_path), - str(manifest_path), - ], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=10, - check=False, - ) - except (OSError, subprocess.TimeoutExpired) as exc: - raise LauncherUpdateError(f"could not verify update signature: {exc}") from exc - if result.returncode != 0: - raise LauncherUpdateError("launcher update manifest signature is invalid") + commands = ( + [ + openssl, + "dgst", + "-sha256", + "-verify", + str(key), + "-signature", + str(signature_path), + str(manifest_path), + ], + [ + openssl, + "pkeyutl", + "-verify", + "-rawin", + "-pubin", + "-inkey", + str(key), + "-sigfile", + str(signature_path), + "-in", + str(manifest_path), + ], + ) + for command in commands: + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + continue + if result.returncode == 0: + return + raise LauncherUpdateError("launcher update manifest signature is invalid") + + +def _launcher_release_metadata( + *, + current_version: str, + target_version: str, + current_channel: str, + target_channel: str, + selected_channel: str, + tag: str | None, + manifest_schema: int | None, + manifest_sha256: str | None, + rollback: bool, + reason: str | None = None, + release_id: int | None = None, +) -> dict[str, object]: + metadata: dict[str, object] = { + "current_version": current_version, + "target_version": target_version, + "current_channel": current_channel, + "target_channel": target_channel, + "selected_update_channel": selected_channel, + "release_tag": tag, + "manifest_version": manifest_schema, + "manifest_hash": manifest_sha256, + "rollback": rollback, + } + if reason is not None: + metadata["reason"] = reason + if release_id is not None: + metadata["release_id"] = release_id + if rollback: + metadata.update( + { + "from_version": current_version, + "to_version": target_version, + "from_channel": current_channel, + "to_channel": target_channel, + } + ) + return metadata + + +def _send_launcher_release_event( + event: str, + *, + metadata: Mapping[str, object], + operation: str, + stage: str, + severity: str = "info", + message: str | None = None, +) -> None: + telemetry = current_telemetry() + if telemetry is not None: + telemetry.send_event( + event, + severity=severity, + message=message, + metadata=metadata, + context={"operation": operation, "stage": stage}, + ) def _download_archive( @@ -684,20 +1826,40 @@ def _download_archive( destination: Path, *, timeout_seconds: float, + event_family: str | None = None, + event_metadata: Mapping[str, object] | None = None, ) -> None: if destination.is_file() and _archive_matches(destination, manifest): return telemetry = current_telemetry() download_started_at = time.monotonic() + operation = "launcher_rollback" if event_family == "rollback" else "launcher_update" + started_event = ( + f"{event_family}_download_started" + if event_family is not None + else "download_started" + ) + success_event = ( + f"{event_family}_download_success" + if event_family is not None + else "download_completed" + ) + failed_event = ( + f"{event_family}_download_failed" + if event_family is not None + else "download_failed" + ) + base_metadata = { + "resource_type": "launcher_update", + "content_bytes": manifest.archive_size, + "mirror_id": "launcher_update_channel", + **dict(event_metadata or {}), + } if telemetry is not None: telemetry.send_event( - "download_started", - metadata={ - "resource_type": "launcher_update", - "content_bytes": manifest.archive_size, - "mirror_id": "launcher_update_channel", - }, - context={"operation": "launcher_update", "stage": "download"}, + started_event, + metadata=base_metadata, + context={"operation": operation, "stage": "download"}, ) destination.parent.mkdir(parents=True, exist_ok=True) temporary = destination.with_name(f".{destination.name}.{os.getpid()}.part") @@ -759,40 +1921,36 @@ def _download_archive( if telemetry is not None: duration = max(0.0, time.monotonic() - download_started_at) telemetry.send_event( - "download_failed", + failed_event, severity="error", message=str(exc), metadata={ - "resource_type": "launcher_update", + **base_metadata, "download_bytes": written, - "content_bytes": manifest.archive_size, "duration_ms": round(duration * 1000), "average_mbps": _average_mbps(written, duration), "retry_count": 0, "resume_used": False, "http_status": network_http_status(exc), "failure_type": classify_network_failure(exc), - "mirror_id": "launcher_update_channel", }, - context={"operation": "launcher_update", "stage": "download"}, + context={"operation": operation, "stage": "download"}, ) raise duration = max(0.0, time.monotonic() - download_started_at) if telemetry is not None: telemetry.send_event( - "download_completed", + success_event, metadata={ - "resource_type": "launcher_update", + **base_metadata, "download_bytes": written, - "content_bytes": manifest.archive_size, "duration_ms": round(duration * 1000), "average_mbps": _average_mbps(written, duration), "retry_count": 0, "resume_used": False, "http_status": 200, - "mirror_id": "launcher_update_channel", }, - context={"operation": "launcher_update", "stage": "download"}, + context={"operation": operation, "stage": "download"}, ) @@ -823,6 +1981,7 @@ def _stage_and_install_release( staging.mkdir(mode=0o700) try: _extract_release_archive(archive, staging, manifest.version) + _verify_manifest_files(staging, manifest) staged_version = _validate_release_directory(staging) if staged_version != manifest.version: raise LauncherUpdateError( @@ -846,6 +2005,7 @@ def _stage_and_install_release( else: staging.replace(destination) _fsync_directory(paths.launcher_releases) + _write_release_integrity_record(paths, destination, manifest) return destination except BaseException: if staging.exists() and staging.is_dir() and not staging.is_symlink(): @@ -853,6 +2013,126 @@ def _stage_and_install_release( raise +def _verify_manifest_files(release: Path, manifest: UpdateManifest) -> None: + _verify_file_inventory(release, manifest.files) + + +def _verify_file_inventory( + release: Path, + files: tuple[ManifestFile, ...], +) -> None: + if not files: + return + expected = {entry.path: entry for entry in files} + actual: set[str] = set() + for path in release.rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(release).as_posix() + actual.add(relative) + entry = expected.get(relative) + if entry is None: + raise LauncherUpdateError( + f"launcher archive file is absent from its signed manifest: {relative}" + ) + if path.stat().st_size != entry.size or _sha256_file(path) != entry.sha256: + raise LauncherUpdateError( + f"launcher archive file does not match its signed manifest: {relative}" + ) + missing = sorted(set(expected).difference(actual)) + if missing: + raise LauncherUpdateError( + "launcher archive is missing signed manifest files: " + ", ".join(missing) + ) + + +def _write_release_integrity_record( + paths: AppPaths, + release: Path, + manifest: UpdateManifest, +) -> None: + if not manifest.files: + return + directory = paths.launcher_release_integrity + if directory.is_symlink(): + raise LauncherUpdateError( + f"launcher release-integrity directory cannot be a link: {directory}" + ) + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + directory.chmod(0o700) + if release.resolve() != (paths.launcher_releases / manifest.version).resolve(): + raise LauncherUpdateError("launcher integrity record release path is inconsistent") + _write_json( + directory / f"{manifest.version}.json", + { + "schema": 1, + "version": manifest.version, + "channel": manifest.channel, + "tag": manifest.tag or SemanticVersion.parse(manifest.version).tag, + "manifest_schema": manifest.schema, + "manifest_sha256": manifest.manifest_sha256, + "config_schema": manifest.config_schema, + "minimum_config_schema": manifest.minimum_config_schema, + "files": [asdict(entry) for entry in manifest.files], + }, + ) + + +def _verify_retained_release_integrity( + paths: AppPaths, + release: Path, + version: str, +) -> tuple[int, int] | None: + """Verify a retained release against its install-time signed file inventory.""" + + record = paths.launcher_release_integrity / f"{version}.json" + try: + if record.is_symlink(): + raise LauncherUpdateError(f"launcher integrity record cannot be a link: {record}") + data = record.read_bytes() + except FileNotFoundError: + # Releases installed by an older launcher predate integrity persistence. + return None + except OSError as exc: + raise LauncherUpdateError(f"cannot read launcher integrity record: {exc}") from exc + if not data or len(data) > MAX_MANIFEST_BYTES: + raise LauncherUpdateError("launcher integrity record has an invalid size") + try: + value = json.loads(data.decode("utf-8"), object_pairs_hook=_unique_json_object) + except (UnicodeError, json.JSONDecodeError, ValueError) as exc: + raise LauncherUpdateError(f"launcher integrity record is invalid: {exc}") from exc + if not isinstance(value, dict) or value.get("schema") != 1: + raise LauncherUpdateError("launcher integrity record has an unsupported schema") + semantic = _parse_version(version, "retained launcher version") + if ( + value.get("version") != version + or value.get("channel") != semantic.channel + or value.get("tag") != semantic.tag + or value.get("manifest_schema") != 1 + ): + raise LauncherUpdateError("launcher integrity record identity does not match release") + manifest_sha256 = value.get("manifest_sha256") + if manifest_sha256 is not None and ( + not isinstance(manifest_sha256, str) + or _SHA256_PATTERN.fullmatch(manifest_sha256) is None + ): + raise LauncherUpdateError("launcher integrity record manifest hash is invalid") + config_schema = _required_positive_integer(value, "config_schema") + minimum_config_schema = _required_positive_integer( + value, + "minimum_config_schema", + ) + if minimum_config_schema > config_schema: + raise LauncherUpdateError( + "launcher integrity record has invalid config compatibility" + ) + files = _parse_manifest_files(value.get("files")) + if not files: + raise LauncherUpdateError("launcher integrity record has no managed files") + _verify_file_inventory(release, files) + return config_schema, minimum_config_schema + + def _extract_release_archive(archive: Path, destination: Path, version: str) -> None: expected_root = f"{UPDATE_ARCHIVE_PREFIX}{version}" seen: set[PurePosixPath] = set() @@ -952,20 +2232,17 @@ def _validate_release_directory(release: Path) -> str: ) if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode): raise LauncherUpdateError(f"launcher release contains a special file: {item}") - metadata_path = release / "release.json" - try: - metadata = json.loads( - metadata_path.read_text(encoding="utf-8"), - object_pairs_hook=_unique_json_object, - ) - except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: - raise LauncherUpdateError(f"launcher release metadata is invalid: {exc}") from exc - if not isinstance(metadata, dict) or metadata.get("schema") != 1: - raise LauncherUpdateError("launcher release metadata has an unsupported schema") + metadata = _read_release_metadata(release) version = metadata.get("version") if not isinstance(version, str): raise LauncherUpdateError("launcher release metadata has no version") - _parse_version(version, "launcher release version") + parsed_version = _parse_version(version, "launcher release version") + channel = metadata.get("channel") + if channel is not None and channel != parsed_version.channel: + raise LauncherUpdateError("launcher release channel does not match its version") + tag = metadata.get("tag") + if tag is not None and tag != parsed_version.tag: + raise LauncherUpdateError("launcher release tag does not match its version") for required in ( Path("ptlinux/__init__.py"), Path("ptlinux/__main__.py"), @@ -1017,12 +2294,153 @@ def _self_check_release(release: Path, expected_version: str) -> None: ) -def _activate_release(paths: AppPaths, release: Path) -> Path | None: +@contextmanager +def _launcher_mutation_lock(paths: AppPaths, operation: str) -> Iterator[None]: + """Exclude game launches and concurrent release-link mutations.""" + + with launcher_operation_lock( + paths.launcher_operation_lock, + operation, + active_game_probe=lambda: managed_game_process_is_active( + paths.runtime_view, + (paths.proton_prefix, paths.umu_prefix), + ), + ): + with launcher_operation_lock(paths.launcher_update_lock, operation): + yield + + +def _precheck_release_install(paths: AppPaths, manifest: UpdateManifest) -> None: + """Reject a release before download when local transaction prerequisites fail.""" + + _validate_rollback_config_compatibility(paths, manifest) + for directory, label in ( + (paths.downloads, "launcher download cache"), + (paths.app_home, "launcher application directory"), + ): + directory.mkdir(parents=True, exist_ok=True) + if directory.is_symlink() or not directory.is_dir() or not os.access(directory, os.W_OK): + raise LauncherUpdateError(f"{label} is not a writable directory: {directory}") + archive = paths.launcher_update_archive(manifest.version) + cache_bytes = 0 if _archive_matches(archive, manifest) else manifest.archive_size + extracted_bytes = ( + sum(file.size for file in manifest.files) + if manifest.files + else min(MAX_EXTRACTED_BYTES, manifest.archive_size * 4) + ) + _require_free_space(paths.downloads, cache_bytes + 1024 * 1024, "download") + _require_free_space(paths.app_home, extracted_bytes + 1024 * 1024, "staging") + + +def _require_free_space(path: Path, required_bytes: int, label: str) -> None: + try: + free_bytes = shutil.disk_usage(path).free + except OSError as exc: + raise LauncherUpdateError(f"cannot inspect free space for {path}: {exc}") from exc + if free_bytes < required_bytes: + raise LauncherUpdateError( + f"insufficient free disk space for launcher {label}: " + f"need {required_bytes} bytes, have {free_bytes}" + ) + + +def _current_config_schema(paths: AppPaths) -> int: + from .settings import SETTINGS_SCHEMA + + try: + data = paths.settings_file.read_bytes() + except FileNotFoundError: + return SETTINGS_SCHEMA + except OSError as exc: + raise LauncherUpdateError(f"cannot inspect launcher settings schema: {exc}") from exc + if len(data) > 1024 * 1024: + raise LauncherUpdateError("launcher settings file is too large to validate") + try: + value = json.loads(data.decode("utf-8-sig"), object_pairs_hook=_unique_json_object) + except (UnicodeError, json.JSONDecodeError, ValueError) as exc: + raise LauncherUpdateError(f"launcher settings are invalid: {exc}") from exc + if not isinstance(value, dict): + raise LauncherUpdateError("launcher settings must contain an object") + schema = value.get("schema", SETTINGS_SCHEMA) + if isinstance(schema, bool) or not isinstance(schema, int) or schema <= 0: + raise LauncherUpdateError("launcher settings schema is invalid") + return schema + + +def _validate_rollback_config_compatibility( + paths: AppPaths, + manifest: UpdateManifest, +) -> None: + _validate_config_schema_range( + _current_config_schema(paths), + manifest.minimum_config_schema, + manifest.config_schema, + f"launcher {manifest.version}", + ) + + +def _validate_rollback_release_compatibility(paths: AppPaths, release: Path) -> None: + metadata = _read_release_metadata(release) + config_schema = metadata.get("config_schema", 1) + minimum_config_schema = metadata.get("minimum_config_schema", 1) + if ( + isinstance(config_schema, bool) + or not isinstance(config_schema, int) + or isinstance(minimum_config_schema, bool) + or not isinstance(minimum_config_schema, int) + ): + raise LauncherUpdateError("launcher release has invalid config compatibility") + _validate_config_schema_range( + _current_config_schema(paths), + minimum_config_schema, + config_schema, + f"launcher {metadata.get('version', release.name)}", + ) + + +def _validate_config_schema_range( + current: int, + minimum: int, + maximum: int, + label: str, +) -> None: + if minimum <= 0 or maximum <= 0 or minimum > maximum: + raise LauncherUpdateError(f"{label} has invalid config schema compatibility") + if current < minimum or current > maximum: + raise LauncherUpdateError( + f"{label} supports config schemas {minimum} through {maximum}, " + f"but the installed configuration uses schema {current}" + ) + + +def _read_release_metadata(release: Path) -> dict[str, object]: + metadata_path = release / "release.json" + try: + metadata = json.loads( + metadata_path.read_text(encoding="utf-8"), + object_pairs_hook=_unique_json_object, + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise LauncherUpdateError(f"launcher release metadata is invalid: {exc}") from exc + if not isinstance(metadata, dict) or metadata.get("schema") != 1: + raise LauncherUpdateError("launcher release metadata has an unsupported schema") + return metadata + + +def _activate_release( + paths: AppPaths, + release: Path, + *, + telemetry_metadata: Mapping[str, object] | None = None, + restore_reason: str = "failed_update", +) -> Path | None: if paths.app_home.is_symlink(): raise LauncherUpdateError(f"launcher application home cannot be a link: {paths.app_home}") paths.app_home.mkdir(parents=True, exist_ok=True, mode=0o755) paths.app_home.chmod(0o755) + _recover_interrupted_activation(paths) current = _release_from_link(paths.launcher_current, paths.launcher_releases) + previous = _release_from_link(paths.launcher_previous, paths.launcher_releases) if paths.launcher_current.exists() and not paths.launcher_current.is_symlink(): raise LauncherUpdateError( f"launcher current path is not a symbolic link: {paths.launcher_current}" @@ -1031,20 +2449,260 @@ def _activate_release(paths: AppPaths, release: Path) -> Path | None: raise LauncherUpdateError( f"launcher previous path is not a symbolic link: {paths.launcher_previous}" ) - if current is not None: - current_release = current - if current_release != release: - _validate_release_directory(current_release) + resolved_release = release.resolve() + _validate_release_directory(resolved_release) + if current == resolved_release: + return current + _write_activation_journal( + paths, + state="applying", + current=current, + previous=previous, + target=resolved_release, + ) + try: + if current is not None: + _validate_release_directory(current) _replace_release_link( paths.launcher_previous, - current_release, + current, paths.launcher_releases, ) - _replace_release_link(paths.launcher_current, release, paths.launcher_releases) - _fsync_directory(paths.app_home) + _replace_release_link( + paths.launcher_current, + resolved_release, + paths.launcher_releases, + ) + _fsync_directory(paths.app_home) + activated = _release_from_link(paths.launcher_current, paths.launcher_releases) + retained_previous = _release_from_link( + paths.launcher_previous, + paths.launcher_releases, + ) + expected_previous = current if current is not None else previous + if activated is None or activated != resolved_release or retained_previous != expected_previous: + raise LauncherUpdateError( + "launcher release links failed their post-activation health check" + ) + assert activated is not None + _validate_release_directory(activated) + _write_activation_journal( + paths, + state="activated", + current=current, + previous=previous, + target=resolved_release, + ) + except BaseException as activation_error: + restore_metadata = { + **dict(telemetry_metadata or {}), + "reason": restore_reason, + } + _send_launcher_release_event( + "rollback_restore_started", + metadata=restore_metadata, + operation="launcher_rollback", + stage="restore", + ) + try: + _restore_release_links(paths, current=current, previous=previous) + except BaseException as restore_error: + _send_launcher_release_event( + "rollback_restore_failed", + metadata=restore_metadata, + operation="launcher_rollback", + stage="restore", + severity="critical", + message=str(restore_error), + ) + raise LauncherUpdateError( + f"launcher activation failed ({activation_error}); " + f"snapshot restoration also failed ({restore_error})" + ) from activation_error + paths.launcher_update_transaction.unlink(missing_ok=True) + _fsync_directory(paths.launcher_update_transaction.parent) + _send_launcher_release_event( + "rollback_restore_success", + metadata=restore_metadata, + operation="launcher_rollback", + stage="restore", + severity="warning", + ) + raise + paths.launcher_update_transaction.unlink(missing_ok=True) + _fsync_directory(paths.launcher_update_transaction.parent) return current +def _recover_interrupted_activation(paths: AppPaths) -> None: + journal = paths.launcher_update_transaction + try: + if journal.is_symlink(): + raise LauncherUpdateError(f"launcher transaction journal cannot be a link: {journal}") + value = json.loads( + journal.read_text(encoding="utf-8"), + object_pairs_hook=_unique_json_object, + ) + except FileNotFoundError: + return + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: + raise LauncherUpdateError(f"launcher transaction journal is invalid: {exc}") from exc + if not isinstance(value, dict) or value.get("schema") != 1: + raise LauncherUpdateError("launcher transaction journal has an unsupported schema") + state = value.get("state") + if state not in {"applying", "activated"}: + raise LauncherUpdateError("launcher transaction journal has an invalid state") + current = _journal_release(paths, value.get("current"), "current") + previous = _journal_release(paths, value.get("previous"), "previous") + target_version = _journal_version(value.get("target"), "target") + target_path = paths.launcher_releases / target_version + try: + _validate_release_directory(target_path) + except LauncherUpdateError: + target = None + else: + target = target_path.resolve() + if state == "activated": + active = _release_from_link(paths.launcher_current, paths.launcher_releases) + retained_previous = _release_from_link( + paths.launcher_previous, + paths.launcher_releases, + ) + expected_previous = current if current is not None else previous + if target is not None and active == target and retained_previous == expected_previous: + journal.unlink() + _fsync_directory(journal.parent) + return + metadata = _recovery_telemetry_metadata(current, target_version) + _send_launcher_release_event( + "rollback_restore_started", + metadata=metadata, + operation="launcher_rollback", + stage="restore", + severity="warning", + ) + try: + _restore_release_links(paths, current=current, previous=previous) + except BaseException as exc: + _send_launcher_release_event( + "rollback_restore_failed", + metadata=metadata, + operation="launcher_rollback", + stage="restore", + severity="critical", + message=str(exc), + ) + raise + journal.unlink() + _fsync_directory(journal.parent) + _send_launcher_release_event( + "rollback_restore_success", + metadata=metadata, + operation="launcher_rollback", + stage="restore", + severity="warning", + ) + + +def _recovery_telemetry_metadata( + current: Path | None, + target_version: str, +) -> dict[str, object]: + current_version = current.name if current is not None else __version__ + try: + current_channel = SemanticVersion.parse(current_version).channel + except ReleaseModelError: + current_channel = STABLE_CHANNEL + target_channel = SemanticVersion.parse(target_version).channel + return _launcher_release_metadata( + current_version=current_version, + target_version=target_version, + current_channel=current_channel, + target_channel=target_channel, + selected_channel=target_channel, + tag=SemanticVersion.parse(target_version).tag, + manifest_schema=None, + manifest_sha256=None, + rollback=True, + reason="failed_update", + ) + + +def _journal_version(value: object, label: str) -> str: + if not isinstance(value, str): + raise LauncherUpdateError(f"launcher transaction {label} release is invalid") + try: + SemanticVersion.parse(value) + except ReleaseModelError as exc: + raise LauncherUpdateError( + f"launcher transaction {label} version is invalid" + ) from exc + return value + + +def _journal_release(paths: AppPaths, value: object, label: str) -> Path | None: + if value is None: + return None + version = _journal_version(value, label) + release = paths.launcher_releases / version + _validate_release_directory(release) + return release.resolve() + + +def _restore_release_links( + paths: AppPaths, + *, + current: Path | None, + previous: Path | None, +) -> None: + _set_release_link(paths.launcher_current, current, paths.launcher_releases) + _set_release_link(paths.launcher_previous, previous, paths.launcher_releases) + _fsync_directory(paths.app_home) + + +def _set_release_link(link: Path, release: Path | None, releases: Path) -> None: + if release is not None: + _replace_release_link(link, release, releases) + return + if link.is_symlink(): + link.unlink() + elif link.exists(): + raise LauncherUpdateError(f"launcher release link is not symbolic: {link}") + + +def _write_activation_journal( + paths: AppPaths, + *, + state: str, + current: Path | None, + previous: Path | None, + target: Path, +) -> None: + journal = paths.launcher_update_transaction + payload = { + "schema": 1, + "state": state, + "current": current.name if current is not None else None, + "previous": previous.name if previous is not None else None, + "target": target.name, + "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"), + } + journal.parent.mkdir(parents=True, exist_ok=True) + temporary = journal.with_name(f".{journal.name}.{os.getpid()}.tmp") + try: + with temporary.open("w", encoding="utf-8", newline="\n") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temporary.chmod(0o600) + os.replace(temporary, journal) + _fsync_directory(journal.parent) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + def _replace_release_link(link: Path, release: Path, releases: Path) -> None: resolved_release = release.resolve() resolved_releases = releases.resolve() @@ -1146,11 +2804,11 @@ def _require_https_url(value: str, label: str) -> None: raise LauncherUpdateError(f"{label} must be an HTTPS URL without credentials") -def _parse_version(value: str, label: str) -> tuple[int, int, int]: - if _VERSION_PATTERN.fullmatch(value) is None: - raise LauncherUpdateError(f"{label} must use X.Y.Z format: {value!r}") - major, minor, patch = value.split(".") - return int(major), int(minor), int(patch) +def _parse_version(value: str, label: str) -> SemanticVersion: + try: + return SemanticVersion.parse(value) + except ReleaseModelError as exc: + raise LauncherUpdateError(f"invalid {label}: {value!r}: {exc}") from exc def _parse_published_at(value: str) -> datetime: @@ -1203,6 +2861,26 @@ def _required_integer(value: dict[str, object], key: str) -> int: return item +def _required_positive_integer(value: dict[str, object], key: str) -> int: + item = _required_integer(value, key) + if item <= 0: + raise LauncherUpdateError( + f"update manifest field {key!r} must be a positive integer" + ) + return item + + +def _optional_positive_integer(value: dict[str, object], key: str) -> int | None: + item = value.get(key) + if item is None: + return None + if isinstance(item, bool) or not isinstance(item, int) or item <= 0: + raise LauncherUpdateError( + f"release history field {key!r} must be a positive integer or null" + ) + return item + + def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: result: dict[str, object] = {} for key, value in pairs: @@ -1240,6 +2918,63 @@ def _write_json(path: Path, value: object) -> None: raise +def _record_launcher_operation( + paths: AppPaths, + *, + from_version: str, + to_version: str, + from_channel: str, + to_channel: str, + selected_channel: str, + result: str, + rollback: bool, + reason: str, + tag: str | None = None, + manifest_schema: int | None = None, + manifest_sha256: str | None = None, +) -> bool: + """Append one bounded diagnostic record without affecting an installation.""" + + path = paths.launcher_update_operations + try: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + value = {"schema": LOCAL_OPERATION_HISTORY_SCHEMA, "operations": []} + if not isinstance(value, dict) or value.get("schema") != LOCAL_OPERATION_HISTORY_SCHEMA: + return False + operations = value.get("operations") + if not isinstance(operations, list): + return False + operations = [item for item in operations if isinstance(item, dict)] + operations.append( + { + "at": datetime.now().astimezone().isoformat(timespec="seconds"), + "from": from_version, + "from_channel": from_channel, + "manifest_hash": manifest_sha256, + "manifest_version": manifest_schema, + "reason": reason, + "result": result, + "rollback": rollback, + "selected_channel": selected_channel, + "tag": tag, + "to": to_version, + "to_channel": to_channel, + } + ) + _write_json( + path, + { + "schema": LOCAL_OPERATION_HISTORY_SCHEMA, + "operations": operations[-MAX_LOCAL_OPERATION_HISTORY:], + }, + ) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError, RuntimeError): + return False + return True + + def _fsync_directory(path: Path) -> None: try: descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) diff --git a/ptlinux/settings.py b/ptlinux/settings.py index 3ded4db..ec62aa0 100644 --- a/ptlinux/settings.py +++ b/ptlinux/settings.py @@ -13,6 +13,7 @@ from typing import Any from .locking import launcher_operation_lock from .paths import AppPaths +from .update_resolver import SUPPORTED_CHANNELS SETTINGS_SCHEMA = 1 @@ -47,6 +48,7 @@ class Settings: download_speed_limit_mbps: float | None = None low_bandwidth_mode: bool = False allow_window_resizing: bool = False + update_channel: str = "stable" @property def source_override(self) -> Path | None: @@ -176,6 +178,7 @@ def _settings_from_object(value: dict[str, object]) -> Settings: "download_speed_limit_mbps", ), low_bandwidth_mode=_boolean(value, "low_bandwidth_mode", False), + update_channel=_string(value, "update_channel", "stable"), ) _validate(settings) return settings @@ -273,6 +276,8 @@ def _validate(settings: Settings) -> None: raise ValueError(f"unsupported runtime selection: {settings.runtime}") if settings.proton_sync not in {"auto", "fsync", "esync"}: raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}") + if settings.update_channel not in SUPPORTED_CHANNELS: + raise ValueError(f"unsupported launcher update channel: {settings.update_channel}") if ( settings.download_speed_limit_mbps is not None and ( diff --git a/ptlinux/telemetry.py b/ptlinux/telemetry.py index df3eb93..6cbab53 100644 --- a/ptlinux/telemetry.py +++ b/ptlinux/telemetry.py @@ -64,8 +64,10 @@ _RESOURCE_USAGE_EVENTS = frozenset( "launcher_ready", "prefix_reset_success", "repair_success", + "rollback_apply_success", "runtime_install_success", "runtime_repair_success", + "update_apply_success", "update_success", } ) @@ -106,6 +108,7 @@ VALID_EVENTS = frozenset( "game_launch_requested", "game_launch_started", "game_launch_success", + "game_window_diagnostic", "import_failed", "import_scan_completed", "import_started", @@ -133,6 +136,16 @@ VALID_EVENTS = frozenset( "repair_started", "repair_success", "repeated_error", + "rollback_apply_failed", + "rollback_apply_started", + "rollback_apply_success", + "rollback_download_failed", + "rollback_download_started", + "rollback_download_success", + "rollback_requested", + "rollback_restore_failed", + "rollback_restore_started", + "rollback_restore_success", "runtime_check_failed", "runtime_install_failed", "runtime_install_started", @@ -144,13 +157,18 @@ VALID_EVENTS = frozenset( "subprocess_failed", "thread_exception", "update_apply_completed", + "update_apply_failed", "update_apply_started", + "update_apply_success", "update_available", "update_check_started", "update_download_completed", + "update_download_failed", "update_download_started", + "update_download_success", "update_failed", "update_not_required", + "update_release_discovered", "update_started", "update_success", "vulkan_32bit_check_failed", diff --git a/ptlinux/ui/components/theme.py b/ptlinux/ui/components/theme.py index 933b0fa..7da47d0 100644 --- a/ptlinux/ui/components/theme.py +++ b/ptlinux/ui/components/theme.py @@ -349,12 +349,13 @@ def configure_dashboard_style(root: Any, ttk: Any, launcher_ui_scale: float) -> "Settings.TCombobox", fieldbackground=[ ("disabled", "#171412"), + ("active", "#100d0c"), ("readonly", "#100d0c"), ], background=[ ("disabled", "#171412"), ("pressed", "#32251f"), - ("active", "#2a201c"), + ("active", "#191311"), ("readonly", "#191311"), ], foreground=[ diff --git a/ptlinux/ui/components/update_dialog.py b/ptlinux/ui/components/update_dialog.py index 94d13eb..804b862 100644 --- a/ptlinux/ui/components/update_dialog.py +++ b/ptlinux/ui/components/update_dialog.py @@ -6,10 +6,13 @@ from typing import Any, Callable from ...paths import AppPaths from ...self_update import ( + LauncherRemoteHistory, + LauncherRemoteRelease, LauncherUpdateCheck, LauncherUpdateHistory, inspect_launcher_update_history, ) +from ...update_resolver import PRERELEASE_CHANNEL, ReleaseModelError, SemanticVersion from ...tk_chrome import ( centered_window_geometry, create_window_chrome, @@ -19,6 +22,36 @@ from ..viewmodels.dashboard import fitted_window_size from ..viewmodels.downloader import format_bytes +MAX_RELEASE_NOTES_PREVIEW_LINES = 6 +MAX_RELEASE_NOTES_PREVIEW_CHARACTERS = 900 + + +def release_notes_preview(markdown: str | None) -> str: + """Return a compact, readable changelog excerpt for the update dialog.""" + + if not markdown: + return "" + lines: list[str] = [] + truncated = False + for raw_line in markdown.splitlines(): + line = raw_line.strip() + if not line or (not lines and line.startswith("# ")): + continue + if line == "---": + break + if len(lines) >= MAX_RELEASE_NOTES_PREVIEW_LINES: + truncated = True + break + lines.append(line) + preview = "\n".join(lines) + if len(preview) > MAX_RELEASE_NOTES_PREVIEW_CHARACTERS: + preview = preview[:MAX_RELEASE_NOTES_PREVIEW_CHARACTERS].rstrip() + truncated = True + if truncated: + preview = preview.rstrip() + "\n…" + return preview + + class LauncherUpdateDialog: """Own the update window and expose small refresh/error entry points.""" @@ -32,6 +65,8 @@ class LauncherUpdateDialog: window_icon: Any | None, apply_window_icon: Callable[[Any], None], get_check: Callable[[], LauncherUpdateCheck | None], + get_remote_history: Callable[[], LauncherRemoteHistory | None], + get_selected_channel: Callable[[], str], is_busy: Callable[[], bool], request_check: Callable[[], None], install_and_restart: Callable[[str], None], @@ -46,6 +81,8 @@ class LauncherUpdateDialog: self._window_icon = window_icon self._apply_window_icon = apply_window_icon self._get_check = get_check + self._get_remote_history = get_remote_history + self._get_selected_channel = get_selected_channel self._is_busy = is_busy self._request_check = request_check self._install_and_restart = install_and_restart @@ -58,6 +95,8 @@ class LauncherUpdateDialog: self._status_value: Any = None self._details_value: Any = None self._history: Any = None + self._history_filter_value: Any = None + self._history_items: dict[str, tuple[str, str, str | None, bool]] = {} self._release_notes_button: Any = None self._check_button: Any = None self._rollback_button: Any = None @@ -103,7 +142,7 @@ class LauncherUpdateDialog: header_copy.pack(side="left", fill="both", expand=True, padx=20, pady=14) tk.Label( header_copy, - text="SIGNED STABLE CHANNEL", + text="SIGNED RELEASE CHANNEL", background="#24170f", foreground="#d8aa61", font=("Sans", 8, "bold"), @@ -165,24 +204,54 @@ class LauncherUpdateDialog: history_card = ttk.LabelFrame( content, - text="INSTALLED HISTORY", + text="VERSION HISTORY", style="SettingsCard.TLabelframe", padding=12, ) history_card.grid(row=1, column=0, sticky="nsew", pady=(12, 0)) history_card.columnconfigure(0, weight=1) - history_card.rowconfigure(0, weight=1) + history_card.rowconfigure(1, weight=1) + filter_row = ttk.Frame(history_card, style="Root.TFrame") + filter_row.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 8)) + filter_row.columnconfigure(0, weight=1) + ttk.Label( + filter_row, + text="Signed Gitea releases and retained local versions", + style="SettingsHint.TLabel", + ).grid(row=0, column=0, sticky="w") + self._history_filter_value = tk.StringVar( + value=( + "All" + if self._get_selected_channel() == PRERELEASE_CHANNEL + else "Stable" + ) + ) + history_filter = ttk.Combobox( + filter_row, + name="launcher_update_channel_filter", + textvariable=self._history_filter_value, + values=("All", "Stable", "Prerelease"), + state="readonly", + style="Settings.TCombobox", + cursor="hand2", + exportselection=False, + takefocus=True, + width=12, + ) + history_filter.grid(row=0, column=1, sticky="e", padx=(12, 0)) + history_filter.bind("<>", lambda _event: self.refresh()) self._history = ttk.Treeview( history_card, - columns=("version", "state", "installed"), + columns=("version", "channel", "state", "installed"), show="headings", height=6, style="Update.Treeview", selectmode="browse", ) self._history.heading("version", text="VERSION", anchor="w") + self._history.heading("channel", text="CHANNEL", anchor="w") self._history.heading("state", text="STATE", anchor="w") - self._history.heading("installed", text="INSTALLED", anchor="w") + self._history.heading("installed", text="DATE", anchor="w") self._history.column( "version", width=round(120 * self._ui_scale), @@ -190,6 +259,13 @@ class LauncherUpdateDialog: stretch=False, anchor="w", ) + self._history.column( + "channel", + width=round(105 * self._ui_scale), + minwidth=round(80 * self._ui_scale), + stretch=False, + anchor="w", + ) self._history.column( "state", width=round(150 * self._ui_scale), @@ -204,14 +280,15 @@ class LauncherUpdateDialog: stretch=True, anchor="w", ) - self._history.grid(row=0, column=0, sticky="nsew") + self._history.grid(row=1, column=0, sticky="nsew") + self._history.bind("<>", self._history_selected) history_scrollbar = ttk.Scrollbar( history_card, orient="vertical", style="News.Vertical.TScrollbar", command=self._history.yview, ) - history_scrollbar.grid(row=0, column=1, sticky="ns", padx=(6, 0)) + history_scrollbar.grid(row=1, column=1, sticky="ns", padx=(6, 0)) self._history.configure(yscrollcommand=history_scrollbar.set) footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9)) @@ -298,37 +375,8 @@ class LauncherUpdateDialog: except RuntimeError as error: self._installed_history = None self._append_log(f"Could not inspect launcher update history: {error}") - for item in self._history.get_children(): - self._history.delete(item) - if self._installed_history is not None: - for release in self._installed_history.releases: - state_label = ( - "Current" - if release.active - else "Rollback target" - if release.previous - else "Retained" - ) - self._history.insert( - "", - "end", - values=(release.version, state_label, release.installed_at), - ) - if not self._history.get_children(): - self._history.insert( - "", - "end", - values=(self._launcher_version, "Running", "Not recorded"), - ) busy = self._is_busy() - rollback_available = ( - self._installed_history is not None - and self._installed_history.previous_version is not None - and not busy - ) - self._rollback_button.configure( - state="normal" if rollback_available else "disabled" - ) + self._populate_history(busy=busy) self._check_button.configure(state="disabled" if busy else "normal") if check is None: self._status_value.set("Checking the signed update channel…") @@ -338,21 +386,50 @@ class LauncherUpdateDialog: + " · release information pending" ) self._install_button.configure(state="disabled") - self._release_notes_button.configure(state="disabled") + self._refresh_history_actions(busy=busy) return status_prefix = ( f"Compatibility update required: {self._required_reason}\n" if self._required_reason is not None else "" ) + remote_history = self._get_remote_history() + installed_status = next( + ( + release.status + for release in ( + remote_history.releases if remote_history is not None else () + ) + if release.version == check.current_version and release.status != "active" + ), + None, + ) + if installed_status is not None: + status_prefix += ( + f"Warning: installed launcher {check.current_version} is " + f"{installed_status}; install the next active eligible release.\n" + ) self._status_value.set(status_prefix + check.reason) - self._details_value.set( - f"Current {check.current_version} · Latest {check.latest_version} · " - f"{check.manifest.channel.title()} channel · " + release_title = check.manifest.title or f"Launcher {check.latest_version}" + release_summary = check.manifest.summary or "No release summary was provided." + notes_preview = release_notes_preview(check.manifest.notes_markdown) + prerelease_label = ( + " · PRERELEASE BUILD" + if check.manifest.channel == "prerelease" + else "" + ) + details = ( + f"{check.current_version} → {check.latest_version}\n" + f"{release_title}{prerelease_label}\n" + f"{release_summary}\n" + f"Channel: {check.manifest.channel.title()} · " f"{format_bytes(check.manifest.archive_size)} · " f"published {check.manifest.published_at} · " f"checked {check.checked_at or 'time unavailable'}" ) + if notes_preview: + details += f"\n\nWhat's new:\n{notes_preview}" + self._details_value.set(details) self._install_button.configure( state=( "normal" @@ -363,6 +440,178 @@ class LauncherUpdateDialog: self._release_notes_button.configure( state="normal" if check.manifest.notes_url is not None else "disabled" ) + self._refresh_history_actions(busy=busy) + + def _populate_history(self, *, busy: bool) -> None: + assert self._history is not None + assert self._history_filter_value is not None + selected_version = None + selection = self._history.selection() + if selection: + selected = self._history_items.get(selection[0]) + selected_version = selected[0] if selected is not None else None + for item in self._history.get_children(): + self._history.delete(item) + self._history_items.clear() + installed = { + release.version: release + for release in ( + self._installed_history.releases + if self._installed_history is not None + else () + ) + } + current_version = ( + self._installed_history.current_version + if self._installed_history is not None + and self._installed_history.current_version is not None + else self._launcher_version + ) + try: + current_semantic = SemanticVersion.parse(current_version) + except ReleaseModelError: + current_semantic = None + filter_name = str(self._history_filter_value.get()).casefold() + + def channel_visible(channel: str) -> bool: + return filter_name == "all" or filter_name == channel + + remote_history = self._get_remote_history() + remote_versions: set[str] = set() + if remote_history is not None: + for index, release in enumerate(remote_history.releases): + if not channel_visible(release.channel): + continue + remote_versions.add(release.version) + local = installed.get(release.version) + is_older = ( + current_semantic is not None + and SemanticVersion.parse(release.version) < current_semantic + ) + can_rollback = release.status == "active" and is_older + state = self._remote_release_state( + release, + local_active=bool(local is not None and local.active), + local_retained=local is not None, + is_older=is_older, + ) + iid = f"remote:{index}:{release.version}" + self._history.insert( + "", + "end", + iid=iid, + values=( + release.version, + release.channel.title(), + state, + release.release_date, + ), + ) + self._history_items[iid] = ( + release.version, + release.channel, + release.release_notes_url, + can_rollback, + ) + if self._installed_history is not None: + for index, release in enumerate(self._installed_history.releases): + if release.version in remote_versions or not channel_visible(release.channel): + continue + try: + is_older = ( + current_semantic is not None + and SemanticVersion.parse(release.version) < current_semantic + ) + except ReleaseModelError: + is_older = False + state = "Current" if release.active else "Retained locally" + iid = f"local:{index}:{release.version}" + self._history.insert( + "", + "end", + iid=iid, + values=( + release.version, + release.channel.title(), + state, + release.installed_at, + ), + ) + self._history_items[iid] = ( + release.version, + release.channel, + None, + is_older, + ) + if not self._history.get_children(): + iid = "placeholder" + self._history.insert( + "", + "end", + iid=iid, + values=(self._launcher_version, "Unknown", "Running", "Not recorded"), + ) + default_version = selected_version + if default_version is None and self._installed_history is not None: + default_version = self._installed_history.previous_version + selected_iid = next( + ( + iid + for iid, item in self._history_items.items() + if item[0] == default_version + ), + None, + ) + if selected_iid is not None: + self._history.selection_set(selected_iid) + self._history.focus(selected_iid) + self._refresh_history_actions(busy=busy) + + @staticmethod + def _remote_release_state( + release: LauncherRemoteRelease, + *, + local_active: bool, + local_retained: bool, + is_older: bool, + ) -> str: + if local_active: + return "Current" + if release.status != "active": + return release.status.title() + if local_retained: + return "Retained locally" + if is_older: + return "Available rollback" + return "Published" + + def _history_selected(self, _event: object | None = None) -> None: + self._refresh_history_actions(busy=self._is_busy()) + + def _selected_history_item(self) -> tuple[str, str, str | None, bool] | None: + assert self._history is not None + selection = self._history.selection() + return self._history_items.get(selection[0]) if selection else None + + def _refresh_history_actions(self, *, busy: bool) -> None: + assert self._rollback_button is not None + assert self._release_notes_button is not None + selected = self._selected_history_item() + self._rollback_button.configure( + state=( + "normal" + if selected is not None and selected[3] and not busy + else "disabled" + ) + ) + if selected is None: + check = self._get_check() + notes_url = check.manifest.notes_url if check is not None else None + else: + notes_url = selected[2] + self._release_notes_button.configure( + state="normal" if notes_url is not None else "disabled" + ) def show_error(self, message: str) -> None: if self._window is None or not self._window.winfo_exists(): @@ -380,12 +629,17 @@ class LauncherUpdateDialog: assert self._check_button is not None assert self._install_button is not None self._status_value.set("Checking the signed update channel…") - self._details_value.set("Authenticating the stable manifest and signature.") + self._details_value.set("Authenticating the selected channel manifests and signatures.") self._check_button.configure(state="disabled") self._install_button.configure(state="disabled") self._request_check() def _view_release_notes(self) -> None: + selected = self._selected_history_item() + if selected is not None: + if selected[2] is not None: + self._open_article(selected[2]) + return check = self._get_check() if check is not None and check.manifest.notes_url is not None: self._open_article(check.manifest.notes_url) @@ -399,6 +653,7 @@ class LauncherUpdateDialog: title="Update launcher", message=( f"Install signed launcher {check.latest_version} " + f"from the {check.manifest.channel} channel " f"({format_bytes(check.manifest.archive_size)}) and restart now?" ), kind="question", @@ -413,15 +668,53 @@ class LauncherUpdateDialog: def _rollback_update(self) -> None: history = self._installed_history - if history is None or history.previous_version is None: + selected = self._selected_history_item() + if selected is not None and selected[3]: + target_version, target_channel, _notes_url, _can_rollback = selected + elif history is not None and history.previous_version is not None: + target_version = history.previous_version + target = next( + ( + release + for release in history.releases + if release.version == target_version + ), + None, + ) + target_channel = target.channel if target is not None else "stable" + else: return + current_release = next( + ( + release + for release in (history.releases if history is not None else ()) + if release.active + ), + None, + ) + current_channel = ( + current_release.channel + if current_release is not None + else SemanticVersion.parse(self._launcher_version).channel + ) + channel_warning = "" + if current_channel != target_channel: + channel_warning = ( + f"\n\nThis crosses from {current_channel.title()} to " + f"{target_channel.title()}. Older releases may use different " + "configuration schemas." + ) if not show_themed_dialog( self._window, title="Roll back launcher", message=( - f"Switch from launcher {history.current_version or self._launcher_version} " - f"to {history.previous_version} and restart?\n\n" + f"Switch from launcher " + f"{(history.current_version if history is not None else None) or self._launcher_version} " + f"to {target_version} and restart?\n\n" + "The target manifest, signature, archive, and file hashes will be " + "authenticated before activation.\n\n" "The current release remains available as the next rollback target." + + channel_warning ), kind="warning", confirm=True, @@ -429,6 +722,5 @@ class LauncherUpdateDialog: window_icon=self._window_icon, ): return - target_version = history.previous_version self.close() self._rollback_and_restart(target_version) diff --git a/ptlinux/ui/dashboard.py b/ptlinux/ui/dashboard.py index 891372a..bf931af 100644 --- a/ptlinux/ui/dashboard.py +++ b/ptlinux/ui/dashboard.py @@ -66,9 +66,13 @@ from ..overlay import RuntimeCompatibility, inspect_runtime_compatibility from ..paths import AppPaths from ..settings import Settings, ensure_settings, load_settings, update_settings from ..self_update import ( + LauncherRemoteHistory, LauncherUpdateCheck, + cache_launcher_release_history, cache_launcher_update_check, check_for_launcher_update, + fetch_launcher_release_history, + read_cached_launcher_release_history, read_cached_launcher_update_check, ) from ..server_status import RealmServerStatus @@ -665,6 +669,7 @@ def run_dashboard(paths: AppPaths) -> None: latest_installation_status: GameInstallationStatus | None = None available_start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,) preferred_realm = startup_settings.realm + selected_update_channel = startup_settings.update_channel latest_server_statuses: tuple[RealmServerStatus, ...] | None = None latest_server_status_check: ServerStatusCheck | None = None server_status_placeholder = "Checking official server status…" @@ -674,6 +679,7 @@ def run_dashboard(paths: AppPaths) -> None: dashboard_debug_state_error: str | None = None last_dashboard_debug_render = "" launcher_update_check: LauncherUpdateCheck | None = None + launcher_release_history: LauncherRemoteHistory | None = None runtime_compatibility: RuntimeCompatibility | None = None compatibility_check_generation = 0 controls_are_stacked: bool | None = None @@ -2282,14 +2288,18 @@ def run_dashboard(paths: AppPaths) -> None: launcher_update_dialog.refresh() def refresh_launcher_update_check() -> None: + requested_channel = selected_update_channel + def worker() -> None: try: - check = check_for_launcher_update() + check = check_for_launcher_update(channel=requested_channel) except RuntimeError as update_error: - events.put(("launcher_update_error", str(update_error))) + events.put( + ("launcher_update_error", (requested_channel, str(update_error))) + ) else: cache_launcher_update_check(paths.launcher_update_check_cache, check) - events.put(("launcher_update_check", check)) + events.put(("launcher_update_check", (requested_channel, check))) threading.Thread( target=worker, @@ -2297,6 +2307,24 @@ def run_dashboard(paths: AppPaths) -> None: daemon=True, ).start() + def history_worker() -> None: + try: + history = fetch_launcher_release_history() + except RuntimeError as history_error: + events.put(("launcher_history_error", str(history_error))) + else: + cache_launcher_release_history( + paths.launcher_release_history_cache, + history, + ) + events.put(("launcher_release_history", history)) + + threading.Thread( + target=history_worker, + name="ptlinux-release-history", + daemon=True, + ).start() + def refresh_remote_content() -> None: def broadcast_worker() -> None: events.put(("broadcast", fetch_latest_broadcast(paths.broadcast_cache))) @@ -2444,7 +2472,8 @@ def run_dashboard(paths: AppPaths) -> None: progress_text.set("See the activity log for details.") def poll_events() -> None: - nonlocal game_window_active, launcher_update_check, runtime_compatibility + nonlocal game_window_active, launcher_update_check, launcher_release_history + nonlocal runtime_compatibility processed = 0 try: while processed < EVENT_BATCH_SIZE: @@ -2657,16 +2686,31 @@ def run_dashboard(paths: AppPaths) -> None: "version", "Game version check complete", ) - elif kind == "launcher_update_check" and isinstance( - value, - LauncherUpdateCheck, + elif ( + kind == "launcher_update_check" + and isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], str) + and isinstance(value[1], LauncherUpdateCheck) ): - show_launcher_update_check(value) + requested_channel, update_check = value + if requested_channel != selected_update_channel: + continue + show_launcher_update_check(update_check) mark_startup_component_ready( "launcher_update", "Launcher update status ready", ) - elif kind == "launcher_update_error" and isinstance(value, str): + elif ( + kind == "launcher_update_error" + and isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], str) + and isinstance(value[1], str) + ): + requested_channel, update_error = value + if requested_channel != selected_update_channel: + continue if launcher_update_check is None: launcher_update_button.configure( text="CHECK LAUNCHER", @@ -2678,13 +2722,22 @@ def run_dashboard(paths: AppPaths) -> None: ), ) launcher_update_badge.pack_forget() - append_log(f"Launcher update check unavailable: {value}") + append_log(f"Launcher update check unavailable: {update_error}") if launcher_update_dialog is not None: - launcher_update_dialog.show_error(value) + launcher_update_dialog.show_error(update_error) mark_startup_component_ready( "launcher_update", "Launcher update check complete", ) + elif kind == "launcher_release_history" and isinstance( + value, + LauncherRemoteHistory, + ): + launcher_release_history = value + if launcher_update_dialog is not None: + launcher_update_dialog.refresh() + elif kind == "launcher_history_error" and isinstance(value, str): + append_log(f"Launcher release history unavailable: {value}") except queue.Empty: pass except Exception as event_error: @@ -2725,7 +2778,7 @@ def run_dashboard(paths: AppPaths) -> None: def rollback_launcher_and_restart(target_version: str) -> None: run_operation( f"Rolling back launcher to {target_version}…", - command_for("launcher-update", "--rollback"), + command_for("launcher-update", "--rollback", target_version), restart_on_success=True, ) @@ -2737,6 +2790,8 @@ def run_dashboard(paths: AppPaths) -> None: window_icon=window_icon, apply_window_icon=apply_window_icon, get_check=lambda: launcher_update_check, + get_remote_history=lambda: launcher_release_history, + get_selected_channel=lambda: selected_update_channel, is_busy=lambda: operation_active or bool(game_clients.running_count), request_check=refresh_launcher_update_check, install_and_restart=install_launcher_and_restart, @@ -2838,6 +2893,9 @@ def run_dashboard(paths: AppPaths) -> None: ) def apply_saved_settings(updated_settings: Settings) -> None: + nonlocal selected_update_channel, launcher_update_check + previous_update_channel = selected_update_channel + selected_update_channel = updated_settings.update_channel set_shared_telemetry_enabled(updated_settings.telemetry_enabled) saved_limit = ( f"{updated_settings.download_speed_limit_mbps:g} MB/s" @@ -2850,6 +2908,7 @@ def run_dashboard(paths: AppPaths) -> None: f"proton_sync={updated_settings.proton_sync}, " f"download_limit={saved_limit}, " f"low_bandwidth={updated_settings.low_bandwidth_mode}, " + f"update_channel={updated_settings.update_channel}, " f"telemetry={updated_settings.telemetry_enabled}" ) status.set("Settings saved.") @@ -2860,6 +2919,13 @@ def run_dashboard(paths: AppPaths) -> None: if show_dashboard_debug: refresh_dashboard_debug_disk_state() update_dashboard_debug_view(force=True) + if previous_update_channel != selected_update_channel: + launcher_update_check = None + append_log( + f"Launcher update channel changed from {previous_update_channel} " + f"to {selected_update_channel}; checking eligible releases." + ) + refresh_launcher_update_check() def open_settings() -> None: nonlocal settings_window @@ -3017,10 +3083,14 @@ def run_dashboard(paths: AppPaths) -> None: show_news(read_cached_news(paths.news_cache)) show_patch_notes(read_cached_patch_notes(paths.patches_cache)) cached_launcher_update = read_cached_launcher_update_check( - paths.launcher_update_check_cache + paths.launcher_update_check_cache, + channel=selected_update_channel, ) if cached_launcher_update is not None: show_launcher_update_check(cached_launcher_update, log_reason=False) + launcher_release_history = read_cached_launcher_release_history( + paths.launcher_release_history_cache, + ) refresh_runtime_compatibility() mark_startup_component_ready( "cached_content", diff --git a/ptlinux/ui/dialogs/settings_dialog.py b/ptlinux/ui/dialogs/settings_dialog.py index 107c20f..7558142 100644 --- a/ptlinux/ui/dialogs/settings_dialog.py +++ b/ptlinux/ui/dialogs/settings_dialog.py @@ -36,6 +36,10 @@ PROTON_SYNC_LABELS: dict[str, str] = { "Fsync": "fsync", "Esync": "esync", } +UPDATE_CHANNEL_LABELS: dict[str, str] = { + "Stable — Recommended for normal use": "stable", + "Prerelease — Beta and release candidate builds": "prerelease", +} SETTINGS_STACKED_LAYOUT_WIDTH = 720 SETTINGS_STACKED_FOOTER_WIDTH = 640 @@ -207,6 +211,9 @@ def open_settings_dialog( low_bandwidth_value = tk.BooleanVar( value=current_settings.low_bandwidth_mode ) + update_channel_value = tk.StringVar( + value=setting_label(current_settings.update_channel, UPDATE_CHANNEL_LABELS) + ) def add_choice( parent: ttk.LabelFrame, @@ -325,6 +332,10 @@ def open_settings_dialog( game_source=game_source_value.get().strip() or None, download_speed_limit_mbps=download_speed_limit, low_bandwidth_mode=low_bandwidth_value.get(), + update_channel=setting_value( + update_channel_value.get(), + UPDATE_CHANNEL_LABELS, + ), ) except (OSError, ValueError) as save_error: show_themed_dialog( @@ -526,6 +537,42 @@ def open_settings_dialog( ) update_custom_download_state() + updates_card = ttk.LabelFrame( + content, + text="LAUNCHER UPDATES", + style="SettingsCard.TLabelframe", + padding=14, + ) + updates_card.grid( + row=2, + column=0, + columnspan=2, + sticky="ew", + pady=(12, 0), + ) + updates_card.columnconfigure(0, weight=1) + updates_card.columnconfigure(1, weight=1) + add_choice( + updates_card, + 0, + "settings_selector_update_channel", + "Update channel", + "Existing installations default to Stable", + update_channel_value, + tuple(UPDATE_CHANNEL_LABELS), + ) + updates_hint = ttk.Label( + updates_card, + text=( + "Stable receives production releases. Prerelease also receives beta and " + "release candidate builds. Returning to Stable never downgrades the " + "installed launcher automatically." + ), + style="SettingsHint.TLabel", + wraplength=720, + ) + updates_hint.grid(row=2, column=0, columnspan=2, sticky="w") + diagnostics_card = ttk.LabelFrame( content, text="PRIVACY & DIAGNOSTICS", @@ -533,7 +580,7 @@ def open_settings_dialog( padding=14, ) diagnostics_card.grid( - row=2, + row=3, column=0, columnspan=2, sticky="ew", @@ -568,7 +615,7 @@ def open_settings_dialog( style="SettingsCard.TLabelframe", padding=14, ) - overrides_card.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0)) + overrides_card.grid(row=4, column=0, columnspan=2, sticky="ew", pady=(12, 0)) overrides_card.columnconfigure(0, weight=1) ttk.Label( overrides_card, @@ -652,6 +699,9 @@ def open_settings_dialog( card_wraplength = max(140, card_width - round(42 * launcher_ui_scale)) display_hint.configure(wraplength=card_wraplength) rendering_hint.configure(wraplength=card_wraplength) + updates_hint.configure( + wraplength=max(140, viewport_width - round(42 * launcher_ui_scale)) + ) diagnostics_hint.configure( wraplength=max(140, viewport_width - round(42 * launcher_ui_scale)) ) @@ -685,8 +735,9 @@ def open_settings_dialog( pady=(12, 0), ) downloads_card.grid_configure(row=2, pady=(12, 0)) - diagnostics_card.grid_configure(row=3, pady=(12, 0)) - overrides_card.grid_configure(row=4, pady=(12, 0)) + updates_card.grid_configure(row=3, pady=(12, 0)) + diagnostics_card.grid_configure(row=4, pady=(12, 0)) + overrides_card.grid_configure(row=5, pady=(12, 0)) else: display_card.grid_configure( row=0, @@ -705,8 +756,9 @@ def open_settings_dialog( pady=0, ) downloads_card.grid_configure(row=1, pady=(12, 0)) - diagnostics_card.grid_configure(row=2, pady=(12, 0)) - overrides_card.grid_configure(row=3, pady=(12, 0)) + updates_card.grid_configure(row=2, pady=(12, 0)) + diagnostics_card.grid_configure(row=3, pady=(12, 0)) + overrides_card.grid_configure(row=4, pady=(12, 0)) if footer_stacked: footer_note.grid_configure(row=0, column=0, columnspan=2)