Commit d0d64dfd by 周田

feat: 增加 helper 版本校验、更新清理和进度提示 261028.3.0

parent b21e9d41
...@@ -2,9 +2,57 @@ ...@@ -2,9 +2,57 @@
"""PyInstaller spec for the standalone Windows update helper.""" """PyInstaller spec for the standalone Windows update helper."""
import sys
import tomllib
from pathlib import Path from pathlib import Path
from PyInstaller.utils.win32.versioninfo import (
FixedFileInfo,
StringFileInfo,
StringStruct,
StringTable,
VarFileInfo,
VarStruct,
VSVersionInfo,
)
ROOT = Path(SPECPATH).resolve() ROOT = Path(SPECPATH).resolve()
sys.path.insert(0, str(ROOT))
from updater.platforms.windows.version import HELPER_PROTOCOL_VERSION
PROJECT_VERSION = tomllib.loads(
(ROOT / "pyproject.toml").read_text(encoding="utf-8")
)["project"]["version"]
FILE_VERSION = (HELPER_PROTOCOL_VERSION, 0, 0, 0)
VERSION_INFO = VSVersionInfo(
ffi=FixedFileInfo(filevers=FILE_VERSION, prodvers=FILE_VERSION),
kids=[
StringFileInfo(
[
StringTable(
"040904B0",
[
StringStruct("CompanyName", "program-updater"),
StringStruct(
"FileDescription",
"Program Updater Helper",
),
StringStruct(
"FileVersion",
".".join(str(part) for part in FILE_VERSION),
),
StringStruct("InternalName", "UpdaterHelper"),
StringStruct("OriginalFilename", "UpdaterHelper.exe"),
StringStruct("ProductName", "program-updater"),
StringStruct("ProductVersion", PROJECT_VERSION),
],
)
]
),
VarFileInfo([VarStruct("Translation", [0x0409, 1200])]),
],
)
a = Analysis( a = Analysis(
[str(ROOT / "updater" / "platforms" / "windows" / "helper_entry.py")], [str(ROOT / "updater" / "platforms" / "windows" / "helper_entry.py")],
...@@ -40,4 +88,5 @@ exe = EXE( ...@@ -40,4 +88,5 @@ exe = EXE(
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
version=VERSION_INFO,
) )
Program Updater 更新日志 Program Updater 更新日志
========================= =========================
## 261028.3.0 [WHEEL]
发布日期:2026-10-28
发行包:program-updater
制品:program_updater-261028.3.0-py3-none-any.whl
- 新版完成启动确认后,在后台删除 `.updates` 中剩余的临时文件、备份和日志。
## 261028.3.0 [EXE]
发布日期:2026-10-28
应用 ID:program-updater-helper
目标平台:Windows
架构:x86-64
制品:UpdaterHelper.exe
- 更新成功并确认启动后,删除 `.updates` 中的 staging、backup、健康标记和
旧 helper 文件。
## 261028.2.0 [WHEEL]
发布日期:2026-10-28
发行包:program-updater
制品:program_updater-261028.2.0-py3-none-any.whl
- 启动更新前校验目标程序内嵌的 helper 版本;helper 过旧或缺少版本信息时
不再执行更新,避免更新中途失败。
- helper 版本不满足要求时弹出提示框,用户确认后打开下载页面获取最新完整
程序。
- 新增 `download_page_url` 配置;未提供时按 manifest 地址推导
`/download/{app_id}` 下载页面。
## 261028.2.0 [EXE]
发布日期:2026-10-28
应用 ID:program-updater-helper
目标平台:Windows
架构:x86-64
制品:UpdaterHelper.exe
- helper 可执行文件写入版本资源,协议版本为 2,供客户端校验。
- `app.spec` 从 `pyproject.toml` 读取产品版本并写入 exe 元数据。
## 261028.1.2 [WHEEL]
发布日期:2026-10-28
发行包:program-updater
制品:program_updater-261028.1.2-py3-none-any.whl
- 修复更新后首次启动仍会再次检查更新并交接,导致新版提前退出、健康确认
失败并回滚的问题;helper 健康检查启动期间 `update_before_startup()` 会
跳过更新检查,让新版完成启动确认。
## 261028.1.1 [EXE]
发布日期:2026-10-28
应用 ID:program-updater-helper
目标平台:Windows
架构:x86-64
制品:UpdaterHelper.exe
- 修复旧进程已退出时查询进程映像路径失败、更新中断并报
`[WinError 31]` 的问题;父进程已退出时会跳过路径校验并继续替换。
## 261028.1.0 [EXE] ## 261028.1.0 [EXE]
发布日期:2026-10-28 发布日期:2026-10-28
......
No preview for this file type
[project] [project]
name = "program-updater" name = "program-updater"
version = "261028.0.0" version = "261028.3.0"
description = "Lightweight updater for Windows PyInstaller applications" description = "Lightweight updater for Windows PyInstaller applications"
readme = "updater/README.md" readme = "updater/README.md"
authors = [ authors = [
......
...@@ -51,21 +51,26 @@ FastAPI 等 Web 依赖。 ...@@ -51,21 +51,26 @@ FastAPI 等 Web 依赖。
### 2. 构建 Windows helper ### 2. 构建 Windows helper
在同一个 Python 3.12 环境中确保已安装 PyInstaller(它是构建工具,不是 updater 在同一个 Python 3.12 环境中确保已安装 PyInstaller(它是构建工具,不是 updater
运行时依赖),然后从 `self-strategy/` 根目录构建 helper: 运行时依赖),然后从 updater 仓库根目录使用 `app.spec` 构建 helper:
```powershell ```powershell
python -m PyInstaller --onefile --paths ..\program-updater --name UpdaterHelper ..\program-updater\updater\platforms\windows\helper_entry.py cd C:\path\to\program-updater
python -m PyInstaller --noconfirm app.spec
``` ```
构建结果为 `self-strategy\dist\UpdaterHelper.exe`。helper 是一个独立的小型 exe, 构建结果为 `program-updater\dist\UpdaterHelper.exe`。helper 是一个独立的小型
负责等待旧程序退出、备份并替换主 exe、启动新版以及在启动失败时回滚。 exe,负责等待旧程序退出、备份并替换主 exe、启动新版以及在启动失败时回滚。
`app.spec` 会把 helper 协议版本写入 exe 版本资源,客户端更新前会校验该版本;
不要再用不带版本资源的命令行方式构建 helper。
### 3. 将 helper 加入目标程序的 PyInstaller 配置 ### 3. 将 helper 加入目标程序的 PyInstaller 配置
在目标程序的 `.spec` 中定义 helper 二进制及其在 onefile 包内的目标目录: 在目标程序的 `.spec` 中定义 helper 二进制及其在 onefile 包内的目标目录:
```python ```python
UPDATER_HELPER = os.path.join(ROOT, "dist", "UpdaterHelper.exe") UPDATER_HELPER = os.path.join(
ROOT, "..", "program-updater", "dist", "UpdaterHelper.exe"
)
updater_binaries = [ updater_binaries = [
(UPDATER_HELPER, "updater/platforms/windows/bin"), (UPDATER_HELPER, "updater/platforms/windows/bin"),
] ]
...@@ -85,7 +90,7 @@ a = Analysis( ...@@ -85,7 +90,7 @@ a = Analysis(
如果使用 PyInstaller 命令行而非 `.spec`,对应写法是: 如果使用 PyInstaller 命令行而非 `.spec`,对应写法是:
```powershell ```powershell
pyinstaller --onefile --add-binary "dist\UpdaterHelper.exe;updater\platforms\windows\bin" --name MyProgram path\to\your_app.py pyinstaller --onefile --add-binary "..\program-updater\dist\UpdaterHelper.exe;updater\platforms\windows\bin" --name MyProgram path\to\your_app.py
``` ```
目标程序静态导入 `updater` 后,PyInstaller 会自动收集所需的 Python 模块;helper 目标程序静态导入 `updater` 后,PyInstaller 会自动收集所需的 Python 模块;helper
...@@ -113,12 +118,14 @@ import logging ...@@ -113,12 +118,14 @@ import logging
import sys import sys
from pathlib import Path from pathlib import Path
from PyQt5.QtWidgets import QApplication, QMessageBox from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QMessageBox, QProgressDialog
from updater import UpdateClient, UpdaterError, confirm_update_started from updater import UpdateClient, UpdaterError, confirm_update_started
APP_ID = "margin-guard" APP_ID = "margin-guard"
APP_VERSION = "1.1.0" APP_VERSION = "1.1.0"
MANIFEST_URL = "http://192.168.1.20:8000/updates/margin-guard/latest.json" 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__) logger = logging.getLogger(__name__)
...@@ -132,6 +139,7 @@ def main() -> int: ...@@ -132,6 +139,7 @@ def main() -> int:
current_version=APP_VERSION, current_version=APP_VERSION,
manifest_url=MANIFEST_URL, manifest_url=MANIFEST_URL,
executable_path=sys.executable, executable_path=sys.executable,
download_page_url=DOWNLOAD_PAGE_URL,
helper_resource=( helper_resource=(
bundle_dir bundle_dir
/ "updater" / "updater"
...@@ -142,7 +150,23 @@ def main() -> int: ...@@ -142,7 +150,23 @@ def main() -> int:
), ),
) )
progress = QProgressDialog("正在检查更新…", "", 0, 0)
progress.setWindowTitle("程序更新")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setAutoClose(False)
progress.setAutoReset(False)
progress.show()
QApplication.processEvents()
def on_progress(downloaded: int, total: int) -> None:
progress.setLabelText("正在下载更新…")
progress.setRange(0, 1000)
progress.setValue(int(downloaded * 1000 / total) if total else 0)
QApplication.processEvents()
def confirm_update(update) -> bool: def confirm_update(update) -> bool:
progress.hide()
answer = QMessageBox.question( answer = QMessageBox.question(
None, None,
"发现新版本", "发现新版本",
...@@ -150,16 +174,24 @@ def main() -> int: ...@@ -150,16 +174,24 @@ def main() -> int:
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes, QMessageBox.Yes,
) )
if answer == QMessageBox.Yes:
progress.setLabelText("准备下载更新…")
progress.setRange(0, 0)
progress.show()
QApplication.processEvents()
return answer == QMessageBox.Yes return answer == QMessageBox.Yes
try: try:
handed_off = updater.update_before_startup( handed_off = updater.update_before_startup(
confirm_update=confirm_update, confirm_update=confirm_update,
restart_args=sys.argv[1:], restart_args=sys.argv[1:],
on_progress=on_progress,
) )
except UpdaterError: except UpdaterError:
logger.exception("自动更新失败,将启动当前版本") logger.exception("自动更新失败,将启动当前版本")
handed_off = False handed_off = False
finally:
progress.close()
if handed_off: if handed_off:
return 0 return 0
...@@ -177,10 +209,16 @@ if __name__ == "__main__": ...@@ -177,10 +209,16 @@ if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())
``` ```
示例中的 `on_progress(downloaded, total)` 是下载进度回调,可以用它驱动 QProgressDialog 等进度条。更新流程在调用线程中同步执行,所以需要在回调里调用 `QApplication.processEvents()` 让界面刷新;其他 GUI 框架使用对应的消息处理函数。
`update_before_startup()` 会同步检查清单。选择“否”时本次继续启动旧版,下次启动会再次提醒;选择“是”时才下载和校验 exe、启动 helper,并返回 `True`。目标进程随后退出,helper 替换 exe 并自动启动新版。新版启动检查时发现已是最新版本,就会继续启动主窗口。LAN 服务不可用或更新失败时,上例记录错误并继续启动旧版;强制更新的程序可以改为提示错误并停止启动。 `update_before_startup()` 会同步检查清单。选择“否”时本次继续启动旧版,下次启动会再次提醒;选择“是”时才下载和校验 exe、启动 helper,并返回 `True`。目标进程随后退出,helper 替换 exe 并自动启动新版。新版启动检查时发现已是最新版本,就会继续启动主窗口。LAN 服务不可用或更新失败时,上例记录错误并继续启动旧版;强制更新的程序可以改为提示错误并停止启动。
`confirm_update()` 是目标应用提供的回调,所以 updater 本身不依赖 PyQt。`confirm_update_started()` 应在新版完成必要初始化后调用。helper 默认等待确认 45 秒;若新版提前退出或没有确认,会尝试恢复旧 exe。数据库迁移前应备份数据,exe 回滚本身不能撤销数据库迁移。portable 安装目录必须允许当前用户写入,配置文件和数据库应放在 exe 之外。 `confirm_update()` 是目标应用提供的回调,所以 updater 本身不依赖 PyQt。`confirm_update_started()` 应在新版完成必要初始化后调用。helper 默认等待确认 45 秒;若新版提前退出或没有确认,会尝试恢复旧 exe。数据库迁移前应备份数据,exe 回滚本身不能撤销数据库迁移。portable 安装目录必须允许当前用户写入,配置文件和数据库应放在 exe 之外。
更新前还会校验目标程序内嵌 helper 的版本资源。helper 会随目标程序一起打包,不会随 Python 代码自动升级;如果 helper 缺少版本信息或低于当前 updater 的要求,客户端会弹出提示框,用户确认后打开 `download_page_url` 下载完整程序。未配置该参数时,默认按 manifest 地址推导 `/download/{app_id}` 页面。发布新版本时必须在同一构建环境中重新构建 helper,并用最新的 `UpdaterHelper.exe` 重新打包目标程序。
新版确认启动成功后,helper 会删除 `.updates` 中的 staging、backup、健康标记和旧 helper 文件;新版客户端随后在后台删除剩余的 `.updates` 目录。失败回滚时不会执行清理,便于排查问题。
## 限制 ## 限制
- helper 只支持 Windows;分发管理后台可在 Linux 上运行。 - helper 只支持 Windows;分发管理后台可在 Linux 上运行。
......
...@@ -10,10 +10,12 @@ import urllib.error ...@@ -10,10 +10,12 @@ import urllib.error
import urllib.request import urllib.request
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
from uuid import uuid4 from uuid import uuid4
from .errors import DownloadError, ManifestError, UpdateApplyError, UpdateCheckError from .errors import DownloadError, ManifestError, UpdateApplyError, UpdateCheckError
from .files import sha256_file from .files import sha256_file
from .health import is_health_check_launch
from .manifest import parse_manifest, validate_app_id, validate_url, version_key from .manifest import parse_manifest, validate_app_id, validate_url, version_key
from .models import UpdateInfo from .models import UpdateInfo
...@@ -36,6 +38,7 @@ class UpdateClient: ...@@ -36,6 +38,7 @@ class UpdateClient:
executable_path: str | Path, executable_path: str | Path,
helper_resource: str | Path, helper_resource: str | Path,
update_dir: str | Path | None = None, update_dir: str | Path | None = None,
download_page_url: str | None = None,
timeout: float = 15.0, timeout: float = 15.0,
max_artifact_size: int = _DEFAULT_MAX_ARTIFACT_SIZE, max_artifact_size: int = _DEFAULT_MAX_ARTIFACT_SIZE,
) -> None: ) -> None:
...@@ -45,6 +48,16 @@ class UpdateClient: ...@@ -45,6 +48,16 @@ class UpdateClient:
self.manifest_url = validate_url(manifest_url, field_name="manifest_url") self.manifest_url = validate_url(manifest_url, field_name="manifest_url")
self.executable_path = Path(executable_path).resolve() self.executable_path = Path(executable_path).resolve()
self.helper_resource = Path(helper_resource).resolve() self.helper_resource = Path(helper_resource).resolve()
if download_page_url is None:
manifest_parts = urlsplit(self.manifest_url)
download_page_url = (
f"{manifest_parts.scheme}://{manifest_parts.netloc}"
f"/download/{self.app_id}"
)
self.download_page_url = validate_url(
download_page_url,
field_name="download_page_url",
)
self.update_dir = ( self.update_dir = (
Path(update_dir).resolve() Path(update_dir).resolve()
if update_dir is not None if update_dir is not None
...@@ -97,12 +110,16 @@ class UpdateClient: ...@@ -97,12 +110,16 @@ class UpdateClient:
If ``confirm_update`` returns ``False``, the current launch continues If ``confirm_update`` returns ``False``, the current launch continues
without applying the update. The caller owns all user-interface behavior. without applying the update. The caller owns all user-interface behavior.
""" """
if is_health_check_launch():
return False
update = self.check_for_update() update = self.check_for_update()
if update is None: if update is None:
return False return False
if confirm_update is not None and not confirm_update(update): if confirm_update is not None and not confirm_update(update):
return False return False
self._ensure_current_helper()
staged_executable = self.download(update, on_progress=on_progress) staged_executable = self.download(update, on_progress=on_progress)
self.launch_update_helper( self.launch_update_helper(
update, update,
...@@ -201,6 +218,7 @@ class UpdateClient: ...@@ -201,6 +218,7 @@ class UpdateClient:
raise UpdateApplyError("staged executable must be inside the updater staging directory.") raise UpdateApplyError("staged executable must be inside the updater staging directory.")
self._verify_artifact(staged_path, update) self._verify_artifact(staged_path, update)
self._ensure_current_helper()
helper_path = self._prepare_helper() helper_path = self._prepare_helper()
backup_dir = self.update_dir / "backup" backup_dir = self.update_dir / "backup"
backup_dir.mkdir(parents=True, exist_ok=True) backup_dir.mkdir(parents=True, exist_ok=True)
...@@ -241,6 +259,24 @@ class UpdateClient: ...@@ -241,6 +259,24 @@ class UpdateClient:
except OSError as exc: except OSError as exc:
raise UpdateApplyError(f"could not start update helper: {exc}") from exc raise UpdateApplyError(f"could not start update helper: {exc}") from exc
def _ensure_current_helper(self) -> None:
from ..platforms.windows.version import helper_is_current
if helper_is_current(self.helper_resource):
return
from ..platforms.windows.dialogs import show_outdated_helper_dialog
show_outdated_helper_dialog(
"当前程序的更新组件版本过旧,无法自动更新。\n"
f"请下载最新完整程序:{self.download_page_url}",
self.download_page_url,
)
raise UpdateApplyError(
"the bundled update helper is outdated; "
"download the latest application package."
)
def _prepare_helper(self) -> Path: def _prepare_helper(self) -> Path:
if not self.helper_resource.is_file(): if not self.helper_resource.is_file():
raise UpdateApplyError(f"update helper resource was not found: {self.helper_resource}") raise UpdateApplyError(f"update helper resource was not found: {self.helper_resource}")
......
"""Startup confirmation shared with the update helper's rollback check.""" """Startup confirmation shared with the update helper's rollback check."""
import os import os
import shutil
import sys import sys
import threading
import time
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
...@@ -9,6 +12,16 @@ from .errors import UpdateApplyError ...@@ -9,6 +12,16 @@ from .errors import UpdateApplyError
_HEALTH_FILE_ENV = "PROGRAM_UPDATER_HEALTH_FILE" _HEALTH_FILE_ENV = "PROGRAM_UPDATER_HEALTH_FILE"
_HEALTH_TOKEN_ENV = "PROGRAM_UPDATER_HEALTH_TOKEN" _HEALTH_TOKEN_ENV = "PROGRAM_UPDATER_HEALTH_TOKEN"
_CLEANUP_PENDING_FILE = ".cleanup-pending"
_CLEANUP_READY_FILE = ".cleanup-ready"
_CLEANUP_WAIT_SECONDS = 15.0
_CLEANUP_RETRY_SECONDS = 10.0
_CLEANUP_POLL_SECONDS = 0.2
def is_health_check_launch() -> bool:
"""Return whether the helper restarted this process for health confirmation."""
return _HEALTH_FILE_ENV in os.environ or _HEALTH_TOKEN_ENV in os.environ
def confirm_update_started() -> bool: def confirm_update_started() -> bool:
...@@ -34,4 +47,38 @@ def confirm_update_started() -> bool: ...@@ -34,4 +47,38 @@ def confirm_update_started() -> bool:
os.replace(temporary_path, marker_path) os.replace(temporary_path, marker_path)
finally: finally:
temporary_path.unlink(missing_ok=True) temporary_path.unlink(missing_ok=True)
_schedule_cleanup(install_root / ".updates")
return True return True
def _schedule_cleanup(update_dir: Path) -> None:
if not (update_dir / _CLEANUP_PENDING_FILE).is_file():
return
threading.Thread(
target=_cleanup_update_dir,
args=(update_dir,),
daemon=True,
name="program-updater-cleanup",
).start()
def _cleanup_update_dir(update_dir: Path) -> None:
ready = update_dir / _CLEANUP_READY_FILE
deadline = time.monotonic() + _CLEANUP_WAIT_SECONDS
while not ready.is_file():
if time.monotonic() >= deadline:
return
time.sleep(_CLEANUP_POLL_SECONDS)
deadline = time.monotonic() + _CLEANUP_RETRY_SECONDS
while True:
try:
shutil.rmtree(update_dir)
return
except FileNotFoundError:
return
except OSError:
if time.monotonic() >= deadline:
return
time.sleep(_CLEANUP_POLL_SECONDS)
"""Native Windows prompts used before starting an update."""
from __future__ import annotations
import ctypes
import os
import webbrowser
from ctypes import wintypes
_IDYES = 6
_MB_YESNO = 0x00000004
_MB_ICONWARNING = 0x00000030
def show_outdated_helper_dialog(message: str, download_page_url: str) -> None:
"""Warn that the bundled helper is outdated and offer the download page."""
if os.name != "nt":
return
user32 = ctypes.WinDLL("user32", use_last_error=True)
user32.MessageBoxW.argtypes = [
wintypes.HWND,
wintypes.LPCWSTR,
wintypes.LPCWSTR,
wintypes.UINT,
]
user32.MessageBoxW.restype = ctypes.c_int
result = user32.MessageBoxW(
None,
f"{message}\n\n是否现在打开下载页面?",
"需要下载最新程序",
_MB_YESNO | _MB_ICONWARNING,
)
if result == _IDYES:
webbrowser.open(download_page_url)
...@@ -7,7 +7,9 @@ import ctypes ...@@ -7,7 +7,9 @@ import ctypes
import json import json
import logging import logging
import os import os
import shutil
import subprocess import subprocess
import sys
import time import time
from collections.abc import Sequence from collections.abc import Sequence
from ctypes import wintypes from ctypes import wintypes
...@@ -16,7 +18,12 @@ from uuid import uuid4 ...@@ -16,7 +18,12 @@ from uuid import uuid4
from ...core.errors import UpdaterError from ...core.errors import UpdaterError
from ...core.files import sha256_file from ...core.files import sha256_file
from ...core.health import _HEALTH_FILE_ENV, _HEALTH_TOKEN_ENV from ...core.health import (
_CLEANUP_PENDING_FILE,
_CLEANUP_READY_FILE,
_HEALTH_FILE_ENV,
_HEALTH_TOKEN_ENV,
)
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
_SYNCHRONIZE = 0x00100000 _SYNCHRONIZE = 0x00100000
...@@ -99,8 +106,9 @@ def _apply_update( ...@@ -99,8 +106,9 @@ def _apply_update(
raise HelperError("sha256 must contain 64 hexadecimal characters.") raise HelperError("sha256 must contain 64 hexadecimal characters.")
root = target.parent root = target.parent
staging_root = (root / ".updates" / "staging").resolve() update_dir = root / ".updates"
backup_root = (root / ".updates" / "backup").resolve() staging_root = (update_dir / "staging").resolve()
backup_root = (update_dir / "backup").resolve()
if not target.is_file() or not staged.is_file(): if not target.is_file() or not staged.is_file():
raise HelperError("the installed or staged executable does not exist.") raise HelperError("the installed or staged executable does not exist.")
if not staged.is_relative_to(staging_root): if not staged.is_relative_to(staging_root):
...@@ -119,9 +127,13 @@ def _apply_update( ...@@ -119,9 +127,13 @@ def _apply_update(
_wait_for_parent_exit(parent_pid, target) _wait_for_parent_exit(parent_pid, target)
_replace_file(target, staged, backup) _replace_file(target, staged, backup)
logging.info("installed staged executable at %s", target) logging.info("installed staged executable at %s", target)
try:
(update_dir / _CLEANUP_PENDING_FILE).write_text("", encoding="ascii")
except OSError:
logging.warning("could not create cleanup marker", exc_info=True)
token = uuid4().hex token = uuid4().hex
health_dir = root / ".updates" / "health" health_dir = update_dir / "health"
health_dir.mkdir(parents=True, exist_ok=True) health_dir.mkdir(parents=True, exist_ok=True)
marker = health_dir / f"{token}.ready" marker = health_dir / f"{token}.ready"
environment = os.environ.copy() environment = os.environ.copy()
...@@ -154,6 +166,10 @@ def _apply_update( ...@@ -154,6 +166,10 @@ def _apply_update(
f"new version failed to start ({exc}); rollback also failed: " f"new version failed to start ({exc}); rollback also failed: "
f"{rollback_error}. Previous executable is at {backup}." f"{rollback_error}. Previous executable is at {backup}."
) from rollback_error ) from rollback_error
try:
(update_dir / _CLEANUP_PENDING_FILE).unlink(missing_ok=True)
except OSError:
logging.warning("could not remove cleanup marker", exc_info=True)
raise HelperError( raise HelperError(
f"updated application failed to start; the previous version was restored: {exc}" f"updated application failed to start; the previous version was restored: {exc}"
) from exc ) from exc
...@@ -163,8 +179,8 @@ def _apply_update( ...@@ -163,8 +179,8 @@ def _apply_update(
except OSError: except OSError:
logging.warning("could not remove health marker %s", marker, exc_info=True) logging.warning("could not remove health marker %s", marker, exc_info=True)
logging.info("new version confirmed; previous executable retained at %s", backup) logging.info("new version confirmed; removing update artifacts")
_remove_old_backups(backup.parent, backup) _cleanup_after_success(update_dir)
def _wait_for_parent_exit(parent_pid: int, target: Path) -> None: def _wait_for_parent_exit(parent_pid: int, target: Path) -> None:
...@@ -181,7 +197,12 @@ def _wait_for_parent_exit(parent_pid: int, target: Path) -> None: ...@@ -181,7 +197,12 @@ def _wait_for_parent_exit(parent_pid: int, target: Path) -> None:
raise OSError(error_code, "could not open the parent application process.") raise OSError(error_code, "could not open the parent application process.")
try: try:
try:
process_path = _process_image_path(kernel32, handle) process_path = _process_image_path(kernel32, handle)
except OSError:
if kernel32.WaitForSingleObject(handle, 0) == _WAIT_OBJECT_0:
return
raise
if os.path.normcase(os.path.abspath(process_path)) != os.path.normcase( if os.path.normcase(os.path.abspath(process_path)) != os.path.normcase(
os.path.abspath(target) os.path.abspath(target)
): ):
...@@ -261,14 +282,37 @@ def _stop_process(child: subprocess.Popen[bytes]) -> None: ...@@ -261,14 +282,37 @@ def _stop_process(child: subprocess.Popen[bytes]) -> None:
child.wait(timeout=5) child.wait(timeout=5)
def _remove_old_backups(backup_dir: Path, current_backup: Path) -> None: def _cleanup_after_success(update_dir: Path) -> None:
for path in backup_dir.glob("*.bak"): for name in ("staging", "backup", "health"):
if path == current_backup: _remove_tree(update_dir / name)
running_helper = Path(sys.executable).resolve()
helpers_dir = update_dir / "helpers"
try:
helper_files = list(helpers_dir.iterdir())
except OSError:
helper_files = []
for path in helper_files:
if path.resolve() == running_helper:
continue continue
try: try:
path.unlink() path.unlink()
except OSError: except OSError:
logging.warning("could not remove older backup %s", path, exc_info=True) logging.warning("could not remove update artifact %s", path, exc_info=True)
try:
(update_dir / _CLEANUP_READY_FILE).write_text("", encoding="ascii")
except OSError:
logging.warning("could not create cleanup-ready marker", exc_info=True)
def _remove_tree(path: Path) -> None:
try:
shutil.rmtree(path)
except FileNotFoundError:
pass
except OSError:
logging.warning("could not remove %s", path, exc_info=True)
def _kernel32() -> ctypes.WinDLL: def _kernel32() -> ctypes.WinDLL:
......
"""Version metadata shared by the Windows update helper and its client."""
from __future__ import annotations
import ctypes
import os
from ctypes import wintypes
from pathlib import Path
HELPER_PROTOCOL_VERSION = 2
_VS_FFI_SIGNATURE = 0xFEEF04BD
class _VSFixedFileInfo(ctypes.Structure):
_fields_ = [
("dwSignature", wintypes.DWORD),
("dwStrucVersion", wintypes.DWORD),
("dwFileVersionMS", wintypes.DWORD),
("dwFileVersionLS", wintypes.DWORD),
("dwProductVersionMS", wintypes.DWORD),
("dwProductVersionLS", wintypes.DWORD),
("dwFileFlagsMask", wintypes.DWORD),
("dwFileFlags", wintypes.DWORD),
("dwFileOS", wintypes.DWORD),
("dwFileType", wintypes.DWORD),
("dwFileSubtype", wintypes.DWORD),
("dwFileDateMS", wintypes.DWORD),
("dwFileDateLS", wintypes.DWORD),
]
def read_helper_protocol_version(helper_path: str | Path) -> int | None:
"""Read the helper's major file version, or ``None`` when it has none."""
if os.name != "nt":
return None
version = ctypes.WinDLL("version", use_last_error=True)
version.GetFileVersionInfoSizeW.argtypes = [
wintypes.LPCWSTR,
ctypes.POINTER(wintypes.DWORD),
]
version.GetFileVersionInfoSizeW.restype = wintypes.DWORD
version.GetFileVersionInfoW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
wintypes.LPVOID,
]
version.GetFileVersionInfoW.restype = wintypes.BOOL
version.VerQueryValueW.argtypes = [
wintypes.LPVOID,
wintypes.LPCWSTR,
ctypes.POINTER(wintypes.LPVOID),
ctypes.POINTER(wintypes.UINT),
]
version.VerQueryValueW.restype = wintypes.BOOL
ignored_handle = wintypes.DWORD()
size = version.GetFileVersionInfoSizeW(
str(Path(helper_path).resolve()),
ctypes.byref(ignored_handle),
)
if size == 0:
return None
buffer = ctypes.create_string_buffer(size)
if not version.GetFileVersionInfoW(
str(Path(helper_path).resolve()),
0,
size,
buffer,
):
return None
pointer = wintypes.LPVOID()
length = wintypes.UINT()
if not version.VerQueryValueW(
buffer,
"\\",
ctypes.byref(pointer),
ctypes.byref(length),
):
return None
if length.value < ctypes.sizeof(_VSFixedFileInfo):
return None
fixed = ctypes.cast(pointer, ctypes.POINTER(_VSFixedFileInfo)).contents
if fixed.dwSignature != _VS_FFI_SIGNATURE:
return None
return fixed.dwFileVersionMS >> 16
def helper_is_current(helper_path: str | Path) -> bool:
"""Return whether the helper is new enough to apply updates."""
protocol_version = read_helper_protocol_version(helper_path)
return (
protocol_version is not None
and protocol_version >= HELPER_PROTOCOL_VERSION
)
...@@ -10,7 +10,8 @@ from pathlib import Path ...@@ -10,7 +10,8 @@ from pathlib import Path
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from updater import UpdateClient, UpdateInfo, confirm_update_started from updater import UpdateClient, UpdateInfo, confirm_update_started
from updater.core.errors import DownloadError, UpdateCheckError from updater.core.errors import DownloadError, UpdateApplyError, UpdateCheckError
from updater.core.health import _cleanup_update_dir
class FakeResponse(io.BytesIO): class FakeResponse(io.BytesIO):
...@@ -84,6 +85,22 @@ class UpdateClientTests(unittest.TestCase): ...@@ -84,6 +85,22 @@ class UpdateClientTests(unittest.TestCase):
): ):
self.assertIsNone(self.client().check_for_update()) self.assertIsNone(self.client().check_for_update())
def test_update_before_startup_skips_health_check_launch(self) -> None:
client = self.client()
with (
patch.dict(
os.environ,
{
"PROGRAM_UPDATER_HEALTH_FILE": str(self.root / "marker"),
"PROGRAM_UPDATER_HEALTH_TOKEN": "a" * 32,
},
),
patch.object(client, "check_for_update") as check_for_update,
):
self.assertFalse(client.update_before_startup())
check_for_update.assert_not_called()
def test_update_before_startup_returns_false_when_current(self) -> None: def test_update_before_startup_returns_false_when_current(self) -> None:
client = self.client() client = self.client()
with ( with (
...@@ -136,6 +153,7 @@ class UpdateClientTests(unittest.TestCase): ...@@ -136,6 +153,7 @@ class UpdateClientTests(unittest.TestCase):
with ( with (
patch.object(client, "check_for_update", return_value=update), patch.object(client, "check_for_update", return_value=update),
patch.object(client, "_ensure_current_helper") as ensure_helper,
patch.object(client, "download", return_value=staged_executable) as download, patch.object(client, "download", return_value=staged_executable) as download,
patch.object(client, "launch_update_helper") as launch_helper, patch.object(client, "launch_update_helper") as launch_helper,
): ):
...@@ -149,6 +167,7 @@ class UpdateClientTests(unittest.TestCase): ...@@ -149,6 +167,7 @@ class UpdateClientTests(unittest.TestCase):
) )
confirm_update.assert_called_once_with(update) confirm_update.assert_called_once_with(update)
ensure_helper.assert_called_once_with()
download.assert_called_once_with(update, on_progress=progress) download.assert_called_once_with(update, on_progress=progress)
launch_helper.assert_called_once_with( launch_helper.assert_called_once_with(
update, update,
...@@ -157,6 +176,26 @@ class UpdateClientTests(unittest.TestCase): ...@@ -157,6 +176,26 @@ class UpdateClientTests(unittest.TestCase):
health_timeout=60.0, health_timeout=60.0,
) )
def test_outdated_helper_prompts_download_page(self) -> None:
client = self.client()
with (
patch(
"updater.platforms.windows.version.helper_is_current",
return_value=False,
),
patch(
"updater.platforms.windows.dialogs.show_outdated_helper_dialog"
) as dialog,
):
with self.assertRaisesRegex(UpdateApplyError, "outdated"):
client._ensure_current_helper()
dialog.assert_called_once()
self.assertEqual(
dialog.call_args.args[1],
"http://127.0.0.1:8000/download/my-program",
)
def test_update_before_startup_surfaces_check_errors(self) -> None: def test_update_before_startup_surfaces_check_errors(self) -> None:
client = self.client() client = self.client()
with patch.object( with patch.object(
...@@ -192,6 +231,16 @@ class UpdateClientTests(unittest.TestCase): ...@@ -192,6 +231,16 @@ class UpdateClientTests(unittest.TestCase):
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
self.assertFalse(confirm_update_started()) self.assertFalse(confirm_update_started())
def test_cleanup_removes_finished_update_directory(self) -> None:
update_dir = self.root / ".updates"
(update_dir / "staging").mkdir(parents=True)
(update_dir / "staging" / "update.exe").write_bytes(b"x")
(update_dir / ".cleanup-ready").write_text("", encoding="ascii")
_cleanup_update_dir(update_dir)
self.assertFalse(update_dir.exists())
def test_health_confirmation_writes_expected_token(self) -> None: def test_health_confirmation_writes_expected_token(self) -> None:
token = "a" * 32 token = "a" * 32
marker = self.root / ".updates" / "health" / f"{token}.ready" marker = self.root / ".updates" / "health" / f"{token}.ready"
......
...@@ -130,7 +130,7 @@ wheels = [ ...@@ -130,7 +130,7 @@ wheels = [
[[package]] [[package]]
name = "program-updater" name = "program-updater"
version = "261028.0.0" version = "261028.3.0"
source = { editable = "." } source = { editable = "." }
[package.optional-dependencies] [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