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;