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
+3 -1
View File
@@ -330,7 +330,8 @@ CMBot/
"last_garment_dir": "", "last_garment_dir": "",
"last_print_dir": "", "last_print_dir": "",
"last_template": "正方形模板", "last_template": "正方形模板",
"last_batch_mode": "full_combo" "last_batch_mode": "full_combo",
"update_source": ""
} }
``` ```
@@ -339,6 +340,7 @@ CMBot/
- `last_garment_dir` / `last_print_dir`:上次打开的衣服 / 印花文件夹路径,用于让文件夹对话框定位到上次位置。 - `last_garment_dir` / `last_print_dir`:上次打开的衣服 / 印花文件夹路径,用于让文件夹对话框定位到上次位置。
- `last_template`:上次选中的模板名称,用于启动恢复。 - `last_template`:上次选中的模板名称,用于启动恢复。
- `last_batch_mode`:上次选择的批量模式(取 `BatchMode` 枚举值,如 `full_combo` / `many_garments` / `many_prints` / `one_to_one`)。 - `last_batch_mode`:上次选择的批量模式(取 `BatchMode` 枚举值,如 `full_combo` / `many_garments` / `many_prints` / `one_to_one`)。
- `update_source`:局域网更新源目录(含 `manifest.json`)。为空时不做更新检查。详见 `docs/10-lan-update.md`。
- 偏好的读取、分发与「改一次存一次」由主窗口集中处理,UI 控件不直接读写配置文件。 - 偏好的读取、分发与「改一次存一次」由主窗口集中处理,UI 控件不直接读写配置文件。
## 10. 验收标准 ## 10. 验收标准
+1 -1
View File
@@ -240,7 +240,7 @@ BatchResult
- 读取和保存应用配置。 - 读取和保存应用配置。
- 管理默认配置。 - 管理默认配置。
- 在配置损坏或缺失时提供安全默认值。 - 在配置损坏或缺失时提供安全默认值。
- 持久化用户偏好:输出设置、上次的衣服/印花文件夹(`last_garment_dir`/`last_print_dir`)、上次选择的模板(`last_template`)、上次选择的批量模式(`last_batch_mode`)等。 - 持久化用户偏好:输出设置、上次的衣服/印花文件夹(`last_garment_dir`/`last_print_dir`)、上次选择的模板(`last_template`)、上次选择的批量模式(`last_batch_mode`)、局域网更新源(`update_source`)等。
由主窗口集中使用:启动时加载一次并把初值分发给各面板,面板选择变化时「改一次存一次」回写。各 UI 控件不直接读写配置文件,避免分散解析。 由主窗口集中使用:启动时加载一次并把初值分发给各面板,面板选择变化时「改一次存一次」回写。各 UI 控件不直接读写配置文件,避免分散解析。
+6 -6
View File
@@ -116,7 +116,7 @@
## 7. 更新源与版本清单 ## 7. 更新源与版本清单
更新源为内网共享目录(UNC 路径)或内网文件服务,例如: 更新源路径由 `app_config.json` 的 `update_source` 配置(见 `docs/02-prd.md`);为空时不做更新检查。更新源为内网共享目录(UNC 路径)或内网文件服务,例如:
```text ```text
\\nas\cmbot\releases\ \\nas\cmbot\releases\
@@ -225,12 +225,12 @@
## 16. 实现阶段建议 ## 16. 实现阶段建议
建议分阶段落地,每阶段可独立验证: 分阶段落地,每阶段可独立验证:
1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提,先行完成并通过现有测试。 1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。
2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示并打开更新源目录(不自动安装)。验证版本检查与降级逻辑。 2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示并打开更新源目录(不自动安装)。✅ 已实现——`services/update_service.py`(`check_for_update` / 版本比较,纯逻辑可测)+ 主窗口顶部通知横幅,检查在后台线程进行(更新源不可达不阻塞启动),更新源路径由 `update_source` 配置。
3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。 3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。⛔ 未做。
4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理。 4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理。⛔ 未做。
## 17. 暂不做 ## 17. 暂不做
+138 -1
View File
@@ -1,10 +1,15 @@
import logging 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 ( from PySide6.QtWidgets import (
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QMainWindow, QMainWindow,
QMessageBox,
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QSplitter, QSplitter,
@@ -16,6 +21,7 @@ from PySide6.QtWidgets import (
from version import APP_NAME, APP_VERSION from version import APP_NAME, APP_VERSION
from core.models import BatchMode, TransformState from core.models import BatchMode, TransformState
from services.config_service import load_config, save_config 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.export_panel import ExportPanel
from app.widgets.image_canvas import ImageCanvas from app.widgets.image_canvas import ImageCanvas
from app.widgets.image_list_panel import ImageListPanel from app.widgets.image_list_panel import ImageListPanel
@@ -33,6 +39,10 @@ _TABBAR_HEIGHT = 34
class MainWindow(QMainWindow): 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): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("{} v{}".format(APP_NAME, APP_VERSION)) self.setWindowTitle("{} v{}".format(APP_NAME, APP_VERSION))
@@ -59,6 +69,9 @@ class MainWindow(QMainWindow):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(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 # No in-app title bar: the OS window title (setWindowTitle) already
# shows the app name and version, so an in-content header would just # shows the app name and version, so an in-content header would just
# duplicate it. Start straight from the workflow tab bar. # duplicate it. Start straight from the workflow tab bar.
@@ -70,6 +83,7 @@ class MainWindow(QMainWindow):
self._apply_stylesheet() self._apply_stylesheet()
self._connect_signals() self._connect_signals()
self._restore_preferences() self._restore_preferences()
self._start_update_check()
def _create_tab_bar(self): def _create_tab_bar(self):
"""Workflow step selector (QTabBar only — no swappable pane).""" """Workflow step selector (QTabBar only — no swappable pane)."""
@@ -96,6 +110,102 @@ class MainWindow(QMainWindow):
return container 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): def _create_work_area(self):
"""Horizontal splitter: left material panel | canvas | right params.""" """Horizontal splitter: left material panel | canvas | right params."""
splitter = QSplitter(Qt.Horizontal) splitter = QSplitter(Qt.Horizontal)
@@ -469,6 +579,33 @@ class MainWindow(QMainWindow):
background-color: #f0f0f0; 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 */ /* Flow tab bar */
#tabBarContainer { #tabBarContainer {
background-color: #f0f0f0; background-color: #f0f0f0;
+1
View File
@@ -11,6 +11,7 @@ DEFAULT_CONFIG = {
"last_print_dir": "", "last_print_dir": "",
"last_template": "", # name of the last-selected template "last_template": "", # name of the last-selected template
"last_batch_mode": "full_combo", # BatchMode value of the last-used mode "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" _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
+26
View File
@@ -846,6 +846,32 @@
- [x] 一个批次内含不同尺寸的衣服/印花时,未微调项各自按模板正确落位 - [x] 一个批次内含不同尺寸的衣服/印花时,未微调项各自按模板正确落位
- [ ] GUI 实测:混合尺寸批量导出,结果图印花位置/大小均正确 - [ ] GUI 实测:混合尺寸批量导出,结果图印花位置/大小均正确
### 17.16 局域网更新 · 阶段②:启动时检测并通知
前置阅读:
- `docs/10-lan-update.md`(§7 更新源、§8 流程、§16 阶段②)
- `docs/02-prd.md`(app_config 的 `update_source`)
说明:
- 阶段①(数据目录分离)已在 commit `76f2c6d` 完成。本任务实现阶段②「只读通知」,不自动安装。
任务:
- [x] 配置项 `update_source`(空 = 不检查)写入 `DEFAULT_CONFIG`
- [x] `services/update_service.py`:`parse_version` / `is_newer` / `check_for_update`,读取 `<source>/manifest.json`,任何不可达/损坏/非更新一律返回 None(不抛错、不阻塞)
- [x] 主窗口顶部通知横幅(默认隐藏):「发现新版本 vX.Y.Z」+「打开更新目录」+ 关闭
- [x] 检查在后台守护线程进行,经 Qt 队列信号回主线程显示横幅(更新源不可达不卡启动)
- [x] 「打开更新目录」用 `QDesktopServices` 打开 `manifest.source`
- [x] 15 个纯函数单测覆盖版本比较与 `check_for_update` 各分支
验收:
- [x] 单测通过;更新源为空/不可达时静默跳过
- [ ] GUI 实测:配置可达更新源 + 高版本 manifest → 启动后显示横幅,点击打开目录
- [ ] GUI 实测:更新源不可达 → 正常启动、无横幅、无卡顿
## 18. 后续暂缓任务 ## 18. 后续暂缓任务
以下任务第一阶段暂不做,后续需要时再新增设计文档: 以下任务第一阶段暂不做,后续需要时再新增设计文档:
+105
View File
@@ -0,0 +1,105 @@
"""Tests for services.update_service — no GUI dependency."""
import json
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from services.update_service import (
UpdateInfo,
check_for_update,
is_newer,
parse_version,
)
class TestVersionCompare(unittest.TestCase):
def test_parse_basic(self):
self.assertEqual(parse_version("1.2.3"), (1, 2, 3))
def test_parse_pads_missing_parts(self):
self.assertEqual(parse_version("1"), (1, 0, 0))
self.assertEqual(parse_version("1.5"), (1, 5, 0))
def test_parse_tolerates_suffix(self):
self.assertEqual(parse_version("1.2.3rc1"), (1, 2, 3))
self.assertEqual(parse_version("v"), (0, 0, 0))
def test_is_newer(self):
self.assertTrue(is_newer("1.1.0", "1.0.0"))
self.assertTrue(is_newer("1.0.1", "1.0.0"))
self.assertTrue(is_newer("2.0.0", "1.9.9"))
def test_is_not_newer(self):
self.assertFalse(is_newer("1.0.0", "1.0.0"))
self.assertFalse(is_newer("1.0.0", "1.1.0"))
self.assertFalse(is_newer("0.9.9", "1.0.0"))
class TestCheckForUpdate(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
def tearDown(self):
shutil.rmtree(str(self.tmp), ignore_errors=True)
def _write_manifest(self, data):
with open(str(self.tmp / "manifest.json"), "w", encoding="utf-8") as f:
json.dump(data, f)
def test_no_source_returns_none(self):
self.assertIsNone(check_for_update("", "1.0.0"))
def test_missing_manifest_returns_none(self):
# tmp exists but has no manifest.json
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
def test_unreachable_source_returns_none(self):
self.assertIsNone(check_for_update(str(self.tmp / "nope"), "1.0.0"))
def test_malformed_manifest_returns_none(self):
with open(str(self.tmp / "manifest.json"), "w", encoding="utf-8") as f:
f.write("{ not valid json")
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
def test_non_object_manifest_returns_none(self):
self._write_manifest(["1.1.0"])
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
def test_newer_version_returns_info(self):
self._write_manifest({
"version": "1.1.0",
"source": r"\\nas\cmbot\releases\CMBot-1.1.0",
"notes": "fix batch export",
"mandatory": False,
})
info = check_for_update(str(self.tmp), "1.0.0")
self.assertIsInstance(info, UpdateInfo)
self.assertEqual(info.version, "1.1.0")
self.assertEqual(info.source, r"\\nas\cmbot\releases\CMBot-1.1.0")
self.assertEqual(info.notes, "fix batch export")
self.assertFalse(info.mandatory)
def test_same_version_returns_none(self):
self._write_manifest({"version": "1.0.0"})
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
def test_older_version_returns_none(self):
self._write_manifest({"version": "0.9.0"})
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
def test_missing_version_field_returns_none(self):
self._write_manifest({"notes": "no version here"})
self.assertIsNone(check_for_update(str(self.tmp), "1.0.0"))
def test_source_falls_back_to_update_source(self):
self._write_manifest({"version": "2.0.0"}) # no "source" field
info = check_for_update(str(self.tmp), "1.0.0")
self.assertEqual(info.source, str(self.tmp))
if __name__ == "__main__":
unittest.main()