Commit cacd1900 by 周田

feat: 增加 PyQt5 更新封装并发布 261028.4.0

parent 033861e9
Program Updater 更新日志
=========================
## 261028.4.0 [WHEEL]
发布日期:2026-10-28
发行包:program-updater
制品:program_updater-261028.4.0-py3-none-any.whl
- 新增 `update_on_startup()` 便捷入口,自动处理 frozen 判断、
`sys.executable` 和内嵌 `UpdaterHelper.exe` 路径。
- 新增可选 `updater.integrations.pyqt` 集成,提供
`run_startup_update()`,内置确认框和下载进度条;PyQt5 仍不是基础依赖。
- 保留无 UI 依赖的回调接入方式,并同步简化文档示例。
## 261028.4.0 [EXE]
发布日期:2026-10-28
应用 ID:program-updater-helper
目标平台:Windows
架构:x86-64
制品:UpdaterHelper.exe
- 重新构建 helper,产品版本更新为 261028.4.0,helper 协议版本保持 2。
## 261028.3.0 [WHEEL]
发布日期:2026-10-28
......
No preview for this file type
[project]
name = "program-updater"
version = "261028.3.0"
version = "261028.4.0"
description = "Lightweight updater for Windows PyInstaller applications"
readme = "updater/README.md"
authors = [
......
......@@ -110,90 +110,25 @@ exe 则必须按上述方式额外嵌入。
}
```
目标程序在启动早期、创建主窗口和初始化业务逻辑之前调用更新器。下面是 PyQt
程序的示例:
目标程序在启动早期、创建主窗口和初始化业务逻辑之前调用更新器。如果目标程序
使用 PyQt5,可以直接用可选的 `run_startup_update()` 封装:
```python
import logging
import sys
from pathlib import Path
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QMessageBox, QProgressDialog
from updater import UpdateClient, UpdaterError, confirm_update_started
from PyQt5.QtWidgets import QApplication
from updater import confirm_update_started
from updater.integrations.pyqt import run_startup_update
APP_ID = "margin-guard"
APP_VERSION = "1.1.0"
MANIFEST_URL = "http://192.168.1.20:8000/updates/margin-guard/latest.json"
DOWNLOAD_PAGE_URL = "http://192.168.1.20:8000/download/margin-guard"
logger = logging.getLogger(__name__)
def main() -> int:
app = QApplication(sys.argv)
if getattr(sys, "frozen", False):
bundle_dir = Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
updater = UpdateClient(
app_id=APP_ID,
current_version=APP_VERSION,
manifest_url=MANIFEST_URL,
executable_path=sys.executable,
download_page_url=DOWNLOAD_PAGE_URL,
helper_resource=(
bundle_dir
/ "updater"
/ "platforms"
/ "windows"
/ "bin"
/ "UpdaterHelper.exe"
),
)
progress = QProgressDialog("正在下载更新…", "", 0, 0)
progress.setWindowTitle("程序更新")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setAutoClose(False)
progress.setAutoReset(False)
def on_progress(downloaded: int, total: int) -> None:
progress.setLabelText("正在下载更新…")
progress.setRange(0, 1000)
progress.setValue(int(downloaded * 1000 / total) if total else 0)
if not progress.isVisible():
progress.show()
QApplication.processEvents()
def confirm_update(update) -> bool:
progress.hide()
answer = QMessageBox.question(
None,
"发现新版本",
f"发现版本 {update.version},现在更新吗?选择“否”可稍后更新。",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if answer == QMessageBox.Yes:
progress.setLabelText("准备下载更新…")
progress.setRange(0, 0)
progress.show()
QApplication.processEvents()
return answer == QMessageBox.Yes
try:
handed_off = updater.update_before_startup(
confirm_update=confirm_update,
restart_args=sys.argv[1:],
on_progress=on_progress,
)
except UpdaterError:
logger.exception("自动更新失败,将启动当前版本")
handed_off = False
finally:
progress.close()
if handed_off:
if run_startup_update(APP_ID, APP_VERSION, MANIFEST_URL):
return 0
# 放入目标应用原有的窗口、host 和业务初始化代码。
......@@ -209,11 +144,51 @@ if __name__ == "__main__":
raise SystemExit(main())
```
示例中的 `on_progress(downloaded, total)` 是下载进度回调,可以用它驱动 QProgressDialog 等进度条。更新流程在调用线程中同步执行,所以需要在回调里调用 `QApplication.processEvents()` 让界面刷新;其他 GUI 框架使用对应的消息处理函数。不要在检查更新之前调用 `progress.show()`,否则即使已经是最新版本,每次启动也会闪出进度框;只在用户确认更新或第一次收到下载进度时显示。
`run_startup_update()` 是可选 PyQt5 封装,内部使用确认框和下载进度条,并自动完成 frozen 检查、定位 `sys.executable` 和内嵌的 `UpdaterHelper.exe`;未打包时直接返回 `False`。PyQt5 不是 updater 的安装依赖,只有目标程序本身已经使用 PyQt5 时才可调用该封装。`download_page_url` 可选,默认按 manifest 地址推导为 `/download/{app_id}`。
#### 不使用 PyQt
没有 Qt 或其他 GUI 框架时,使用核心入口 `update_on_startup()`,界面由目标程序自己接入:
```python
import logging
from updater import UpdaterError, update_on_startup
logger = logging.getLogger(__name__)
def confirm_update(update) -> bool:
# 替换为目标程序自己的确认对话框。
return True
def on_progress(downloaded: int, total: int) -> None:
# 替换为目标程序自己的进度条;total 是总字节数。
pass
try:
handed_off = update_on_startup(
APP_ID,
APP_VERSION,
MANIFEST_URL,
confirm_update=confirm_update,
on_progress=on_progress,
)
except UpdaterError:
logger.exception("自动更新失败,将启动当前版本")
handed_off = False
if handed_off:
return 0
```
`update_on_startup()` 不依赖任何 UI 库;只有在确实要下载更新时才调用回调,未打包时直接返回 `False`。使用其他 UI 框架时,在 `on_progress` 中刷新自己的进度条即可。
`update_before_startup()` 会同步检查清单。选择“否”时本次继续启动旧版,下次启动会再次提醒;选择“是”时才下载和校验 exe、启动 helper,并返回 `True`。目标进程随后退出,helper 替换 exe 并自动启动新版。新版启动检查时发现已是最新版本,就会继续启动主窗口。LAN 服务不可用或更新失败时,上例记录错误并继续启动旧版;强制更新的程序可以改为提示错误并停止启动。
`update_before_startup()` 会同步检查清单。选择“否”时本次继续启动旧版,下次启动会再次提醒;选择“是”时才下载和校验 exe、启动 helper,并返回 `True`。目标进程随后退出,helper 替换 exe 并自动启动新版。新版启动检查时发现已是最新版本,就会继续启动主窗口。LAN 服务不可用或更新失败时,`run_startup_update()` 会记录错误并继续启动旧版;强制更新的程序可以改为提示错误并停止启动。
`confirm_update()` 是目标应用提供的回调,所以 updater 本身不依赖 PyQt。`confirm_update_started()` 应在新版完成必要初始化后调用。helper 默认等待确认 45 秒;若新版提前退出或没有确认,会尝试恢复旧 exe。数据库迁移前应备份数据,exe 回滚本身不能撤销数据库迁移。portable 安装目录必须允许当前用户写入,配置文件和数据库应放在 exe 之外。
`update_before_startup()` 和 `update_on_startup()` 都不依赖 PyQt;`run_startup_update()` 只是可选的 PyQt5 封装。`confirm_update_started()` 应在新版完成必要初始化后调用。helper 默认等待确认 45 秒;若新版提前退出或没有确认,会尝试恢复旧 exe。数据库迁移前应备份数据,exe 回滚本身不能撤销数据库迁移。portable 安装目录必须允许当前用户写入,配置文件和数据库应放在 exe 之外。
更新前还会校验目标程序内嵌 helper 的版本资源。helper 会随目标程序一起打包,不会随 Python 代码自动升级;如果 helper 缺少版本信息或低于当前 updater 的要求,客户端会弹出提示框,用户确认后打开 `download_page_url` 下载完整程序。未配置该参数时,默认按 manifest 地址推导 `/download/{app_id}` 页面。发布新版本时必须在同一构建环境中重新构建 helper,并用最新的 `UpdaterHelper.exe` 重新打包目标程序。
......
"""Public client-side updater API."""
from .core.client import UpdateClient
from .core.client import UpdateClient, update_on_startup
from .core.errors import (
DownloadError,
ManifestError,
......@@ -20,6 +20,7 @@ __all__ = [
"UpdateInfo",
"UpdaterError",
"confirm_update_started",
"update_on_startup",
]
......
......@@ -5,6 +5,7 @@ import json
import os
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
......@@ -327,3 +328,63 @@ class UpdateClient:
)
if actual_digest != update.sha256.lower():
raise DownloadError("staged update SHA-256 does not match the manifest.")
def update_on_startup(
app_id: str,
current_version: str,
manifest_url: str,
*,
download_page_url: str | None = None,
helper_resource: str | Path | None = None,
executable_path: str | Path | None = None,
update_dir: str | Path | None = None,
confirm_update: Callable[[UpdateInfo], bool] | None = None,
on_progress: Callable[[int, int], None] | None = None,
restart_args: Sequence[str] | None = None,
health_timeout: float = 45.0,
timeout: float = 15.0,
max_artifact_size: int = _DEFAULT_MAX_ARTIFACT_SIZE,
) -> bool:
"""Check for and apply an update when running as a frozen application.
Returns ``False`` when the application is not frozen, when no update is
available, or when the user defers. Returns ``True`` after handing off to
the update helper; the caller must then exit.
"""
if not getattr(sys, "frozen", False):
return False
executable = (
Path(executable_path).resolve()
if executable_path is not None
else Path(sys.executable).resolve()
)
if helper_resource is None:
bundle_dir = Path(getattr(sys, "_MEIPASS", executable.parent))
helper_resource = (
bundle_dir
/ "updater"
/ "platforms"
/ "windows"
/ "bin"
/ "UpdaterHelper.exe"
)
client = UpdateClient(
app_id=app_id,
current_version=current_version,
manifest_url=manifest_url,
executable_path=executable,
helper_resource=helper_resource,
update_dir=update_dir,
download_page_url=download_page_url,
timeout=timeout,
max_artifact_size=max_artifact_size,
)
return client.update_before_startup(
confirm_update=confirm_update,
restart_args=sys.argv[1:] if restart_args is None else restart_args,
on_progress=on_progress,
health_timeout=health_timeout,
)
"""Optional UI integrations for the updater."""
"""Optional PyQt5 integration for applying updates before startup."""
from __future__ import annotations
import logging
import sys
from collections.abc import Sequence
from pathlib import Path
from typing import Any
from ..core.client import _DEFAULT_MAX_ARTIFACT_SIZE, update_on_startup
from ..core.errors import UpdaterError
_logger = logging.getLogger(__name__)
def run_startup_update(
app_id: str,
current_version: str,
manifest_url: str,
*,
download_page_url: str | None = None,
helper_resource: str | Path | None = None,
executable_path: str | Path | None = None,
update_dir: str | Path | None = None,
restart_args: Sequence[str] | None = None,
health_timeout: float = 45.0,
timeout: float = 15.0,
max_artifact_size: int = _DEFAULT_MAX_ARTIFACT_SIZE,
parent: Any = None,
) -> bool:
"""Check for and apply an update using standard PyQt dialogs.
Returns ``True`` after handing off to the update helper; the caller must
then exit. Returns ``False`` when the application is not frozen, is already
current, the user defers, or the update fails.
"""
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QMessageBox, QProgressDialog
if QApplication.instance() is None:
raise RuntimeError("create QApplication before running the updater.")
progress = QProgressDialog("正在下载更新…", "", 0, 0, parent)
progress.setWindowTitle("程序更新")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setAutoClose(False)
progress.setAutoReset(False)
def on_progress(downloaded: int, total: int) -> None:
progress.setLabelText("正在下载更新…")
progress.setRange(0, 1000)
progress.setValue(int(downloaded * 1000 / total) if total else 0)
if not progress.isVisible():
progress.show()
QApplication.processEvents()
def confirm_update(update) -> bool:
progress.hide()
answer = QMessageBox.question(
parent,
"发现新版本",
f"发现版本 {update.version},现在更新吗?选择“否”可稍后更新。",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if answer == QMessageBox.Yes:
progress.setLabelText("准备下载更新…")
progress.setRange(0, 0)
progress.show()
QApplication.processEvents()
return answer == QMessageBox.Yes
try:
return update_on_startup(
app_id,
current_version,
manifest_url,
download_page_url=download_page_url,
helper_resource=helper_resource,
executable_path=executable_path,
update_dir=update_dir,
confirm_update=confirm_update,
on_progress=on_progress,
restart_args=sys.argv[1:] if restart_args is None else restart_args,
health_timeout=health_timeout,
timeout=timeout,
max_artifact_size=max_artifact_size,
)
except UpdaterError:
_logger.exception("自动更新失败,将启动当前版本")
return False
finally:
progress.close()
......@@ -4,12 +4,18 @@ import hashlib
import io
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from updater import UpdateClient, UpdateInfo, confirm_update_started
from updater import (
UpdateClient,
UpdateInfo,
confirm_update_started,
update_on_startup,
)
from updater.core.errors import DownloadError, UpdateApplyError, UpdateCheckError
from updater.core.health import _cleanup_update_dir
......@@ -176,6 +182,41 @@ class UpdateClientTests(unittest.TestCase):
health_timeout=60.0,
)
def test_update_on_startup_skips_unfrozen_application(self) -> None:
with patch.object(sys, "frozen", False, create=True):
self.assertFalse(
update_on_startup(
app_id="my-program",
current_version="1.0.0",
manifest_url="http://127.0.0.1:8000/latest.json",
)
)
def test_update_on_startup_builds_client_for_frozen_app(self) -> None:
with (
patch.object(sys, "frozen", True, create=True),
patch("updater.core.client.UpdateClient") as client_class,
):
client_class.return_value.update_before_startup.return_value = True
handed_off = update_on_startup(
app_id="my-program",
current_version="1.0.0",
manifest_url="http://127.0.0.1:8000/latest.json",
restart_args=("--safe-mode",),
health_timeout=30.0,
)
self.assertTrue(handed_off)
arguments = client_class.call_args.kwargs
self.assertEqual(arguments["executable_path"], Path(sys.executable).resolve())
self.assertEqual(arguments["helper_resource"].name, "UpdaterHelper.exe")
client_class.return_value.update_before_startup.assert_called_once_with(
confirm_update=None,
restart_args=("--safe-mode",),
on_progress=None,
health_timeout=30.0,
)
def test_outdated_helper_prompts_download_page(self) -> None:
client = self.client()
with (
......
......@@ -130,7 +130,7 @@ wheels = [
[[package]]
name = "program-updater"
version = "261028.3.0"
version = "261028.4.0"
source = { editable = "." }
[package.optional-dependencies]
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment