feat: LAN update check on startup with notify banner (stage 2)

Read manifest.json from the configured update_source and show a dismissable
banner when a newer version is available. Notify-only — no install yet.

- services/update_service.py: version compare + check_for_update (pure, tested)
- config: add update_source key (empty = no check)
- main_window: top banner, background-thread check; open folder via os.startfile
  (QDesktopServices.openUrl mishandles file:// folder URLs — ShellExecute err 2)
- tests: +15 covering version compare and check_for_update branches
- docs 02/05/10 + tasks 17.16

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:05:46 +08:00
co-authored by Claude Opus 4.8
parent f2b08fecb3
commit 0392e1c8a2
8 changed files with 373 additions and 9 deletions
+138 -1
View File
@@ -1,10 +1,15 @@
import logging
import os
import threading
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QSplitter,
@@ -16,6 +21,7 @@ from PySide6.QtWidgets import (
from version import APP_NAME, APP_VERSION
from core.models import BatchMode, TransformState
from services.config_service import load_config, save_config
from services.update_service import check_for_update
from app.widgets.export_panel import ExportPanel
from app.widgets.image_canvas import ImageCanvas
from app.widgets.image_list_panel import ImageListPanel
@@ -33,6 +39,10 @@ _TABBAR_HEIGHT = 34
class MainWindow(QMainWindow):
# Emitted from the background update-check thread; delivered to the UI
# thread via Qt's queued connection so the banner is built on the main thread.
_update_found = Signal(object) # UpdateInfo
def __init__(self):
super().__init__()
self.setWindowTitle("{} v{}".format(APP_NAME, APP_VERSION))
@@ -59,6 +69,9 @@ class MainWindow(QMainWindow):
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Update notification banner (hidden until a newer version is found).
layout.addWidget(self._create_update_banner())
# No in-app title bar: the OS window title (setWindowTitle) already
# shows the app name and version, so an in-content header would just
# duplicate it. Start straight from the workflow tab bar.
@@ -70,6 +83,7 @@ class MainWindow(QMainWindow):
self._apply_stylesheet()
self._connect_signals()
self._restore_preferences()
self._start_update_check()
def _create_tab_bar(self):
"""Workflow step selector (QTabBar only — no swappable pane)."""
@@ -96,6 +110,102 @@ class MainWindow(QMainWindow):
return container
# ------------------------------------------------------------------
# Update notification (stage 2: notify-only, see docs/10-lan-update.md)
# ------------------------------------------------------------------
def _create_update_banner(self):
"""A thin info bar shown when a newer version is found. Hidden by default."""
self._update_info = None
bar = QWidget()
bar.setObjectName("updateBanner")
bar.setVisible(False)
row = QHBoxLayout(bar)
row.setContentsMargins(12, 5, 8, 5)
row.setSpacing(8)
self._update_label = QLabel()
self._update_label.setObjectName("updateBannerText")
open_btn = QPushButton("打开更新目录")
open_btn.setObjectName("updateBannerOpen")
open_btn.setCursor(Qt.PointingHandCursor)
open_btn.clicked.connect(self._open_update_source)
close_btn = QPushButton("✕")
close_btn.setObjectName("updateBannerClose")
close_btn.setFixedWidth(24)
close_btn.setCursor(Qt.PointingHandCursor)
close_btn.setToolTip("关闭")
close_btn.clicked.connect(lambda: self._update_banner.setVisible(False))
row.addWidget(self._update_label)
row.addStretch()
row.addWidget(open_btn)
row.addWidget(close_btn)
self._update_banner = bar
return bar
def _start_update_check(self):
"""Check the configured LAN source for a newer version, off the UI thread."""
source = self._config.get("update_source", "")
if not source:
return
self._update_found.connect(self._on_update_found)
def worker():
try:
info = check_for_update(source, APP_VERSION)
except Exception: # never let the thread crash startup
logger.exception("Update check failed")
return
if info:
self._update_found.emit(info)
threading.Thread(target=worker, name="update-check", daemon=True).start()
def _on_update_found(self, info):
"""Show the update banner (runs on the UI thread via queued signal)."""
self._update_info = info
text = "发现新版本 v{},当前 v{}。".format(info.version, APP_VERSION)
if info.notes:
text += " " + info.notes
self._update_label.setText(text)
self._update_banner.setVisible(True)
def _open_update_source(self):
"""Open the update folder in the file explorer.
Prefer the version folder from the manifest; fall back to the configured
source root (which we know exists — the manifest was just read from it).
os.startfile is the reliable way to open a directory on Windows;
QDesktopServices.openUrl mishandles file:// URLs to folders (ShellExecute
error 2), so it is only a secondary fallback.
"""
if not self._update_info:
return
candidates = [
self._update_info.source,
self._config.get("update_source", ""),
]
for path in candidates:
if not path or not Path(path).exists():
continue
try:
os.startfile(path) # noqa: S606 — native Explorer open
return
except (OSError, AttributeError) as exc:
logger.warning("startfile failed for %s: %s", path, exc)
if QDesktopServices.openUrl(QUrl.fromLocalFile(path)):
return
shown = self._update_info.source or self._config.get("update_source", "")
QMessageBox.information(
self, "更新目录",
"无法自动打开更新目录,请手动前往:\n{}".format(shown),
)
def _create_work_area(self):
"""Horizontal splitter: left material panel | canvas | right params."""
splitter = QSplitter(Qt.Horizontal)
@@ -469,6 +579,33 @@ class MainWindow(QMainWindow):
background-color: #f0f0f0;
}
/* Update notification banner */
#updateBanner {
background-color: #eef6ff;
border-bottom: 1px solid #cfe3fa;
}
#updateBannerText {
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 12px;
color: #1b4f86;
}
#updateBannerOpen {
font-size: 12px;
padding: 3px 12px;
border: 1px solid #0078d4;
border-radius: 3px;
color: #0078d4;
background: transparent;
}
#updateBannerOpen:hover { background: #d8e9fb; }
#updateBannerClose {
font-size: 12px;
border: none;
color: #6a8bab;
background: transparent;
}
#updateBannerClose:hover { color: #1b4f86; }
/* Flow tab bar */
#tabBarContainer {
background-color: #f0f0f0;
+1
View File
@@ -11,6 +11,7 @@ DEFAULT_CONFIG = {
"last_print_dir": "",
"last_template": "", # name of the last-selected template
"last_batch_mode": "full_combo", # BatchMode value of the last-used mode
"update_source": "", # LAN folder holding manifest.json (empty = no update check)
}
_CONFIG_FILENAME = "app_config.json"
+93
View File
@@ -0,0 +1,93 @@
"""LAN update check (stage 2: notify-only).
Reads a manifest.json from a configured LAN folder and reports whether a newer
version is advertised. This module performs no installation — it only decides
whether to notify the user. See docs/10-lan-update.md.
All functions degrade gracefully: a missing/unreachable source or a malformed
manifest yields "no update" rather than an error, so an update check can never
block or break startup.
"""
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
MANIFEST_NAME = "manifest.json"
@dataclass
class UpdateInfo:
"""A newer version advertised by the update source."""
version: str
source: str # folder the user opens to get the new version
notes: str = ""
mandatory: bool = False
def parse_version(text) -> tuple:
"""Parse 'a.b.c' into a comparable (a, b, c) int tuple.
Tolerant: missing parts pad with 0, non-numeric suffixes (e.g. '1rc2')
keep their leading digits, fully non-numeric parts become 0.
"""
nums = []
for part in str(text).strip().split(".")[:3]:
digits = ""
for ch in part:
if ch.isdigit():
digits += ch
else:
break
nums.append(int(digits) if digits else 0)
while len(nums) < 3:
nums.append(0)
return tuple(nums)
def is_newer(remote, local) -> bool:
"""True if version string *remote* is strictly newer than *local*."""
return parse_version(remote) > parse_version(local)
def check_for_update(update_source, current_version) -> Optional[UpdateInfo]:
"""Return UpdateInfo if *update_source* advertises a version newer than
*current_version*, else None.
Returns None (never raises) when:
- no source is configured,
- the source/manifest is unreachable or unreadable,
- the manifest is malformed,
- the advertised version is not newer.
"""
if not update_source:
return None
manifest_path = Path(update_source) / MANIFEST_NAME
try:
with open(str(manifest_path), encoding="utf-8") as f:
data = json.load(f)
except (OSError, ValueError) as exc:
logger.info("Update check skipped (%s): %s", type(exc).__name__, exc)
return None
if not isinstance(data, dict):
logger.warning("Manifest is not a JSON object, ignoring: %s", manifest_path)
return None
version = str(data.get("version", "")).strip()
if not version or not is_newer(version, current_version):
return None
source = str(data.get("source", "")).strip() or str(update_source)
info = UpdateInfo(
version=version,
source=source,
notes=str(data.get("notes", "")),
mandatory=bool(data.get("mandatory", False)),
)
logger.info("Update available: v%s (current v%s)", version, current_version)
return info