Added resolution settings and updater modal with rollback functionality for launcher versions.

This commit is contained in:
2026-09-02 23:18:16 +02:00
parent bb90efa9d1
commit ba079ef6fa
4 changed files with 1065 additions and 213 deletions

View File

@@ -74,6 +74,7 @@ class LauncherUpdateCheck:
reason: str
manifest_url: str
manifest: UpdateManifest
checked_at: str | None = None
@dataclass(frozen=True)
@@ -110,6 +111,26 @@ class LauncherRollbackResult:
restart_required: bool
@dataclass(frozen=True)
class LauncherInstalledRelease:
"""One validated release retained in the launcher's version store."""
version: str
installed_at: str
directory: str
active: bool
previous: bool
@dataclass(frozen=True)
class LauncherUpdateHistory:
"""Validated launcher releases available for inspection or rollback."""
current_version: str | None
previous_version: str | None
releases: tuple[LauncherInstalledRelease, ...]
def default_public_key_path() -> Path:
return Path(__file__).with_name(PUBLIC_KEY_FILENAME)
@@ -157,7 +178,12 @@ def check_for_launcher_update(
)
_verify_manifest_signature(manifest_bytes, signature_bytes, key)
manifest = parse_update_manifest(manifest_bytes)
return _evaluate_launcher_update(manifest, current_version, url)
return _evaluate_launcher_update(
manifest,
current_version,
url,
checked_at=datetime.now().astimezone().isoformat(timespec="seconds"),
)
def read_cached_launcher_update_check(
@@ -198,13 +224,21 @@ def read_cached_launcher_update_check(
manifest_value = value.get("manifest")
if not isinstance(manifest_value, dict):
return None
checked_at = _cached_timestamp(value.get("checked_at"))
if checked_at is None:
return None
try:
manifest = parse_update_manifest(
(json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8")
)
except (LauncherUpdateError, TypeError, ValueError):
return None
return _evaluate_launcher_update(manifest, current_version, url)
return _evaluate_launcher_update(
manifest,
current_version,
url,
checked_at=checked_at,
)
def cache_launcher_update_check(
@@ -223,7 +257,8 @@ def cache_launcher_update_check(
cache_path,
{
"schema": UPDATE_CHECK_CACHE_SCHEMA,
"checked_at": datetime.now().astimezone().isoformat(),
"checked_at": check.checked_at
or datetime.now().astimezone().isoformat(timespec="seconds"),
"current_version": check.current_version,
"manifest_url": check.manifest_url,
"public_key_sha256": key_sha256,
@@ -239,6 +274,8 @@ def _evaluate_launcher_update(
manifest: UpdateManifest,
current_version: str,
manifest_url: str,
*,
checked_at: str | None = None,
) -> LauncherUpdateCheck:
current = _parse_version(current_version, "current launcher version")
latest = _parse_version(manifest.version, "manifest version")
@@ -269,6 +306,7 @@ def _evaluate_launcher_update(
reason=reason,
manifest_url=manifest_url,
manifest=manifest,
checked_at=checked_at,
)
@@ -408,12 +446,22 @@ def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult:
raise LauncherUpdateError("the current launcher release link is missing")
if previous is None:
raise LauncherUpdateError("no previous launcher release is available")
current_version = _validate_release_directory(current)
previous_version = _validate_release_directory(previous)
if current == previous:
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, paths.launcher_releases)
_replace_release_link(paths.launcher_current, previous, paths.launcher_releases)
_replace_release_link(
paths.launcher_previous,
current_release,
paths.launcher_releases,
)
_replace_release_link(
paths.launcher_current,
previous_release,
paths.launcher_releases,
)
_fsync_directory(paths.app_home)
_write_json(
paths.launcher_update_state,
@@ -422,19 +470,64 @@ def rollback_launcher_update(paths: AppPaths) -> LauncherRollbackResult:
"rolled_back_at": datetime.now().astimezone().isoformat(),
"from_version": current_version,
"to_version": previous_version,
"release_directory": str(previous),
"previous_release": str(current),
"release_directory": str(previous_release),
"previous_release": str(current_release),
},
)
return LauncherRollbackResult(
from_version=current_version,
to_version=previous_version,
current_release=str(previous),
previous_release=str(current),
current_release=str(previous_release),
previous_release=str(current_release),
restart_required=True,
)
def inspect_launcher_update_history(paths: AppPaths) -> LauncherUpdateHistory:
"""Return validated retained releases without trusting directory names alone."""
current = _release_from_link(paths.launcher_current, paths.launcher_releases)
previous = _release_from_link(paths.launcher_previous, paths.launcher_releases)
try:
candidates = tuple(paths.launcher_releases.iterdir())
except FileNotFoundError:
candidates = ()
except OSError as exc:
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:
continue
try:
version = _validate_release_directory(candidate)
installed_at = datetime.fromtimestamp(
candidate.stat().st_mtime
).astimezone().isoformat(timespec="seconds")
except (LauncherUpdateError, OSError, ValueError, OverflowError):
continue
if version != candidate.name:
continue
resolved = candidate.resolve()
releases.append(
LauncherInstalledRelease(
version=version,
installed_at=installed_at,
directory=str(resolved),
active=resolved == current,
previous=resolved == previous,
)
)
releases.sort(
key=lambda release: _parse_version(release.version, "launcher release version"),
reverse=True,
)
return LauncherUpdateHistory(
current_version=_release_version(releases, active=True),
previous_version=_release_version(releases, previous=True),
releases=tuple(releases),
)
def parse_update_manifest(data: bytes) -> UpdateManifest:
"""Strictly parse one already-authenticated manifest."""
@@ -857,9 +950,15 @@ 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 and current != release:
_validate_release_directory(current)
_replace_release_link(paths.launcher_previous, current, paths.launcher_releases)
if current is not None:
current_release = current
if current_release != release:
_validate_release_directory(current_release)
_replace_release_link(
paths.launcher_previous,
current_release,
paths.launcher_releases,
)
_replace_release_link(paths.launcher_current, release, paths.launcher_releases)
_fsync_directory(paths.app_home)
return current
@@ -980,6 +1079,30 @@ def _parse_published_at(value: str) -> datetime:
return parsed
def _cached_timestamp(value: object) -> str | None:
if not isinstance(value, str) or not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None or parsed.utcoffset() is None:
return None
return value
def _release_version(
releases: list[LauncherInstalledRelease],
*,
active: bool = False,
previous: bool = False,
) -> str | None:
for release in releases:
if (active and release.active) or (previous and release.previous):
return release.version
return None
def _required_string(value: dict[str, object], key: str) -> str:
item = value.get(key)
if not isinstance(item, str) or not item:

View File

@@ -16,21 +16,27 @@ from .paths import AppPaths
SETTINGS_SCHEMA = 1
_TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated"
UI_SCALE_CHOICES = frozenset({"auto", "100", "125", "150", "175", "200"})
_RETIRED_DISPLAY_SETTINGS = frozenset(
{
"width",
"height",
"output_width",
"output_height",
"refresh_rate",
"fullscreen",
"scaler",
"filter",
}
)
@dataclass(frozen=True)
class Settings:
realm: str = "Babel"
width: int = 1600
height: int = 900
output_width: int = 1600
output_height: int = 900
refresh_rate: int = 60
fullscreen: bool = False
ui_scale: str = "auto"
borderless: bool = False
suppress_unresponsive_warnings: bool = True
scaler: str = "fit"
filter: str = "linear"
runtime: str = "auto"
proton_sync: str = "auto"
proton_path: str | None = None
@@ -136,20 +142,13 @@ def _read_settings_object(path: Path) -> dict[str, object] | None:
def _settings_from_object(value: dict[str, object]) -> Settings:
settings = Settings(
realm=_string(value, "realm", "Babel").strip(),
width=_integer(value, "width", 1600),
height=_integer(value, "height", 900),
output_width=_integer(value, "output_width", 1600),
output_height=_integer(value, "output_height", 900),
refresh_rate=_integer(value, "refresh_rate", 60),
fullscreen=_boolean(value, "fullscreen", False),
ui_scale=_string(value, "ui_scale", "auto"),
borderless=_migrated_borderless(value),
suppress_unresponsive_warnings=_boolean(
value,
"suppress_unresponsive_warnings",
True,
),
scaler=_string(value, "scaler", "fit"),
filter=_string(value, "filter", "linear"),
runtime=_string(value, "runtime", "auto"),
proton_sync=_string(value, "proton_sync", "auto"),
proton_path=_optional_string(value, "proton_path"),
@@ -192,6 +191,7 @@ def _settings_need_migration(value: dict[str, object]) -> bool:
value.get("schema") != SETTINGS_SCHEMA
or not _SETTINGS_FIELD_NAMES.issubset(value)
or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True
or bool(_RETIRED_DISPLAY_SETTINGS.intersection(value))
)
@@ -201,6 +201,8 @@ def _write_settings(
existing: dict[str, object] | None,
) -> None:
payload = dict(existing or {})
for name in _RETIRED_DISPLAY_SETTINGS:
payload.pop(name, None)
payload.update(asdict(settings))
payload["schema"] = SETTINGS_SCHEMA
payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True
@@ -242,33 +244,13 @@ def _validate(settings: Settings) -> None:
or any(ord(character) < 32 for character in settings.realm)
):
raise ValueError("realm must be a non-empty display name")
for name, value in (
("width", settings.width),
("height", settings.height),
("output_width", settings.output_width),
("output_height", settings.output_height),
):
if value < 640 or value > 16384:
raise ValueError(f"{name} must be between 640 and 16384")
if settings.refresh_rate < 24 or settings.refresh_rate > 1000:
raise ValueError("refresh_rate must be between 24 and 1000")
if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}:
raise ValueError(f"unsupported legacy scaler setting: {settings.scaler}")
if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}:
raise ValueError(f"unsupported legacy filter setting: {settings.filter}")
if settings.ui_scale not in UI_SCALE_CHOICES:
raise ValueError(f"unsupported launcher UI scale: {settings.ui_scale}")
if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}:
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}")
def _integer(value: dict[str, object], key: str, default: int) -> int:
item = value.get(key, default)
if isinstance(item, bool) or not isinstance(item, int):
raise ValueError(f"{key} must be an integer")
return item
def _boolean(value: dict[str, object], key: str, default: bool) -> bool:
item = value.get(key, default)
if not isinstance(item, bool):

