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:
2026-06-18 11:58:06 +08:00
co-authored by Claude Opus 4.8
parent 30814b6bd6
commit 5a1a004daf
6 changed files with 468 additions and 373 deletions
+64 -5
View File
@@ -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)