Updated the update system to support multi channels (stable / pre-release).

This commit is contained in:
2026-09-09 20:52:00 +02:00
parent 2a53a59cef
commit 5770969629
12 changed files with 3329 additions and 254 deletions

View File

@@ -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("<<ComboboxSelected>>", 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("<<TreeviewSelect>>", 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)