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:
+3
-1
@@ -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. 验收标准
|
||||
|
||||
@@ -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 控件不直接读写配置文件,避免分散解析。
|
||||
|
||||
|
||||
@@ -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. 暂不做
|
||||
|
||||
|
||||
+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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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`,读取 `<source>/manifest.json`,任何不可达/损坏/非更新一律返回 None(不抛错、不阻塞)
|
||||
- [x] 主窗口顶部通知横幅(默认隐藏):「发现新版本 vX.Y.Z」+「打开更新目录」+ 关闭
|
||||
- [x] 检查在后台守护线程进行,经 Qt 队列信号回主线程显示横幅(更新源不可达不卡启动)
|
||||
- [x] 「打开更新目录」用 `QDesktopServices` 打开 `manifest.source`
|
||||
- [x] 15 个纯函数单测覆盖版本比较与 `check_for_update` 各分支
|
||||
|
||||
验收:
|
||||
|
||||
- [x] 单测通过;更新源为空/不可达时静默跳过
|
||||
- [ ] GUI 实测:配置可达更新源 + 高版本 manifest → 启动后显示横幅,点击打开目录
|
||||
- [ ] GUI 实测:更新源不可达 → 正常启动、无横幅、无卡顿
|
||||
|
||||
## 18. 后续暂缓任务
|
||||
|
||||
以下任务第一阶段暂不做,后续需要时再新增设计文档:
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user