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:
+138
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user