From 0392e1c8a2a19561d75b1e1709288a383d6d663c Mon Sep 17 00:00:00 2001 From: ila Date: Wed, 17 Jun 2026 17:05:46 +0800 Subject: [PATCH] feat: LAN update check on startup with notify banner (stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/02-prd.md | 4 +- docs/05-project-architecture.md | 2 +- docs/10-lan-update.md | 12 +-- src/app/main_window.py | 139 +++++++++++++++++++++++++++++++- src/services/config_service.py | 1 + src/services/update_service.py | 93 +++++++++++++++++++++ tasks.md | 26 ++++++ tests/test_update_service.py | 105 ++++++++++++++++++++++++ 8 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 src/services/update_service.py create mode 100644 tests/test_update_service.py diff --git a/docs/02-prd.md b/docs/02-prd.md index 5104622..53fde7d 100644 --- a/docs/02-prd.md +++ b/docs/02-prd.md @@ -330,7 +330,8 @@ CMBot/ "last_garment_dir": "", "last_print_dir": "", "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_template`:上次选中的模板名称,用于启动恢复。 - `last_batch_mode`:上次选择的批量模式(取 `BatchMode` 枚举值,如 `full_combo` / `many_garments` / `many_prints` / `one_to_one`)。 +- `update_source`:局域网更新源目录(含 `manifest.json`)。为空时不做更新检查。详见 `docs/10-lan-update.md`。 - 偏好的读取、分发与「改一次存一次」由主窗口集中处理,UI 控件不直接读写配置文件。 ## 10. 验收标准 diff --git a/docs/05-project-architecture.md b/docs/05-project-architecture.md index e165535..f645fbd 100644 --- a/docs/05-project-architecture.md +++ b/docs/05-project-architecture.md @@ -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 控件不直接读写配置文件,避免分散解析。 diff --git a/docs/10-lan-update.md b/docs/10-lan-update.md index b6233dd..f7734b9 100644 --- a/docs/10-lan-update.md +++ b/docs/10-lan-update.md @@ -116,7 +116,7 @@ ## 7. 更新源与版本清单 -更新源为内网共享目录(UNC 路径)或内网文件服务,例如: +更新源路径由 `app_config.json` 的 `update_source` 配置(见 `docs/02-prd.md`);为空时不做更新检查。更新源为内网共享目录(UNC 路径)或内网文件服务,例如: ```text \\nas\cmbot\releases\ @@ -225,12 +225,12 @@ ## 16. 实现阶段建议 -建议分阶段落地,每阶段可独立验证: +分阶段落地,每阶段可独立验证: -1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提,先行完成并通过现有测试。 -2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示并打开更新源目录(不自动安装)。验证版本检查与降级逻辑。 -3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。 -4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理。 +1. **地基**:数据目录分离(第 5 节)。任何更新方案的前提。✅ 已实现。 +2. **只读通知**:启动时读取 `manifest.json` 比对版本,有新版仅提示并打开更新源目录(不自动安装)。✅ 已实现——`services/update_service.py`(`check_for_update` / 版本比较,纯逻辑可测)+ 主窗口顶部通知横幅,检查在后台线程进行(更新源不可达不阻塞启动),更新源路径由 `update_source` 配置。 +3. **自动安装**:实现启动器完整流程(下载 → 校验 → 原子切换 → 启动 → 回滚)。⛔ 未做。 +4. **强制更新与保留策略**:补全 `mandatory` / `min_supported` 与版本清理。⛔ 未做。 ## 17. 暂不做 diff --git a/src/app/main_window.py b/src/app/main_window.py index 4cbd22c..ca71dc9 100644 --- a/src/app/main_window.py +++ b/src/app/main_window.py @@ -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; diff --git a/src/services/config_service.py b/src/services/config_service.py index 49a8fe4..91cc217 100644 --- a/src/services/config_service.py +++ b/src/services/config_service.py @@ -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" diff --git a/src/services/update_service.py b/src/services/update_service.py new file mode 100644 index 0000000..280e4df --- /dev/null +++ b/src/services/update_service.py @@ -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 diff --git a/tasks.md b/tasks.md index fabeb49..2d9a143 100644 --- a/tasks.md +++ b/tasks.md @@ -846,6 +846,32 @@ - [x] 一个批次内含不同尺寸的衣服/印花时,未微调项各自按模板正确落位 - [ ] 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`,读取 `/manifest.json`,任何不可达/损坏/非更新一律返回 None(不抛错、不阻塞) +- [x] 主窗口顶部通知横幅(默认隐藏):「发现新版本 vX.Y.Z」+「打开更新目录」+ 关闭 +- [x] 检查在后台守护线程进行,经 Qt 队列信号回主线程显示横幅(更新源不可达不卡启动) +- [x] 「打开更新目录」用 `QDesktopServices` 打开 `manifest.source` +- [x] 15 个纯函数单测覆盖版本比较与 `check_for_update` 各分支 + +验收: + +- [x] 单测通过;更新源为空/不可达时静默跳过 +- [ ] GUI 实测:配置可达更新源 + 高版本 manifest → 启动后显示横幅,点击打开目录 +- [ ] GUI 实测:更新源不可达 → 正常启动、无横幅、无卡顿 + ## 18. 后续暂缓任务 以下任务第一阶段暂不做,后续需要时再新增设计文档: diff --git a/tests/test_update_service.py b/tests/test_update_service.py new file mode 100644 index 0000000..2e21275 --- /dev/null +++ b/tests/test_update_service.py @@ -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()