feat: non-blocking updates — download in app, apply at launch
Startup no longer blocks on a download. New split: - services/installer.py: download_and_stage() (runs while the app is open → staging\app.new, verified) and apply_staged() (launcher swaps it into app\ at next launch, when the exe isn't locked). - launcher.py: slimmed to seed + apply_staged + launch; no network at startup. - main_window: a new version lights a dot on the ⚙ 配置 button instead of a blocking banner; the old open-folder banner is removed. - settings_dialog: "检查并更新" downloads + stages, then "下次启动生效". Verified end-to-end over a local HTTP server (download→stage→apply); 101 tests pass (test_installer +11, test_launcher rewritten). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+22
-109
@@ -71,8 +71,10 @@ 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())
|
||||
# Update state: when a newer version is found the gear button shows a
|
||||
# dot; the user downloads it from the settings dialog (no startup block).
|
||||
self._update_info = None
|
||||
self._update_found.connect(self._on_update_found)
|
||||
|
||||
# No in-app title bar: the OS window title (setWindowTitle) already
|
||||
# shows the app name and version, so an in-content header would just
|
||||
@@ -120,13 +122,14 @@ class MainWindow(QMainWindow):
|
||||
return container
|
||||
|
||||
def _open_settings(self):
|
||||
"""Open the settings dialog; persist + re-check on save (docs/07 §4.4)."""
|
||||
"""Open the settings dialog (update config + manual update). Persist on save."""
|
||||
dlg = SettingsDialog(
|
||||
self,
|
||||
update_source=self._config.get("update_source", ""),
|
||||
update_user=self._config.get("update_user", ""),
|
||||
update_pass=self._config.get("update_pass", ""),
|
||||
current_version=APP_VERSION,
|
||||
update_info=self._update_info,
|
||||
)
|
||||
if dlg.exec() == QDialog.Accepted:
|
||||
self._config.update(dlg.values())
|
||||
@@ -134,54 +137,20 @@ class MainWindow(QMainWindow):
|
||||
self._refresh_update_check()
|
||||
|
||||
def _refresh_update_check(self):
|
||||
"""Re-run the update check after the source/credentials changed."""
|
||||
self._update_banner.setVisible(False)
|
||||
"""Clear the indicator and re-check after the source/credentials changed."""
|
||||
self._update_info = None
|
||||
self._set_update_indicator(False)
|
||||
self._start_update_check()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Update notification (stage 2: notify-only, see docs/10-lan-update.md)
|
||||
# Update availability (download happens in the settings dialog)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _create_update_banner(self):
|
||||
"""A thin info bar shown when a newer version is found. Hidden by default."""
|
||||
self._update_info = None
|
||||
self._update_found.connect(self._on_update_found) # connect once
|
||||
|
||||
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 update source for a newer version, off the UI thread.
|
||||
|
||||
Safe to call again after the settings change; the result signal is
|
||||
connected once in _create_update_banner, not here.
|
||||
Notify-only: a new version lights the gear button's dot; the user
|
||||
downloads it from the settings dialog. Startup is never blocked.
|
||||
"""
|
||||
source = self._config.get("update_source", "")
|
||||
if not source:
|
||||
@@ -201,47 +170,17 @@ class MainWindow(QMainWindow):
|
||||
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)."""
|
||||
"""A newer version is available — mark the gear button (UI thread)."""
|
||||
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)
|
||||
self._set_update_indicator(True, info.version)
|
||||
|
||||
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 path and path.lower().startswith(("http://", "https://")):
|
||||
if QDesktopServices.openUrl(QUrl(path)):
|
||||
return
|
||||
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 _set_update_indicator(self, available, version=""):
|
||||
self._settings_btn.setText("⚙ 配置 ●" if available else "⚙ 配置")
|
||||
self._settings_btn.setProperty("hasUpdate", bool(available))
|
||||
self._settings_btn.setToolTip(
|
||||
"发现新版本 v{},点击进入设置更新".format(version) if available else "")
|
||||
self._settings_btn.style().unpolish(self._settings_btn)
|
||||
self._settings_btn.style().polish(self._settings_btn)
|
||||
|
||||
def _create_work_area(self):
|
||||
"""Horizontal splitter: left material panel | canvas | right params."""
|
||||
@@ -616,33 +555,6 @@ 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;
|
||||
@@ -677,6 +589,7 @@ class MainWindow(QMainWindow):
|
||||
padding: 4px 14px;
|
||||
}
|
||||
#settingsBtn:hover { color: #0078d4; background: #e8f0fb; }
|
||||
#settingsBtn[hasUpdate="true"] { color: #c42b1c; font-weight: bold; }
|
||||
|
||||
/* Work splitter */
|
||||
QSplitter#workSplitter::handle {
|
||||
|
||||
@@ -20,24 +20,34 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from version import APP_VERSION
|
||||
from services.update_service import is_newer, load_manifest
|
||||
from services.file_service import get_app_dir
|
||||
from services import installer
|
||||
from services.update_service import check_for_update, is_newer, load_manifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Edit update_source / update_user / update_pass with a connection test."""
|
||||
"""Edit update config; test the connection; download+stage an update.
|
||||
|
||||
_test_done = Signal(str) # result message, delivered to the UI thread
|
||||
Downloading happens here (app is running, writes to staging\\app.new); the
|
||||
actual app/app.old swap is applied by the launcher on the next start, since
|
||||
Windows locks the running exe (docs/10-lan-update.md).
|
||||
"""
|
||||
|
||||
_test_done = Signal(str) # connection-test result message (UI thread)
|
||||
_update_done = Signal(bool, str) # (ok, message) after download+stage
|
||||
|
||||
def __init__(self, parent=None, *, update_source="", update_user="",
|
||||
update_pass="", current_version=APP_VERSION):
|
||||
update_pass="", current_version=APP_VERSION, update_info=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("设置")
|
||||
self.setMinimumWidth(420)
|
||||
self._current_version = current_version
|
||||
self._update_info = update_info
|
||||
self._setup_ui(update_source, update_user, update_pass)
|
||||
self._test_done.connect(self._on_test_done)
|
||||
self._update_done.connect(self._on_update_done)
|
||||
|
||||
# ── UI ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -83,7 +93,19 @@ class SettingsDialog(QDialog):
|
||||
self._result.setWordWrap(True)
|
||||
col.addWidget(self._result)
|
||||
|
||||
hint = QLabel("提示:建议使用只读账号;生产环境请走 HTTPS(凭据为明文存储)。")
|
||||
# ── update section ───────────────────────────────────────────────────
|
||||
update_row = QHBoxLayout()
|
||||
self._update_status = QLabel(self._update_status_text())
|
||||
self._update_status.setObjectName("settingsUpdate")
|
||||
self._update_status.setWordWrap(True)
|
||||
update_row.addWidget(self._update_status, 1)
|
||||
self._update_btn = QPushButton("检查并更新")
|
||||
self._update_btn.clicked.connect(self._on_update)
|
||||
update_row.addWidget(self._update_btn)
|
||||
col.addLayout(update_row)
|
||||
|
||||
hint = QLabel("提示:建议使用只读账号;生产环境请走 HTTPS(凭据为明文存储)。"
|
||||
"更新下载完成后,下次启动时自动生效。")
|
||||
hint.setObjectName("settingsHint")
|
||||
hint.setWordWrap(True)
|
||||
col.addWidget(hint)
|
||||
@@ -144,3 +166,40 @@ class SettingsDialog(QDialog):
|
||||
def _on_test_done(self, msg):
|
||||
self._result.setText(msg)
|
||||
self._test_btn.setEnabled(True)
|
||||
|
||||
# ── manual update: download + stage (swap applied by launcher next start) ──
|
||||
|
||||
def _update_status_text(self):
|
||||
if self._update_info:
|
||||
return "发现新版本 v{}(当前 v{})。".format(
|
||||
self._update_info.version, self._current_version)
|
||||
return "当前 v{}。".format(self._current_version)
|
||||
|
||||
def _on_update(self):
|
||||
source = self._source_edit.text().strip()
|
||||
if not source:
|
||||
self._update_status.setText("请先填写更新地址。")
|
||||
return
|
||||
user, password = self._user_edit.text(), self._pass_edit.text()
|
||||
install_root = installer.install_root_for(get_app_dir())
|
||||
self._update_btn.setEnabled(False)
|
||||
self._update_status.setText("正在下载…")
|
||||
|
||||
def worker():
|
||||
try:
|
||||
info = check_for_update(source, self._current_version, user, password)
|
||||
if info is None:
|
||||
self._update_done.emit(True, "已是最新版本(或更新源不可用)。")
|
||||
return
|
||||
installer.download_and_stage(install_root, info, user, password)
|
||||
self._update_done.emit(
|
||||
True, "已下载 v{},下次启动时自动更新。".format(info.version))
|
||||
except Exception as exc:
|
||||
logger.info("Update download failed: %s", exc)
|
||||
self._update_done.emit(False, "更新失败:{}".format(exc))
|
||||
|
||||
threading.Thread(target=worker, name="settings-update", daemon=True).start()
|
||||
|
||||
def _on_update_done(self, ok, msg):
|
||||
self._update_status.setText(msg)
|
||||
self._update_btn.setEnabled(True)
|
||||
|
||||
Reference in New Issue
Block a user