View File

@@ -26,6 +26,7 @@ _DIALOG_PALETTES: dict[str, DialogPalette] = {
"error": DialogPalette("#cf604c", "#3a1d19", "×"),
"question": DialogPalette("#d98927", "#3a2819", "?"),
}
_BASE_TK_SCALING = 96.0 / 72.0
def dialog_palette(kind: DialogKind) -> DialogPalette:
@@ -111,6 +112,16 @@ def dragged_window_geometry(
)
def widget_ui_scale(widget: Any) -> float:
"""Return the launcher scale already configured on a Tk interpreter."""
try:
scaling = float(widget.tk.call("tk", "scaling"))
except (AttributeError, TypeError, ValueError):
return 1.0
return min(2.0, max(1.0, scaling / _BASE_TK_SCALING))
def _geometry_offset(value: int) -> str:
return f"+{value}" if value >= 0 else str(value)
@@ -138,7 +149,7 @@ def _bind_window_drag(handle: Any, *widgets: Any) -> None:
class DashboardChrome:
"""Controller for one frameless launcher window and its controls."""
"""Controller for one launcher window and its custom controls."""
def __init__(
self,
@@ -310,9 +321,10 @@ def create_window_chrome(
show_minimize: bool = False,
allow_close: bool = True,
) -> DashboardChrome:
"""Install polished managed chrome around one launcher-owned Tk window."""
"""Install the launcher's custom frame and window controls."""
import tkinter as tk
ui_scale = widget_ui_scale(root)
managed, identity_configured = _configure_x11_window(
root,
@@ -338,7 +350,7 @@ def create_window_chrome(
title_bar = tk.Frame(
surface,
background="#17100d",
height=32,
height=round(32 * ui_scale),
borderwidth=0,
highlightthickness=0,
cursor="fleur",
@@ -354,7 +366,7 @@ def create_window_chrome(
factor = icon_subsample_factor(
window_icon.width(),
window_icon.height(),
20,
round(20 * ui_scale),
)
title_icon = window_icon.subsample(factor, factor)
except (tk.TclError, ValueError):
@@ -363,8 +375,8 @@ def create_window_chrome(
mark = tk.Label(
title_bar,
image=title_icon,
width=22,
height=22,
width=round(22 * ui_scale),
height=round(22 * ui_scale),
background="#17100d",
borderwidth=0,
highlightthickness=0,
@@ -374,8 +386,8 @@ def create_window_chrome(
else:
mark = tk.Frame(
title_bar,
width=22,
height=22,
width=round(22 * ui_scale),
height=round(22 * ui_scale),
background="#17100d",
borderwidth=0,
highlightthickness=0,
@@ -414,10 +426,12 @@ def create_window_chrome(
font=("Sans", 12),
width=4,
borderwidth=0,
highlightthickness=0,
highlightthickness=2,
highlightbackground="#17100d",
highlightcolor="#f4bd61",
relief="flat",
cursor="hand2",
takefocus=False,
takefocus=True,
)
close_button.pack(side="right", fill="y")
minimize_button: Any = None
@@ -432,10 +446,12 @@ def create_window_chrome(
font=("Sans", 10, "bold"),
width=4,
borderwidth=0,
highlightthickness=0,
highlightthickness=2,
highlightbackground="#17100d",
highlightcolor="#f4bd61",
relief="flat",
cursor="hand2",
takefocus=False,
takefocus=True,
)
minimize_button.pack(side="right", fill="y")
content = tk.Frame(
@@ -489,9 +505,10 @@ def show_themed_dialog(
cancel_text: str = "CANCEL",
window_icon: Any | None = None,
) -> bool:
"""Show one frameless modal dialog matching the native launcher palette."""
"""Show one custom-framed modal dialog matching the launcher palette."""
import tkinter as tk
ui_scale = widget_ui_scale(parent)
palette = dialog_palette(kind)
dialog = tk.Toplevel(parent)
@@ -524,7 +541,11 @@ def show_themed_dialog(
border.pack(fill="both", expand=True)
surface = tk.Frame(border, background="#191311")
surface.pack(fill="both", expand=True, padx=1, pady=1)
header = tk.Frame(surface, background="#24170f", height=66)
header = tk.Frame(
surface,
background="#24170f",
height=round(66 * ui_scale),
)
header.pack(fill="x")
header.pack_propagate(False)
tk_accent = tk.Frame(header, background=palette.accent, width=5)
@@ -560,21 +581,24 @@ def show_themed_dialog(
font=("Sans", 12),
width=4,
borderwidth=0,
highlightthickness=0,
highlightthickness=2,
highlightbackground="#24170f",
highlightcolor="#f4bd61",
relief="flat",
cursor="hand2",
takefocus=False,
takefocus=True,
)
close_button.pack(side="right", fill="y")
body = tk.Frame(surface, background="#191311", padx=20, pady=20)
body.pack(fill="both", expand=True)
modal_icon_size = round(46 * ui_scale)
modal_icon: Any | None = None
if window_icon is not None:
try:
factor = icon_subsample_factor(
window_icon.width(),
window_icon.height(),
46,
modal_icon_size,
)
modal_icon = window_icon.subsample(factor, factor)
except (tk.TclError, ValueError):
@@ -582,8 +606,8 @@ def show_themed_dialog(
if modal_icon is not None:
icon_host = tk.Frame(
body,
width=46,
height=46,
width=modal_icon_size,
height=modal_icon_size,
background="#191311",
borderwidth=0,
highlightthickness=0,
@@ -601,24 +625,24 @@ def show_themed_dialog(
else:
icon = tk.Canvas(
body,
width=46,
height=46,
width=modal_icon_size,
height=modal_icon_size,
background="#191311",
borderwidth=0,
highlightthickness=0,
)
icon.create_oval(
3,
3,
43,
43,
round(3 * ui_scale),
round(3 * ui_scale),
modal_icon_size - round(3 * ui_scale),
modal_icon_size - round(3 * ui_scale),
fill=palette.accent_soft,
outline=palette.accent,
width=2,
)
icon.create_text(
23,
23,
modal_icon_size // 2,
modal_icon_size // 2,
text=palette.glyph,
fill=palette.accent,
font=("Sans", 17, "bold"),
@@ -632,7 +656,7 @@ def show_themed_dialog(
font=("Sans", 10),
justify="left",
anchor="w",
wraplength=420,
wraplength=round(420 * ui_scale),
)
message_label.pack(side="left", fill="both", expand=True, anchor="n")
footer = tk.Frame(surface, background="#100d0c", padx=18, pady=12)
@@ -660,7 +684,9 @@ def show_themed_dialog(
padx=16,
pady=7,
borderwidth=0,
highlightthickness=0,
highlightthickness=2,
highlightbackground="#100d0c",
highlightcolor="#f4bd61",
relief="flat",
cursor="hand2",
)
@@ -681,12 +707,16 @@ def show_themed_dialog(
)
cancel_button.pack(side="right", padx=(0, 8))
_bind_window_drag(dialog, header, header_copy, eyebrow, title_label)
dialog.protocol("WM_DELETE_WINDOW", lambda: finish(False))
dialog.bind("<Escape>", lambda _event: finish(False))
dialog.bind("<Return>", lambda _event: finish(True))
parent.update_idletasks()
dialog.update_idletasks()
width = 560
height = max(218, dialog.winfo_reqheight())
width = min(round(560 * ui_scale), max(1, dialog.winfo_screenwidth() - 64))
height = min(
max(round(218 * ui_scale), dialog.winfo_reqheight()),
max(1, dialog.winfo_screenheight() - 64),
)
dialog.geometry(
centered_window_geometry(
width,

File diff suppressed because it is too large Load Diff