Files
kefu/wechat_rpa/desktop_updater.py
T
2026-09-21 10:34:06 +08:00

340 lines
11 KiB
Python

"""桌面客户端的升级清单、下载校验和独立安装器调度。"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Callable
import backend_client
from app_version import APP_VERSION, release_status
from runtime_paths import application_data_dir
UPDATE_ROOT = application_data_dir() / "updates"
UPDATE_STATE_FILE = UPDATE_ROOT / "state.json"
UPDATE_RESULT_FILE = UPDATE_ROOT / "result.json"
UPDATE_MANIFEST_PATH = "/api/v2/desktop/update/manifest"
UPDATE_REPORT_PATH = "/api/v2/desktop/update/report"
ProgressCallback = Callable[[int, int], None]
MAX_PACKAGE_BYTES = 1024 * 1024 * 1024
class UpdateError(RuntimeError):
pass
def _atomic_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
)
os.replace(temporary, path)
def fetch_manifest(
server_url: str,
device_id: str,
*,
current_version: str = APP_VERSION,
channel: str = "stable",
timeout: float = 6.0,
) -> dict[str, Any]:
query = urllib.parse.urlencode(
{
"current_version": current_version,
"device_id": device_id,
"channel": channel,
}
)
_, response = backend_client._request(
"GET",
server_url,
f"{UPDATE_MANIFEST_PATH}?{query}",
timeout=timeout,
)
status = release_status(response, local_version=current_version)
status["eligible"] = bool(response.get("eligible", True))
status["update_available"] = bool(
response.get("update_available", status["update_available"])
and status["update_available"]
)
status["force_upgrade"] = bool(
status["force_upgrade"] and status["update_available"]
)
status["auto_install"] = bool(
status["auto_install"] and status["update_available"]
)
status["ready_for_auto_update"] = bool(
status["update_available"]
and status["download_url"]
and status["package_sha256"]
)
return status
def report_update(
target_version: str,
status: str,
*,
progress: int = 0,
error: str = "",
current_version: str = APP_VERSION,
) -> bool:
"""上报属于观测能力,失败不能打断启动或安装。"""
try:
settings = backend_client.load_settings()
token = backend_client.desktop_access_token()
if not token:
return False
backend_client._request(
"POST",
str(settings.get("server_url") or backend_client.DEFAULT_SERVER_URL),
UPDATE_REPORT_PATH,
token=token,
payload={
"current_version": current_version,
"target_version": target_version,
"status": status,
"progress": max(0, min(int(progress), 100)),
"error": str(error or "")[:1000],
},
timeout=5.0,
)
return True
except Exception:
return False
def _validated_download_url(value: Any) -> str:
url = str(value or "").strip()
parsed = urllib.parse.urlparse(url)
if parsed.scheme == "https" and parsed.netloc:
return url
if (
parsed.scheme == "http"
and parsed.hostname in {"127.0.0.1", "localhost", "::1"}
and parsed.netloc
):
return url
raise UpdateError("升级包必须使用 HTTPS 下载(本机调试地址除外)")
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def authenticode_status(path: Path) -> str:
"""返回 Windows Authenticode 状态;非 Windows 明确标记为不可用。"""
if os.name != "nt":
return "Unavailable"
script = (
"$s=Get-AuthenticodeSignature -LiteralPath $args[0];"
"[Console]::Out.Write([string]$s.Status)"
)
try:
result = subprocess.run(
[
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-Command",
script,
str(path),
],
capture_output=True,
text=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except (OSError, subprocess.SubprocessError):
return "UnknownError"
return result.stdout.strip() or "UnknownError"
def download_update(
manifest: dict[str, Any], progress_callback: ProgressCallback | None = None
) -> Path:
version = str(manifest.get("latest_version") or "").strip()
expected_hash = str(manifest.get("package_sha256") or "").strip().lower()
if not version or len(expected_hash) != 64:
raise UpdateError("升级清单缺少版本号或 SHA256")
url = _validated_download_url(manifest.get("download_url"))
version_dir = UPDATE_ROOT / version
version_dir.mkdir(parents=True, exist_ok=True)
installer = version_dir / f"ZhenAI-Update-v{version}.exe"
partial = installer.with_suffix(".exe.part")
expected_size = max(0, int(manifest.get("package_size") or 0))
if expected_size > MAX_PACKAGE_BYTES:
raise UpdateError("升级包超过 1 GB 安全限制")
if installer.is_file() and _sha256(installer) == expected_hash:
if bool(manifest.get("signature_required")):
signature = authenticode_status(installer)
if signature != "Valid":
installer.unlink(missing_ok=True)
raise UpdateError(f"安装包数字签名无效:{signature}")
return installer
existing = partial.stat().st_size if partial.exists() else 0
if existing > MAX_PACKAGE_BYTES:
partial.unlink(missing_ok=True)
raise UpdateError("未完成的升级包超过 1 GB 安全限制,文件已丢弃")
headers = {"User-Agent": f"ZhenAI-Updater/{APP_VERSION}"}
if existing:
headers["Range"] = f"bytes={existing}-"
request = urllib.request.Request(url, headers=headers, method="GET")
try:
response = urllib.request.urlopen(request, timeout=30)
except Exception as exc:
raise UpdateError(f"升级包下载失败:{exc}") from exc
try:
with response:
# urllib 会自动跟随重定向;最终地址必须继续满足 HTTPS 策略,防止
# 合法清单被 302 到明文 HTTP 下载点。
final_url = (
response.geturl() if hasattr(response, "geturl") else url
)
_validated_download_url(final_url)
append = existing > 0 and int(getattr(response, "status", 200)) == 206
if not append:
existing = 0
mode = "ab" if append else "wb"
downloaded = existing
response_length = int(response.headers.get("Content-Length") or 0)
if response_length and existing + response_length > MAX_PACKAGE_BYTES:
raise UpdateError("升级包超过 1 GB 安全限制")
total = expected_size or (existing + response_length)
with partial.open(mode) as output:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
output.write(chunk)
downloaded += len(chunk)
if downloaded > MAX_PACKAGE_BYTES:
raise UpdateError("升级包超过 1 GB 安全限制")
if progress_callback:
progress_callback(downloaded, total)
except UpdateError:
partial.unlink(missing_ok=True)
raise
actual_size = partial.stat().st_size
if expected_size and actual_size != expected_size:
partial.unlink(missing_ok=True)
raise UpdateError(
f"安装包大小不一致:应为 {expected_size} 字节,实际 {actual_size} 字节"
)
actual_hash = _sha256(partial)
if actual_hash != expected_hash:
partial.unlink(missing_ok=True)
raise UpdateError("安装包 SHA256 校验失败,文件已丢弃")
os.replace(partial, installer)
signature = authenticode_status(installer)
if bool(manifest.get("signature_required")) and signature != "Valid":
installer.unlink(missing_ok=True)
raise UpdateError(f"安装包数字签名无效:{signature}")
_atomic_json(
UPDATE_STATE_FILE,
{
"target_version": version,
"installer": str(installer),
"sha256": actual_hash,
"signature": signature,
"status": "downloaded",
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
},
)
return installer
def validate_launch_prerequisites(installer: Path) -> Path:
"""只检查启动条件,不产生进程;用于停业务线程之前的安全预检。"""
if not getattr(sys, "frozen", False):
raise UpdateError("源码运行模式不会自动覆盖安装目录")
helper = Path(sys.executable).resolve().parent / "ZhenAIUpdater.exe"
if not helper.is_file():
raise UpdateError("安装目录缺少独立更新器 ZhenAIUpdater.exe")
if not installer.is_file():
raise UpdateError("已经下载的安装包不存在")
return helper
def launch_installer(installer: Path, target_version: str) -> None:
"""把独立更新器复制到更新目录后启动,主程序随后即可安全退出。"""
helper = validate_launch_prerequisites(installer)
runner = installer.parent / f"updater-{uuid.uuid4().hex[:8]}.exe"
shutil.copy2(helper, runner)
install_dir = Path(sys.executable).resolve().parent
result_file = UPDATE_RESULT_FILE
command = [
str(runner),
"--installer",
str(installer),
"--parent-pid",
str(os.getpid()),
"--install-dir",
str(install_dir),
"--target-version",
str(target_version),
"--result-file",
str(result_file),
"--expected-sha256",
_sha256(installer),
"--from-version",
APP_VERSION,
]
flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(
subprocess, "CREATE_NEW_PROCESS_GROUP", 0
)
subprocess.Popen(
command,
cwd=installer.parent,
close_fds=True,
creationflags=flags,
)
def report_previous_result() -> dict[str, Any]:
"""新版本首次启动时补报上一次安装结果。"""
try:
result = json.loads(UPDATE_RESULT_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return {}
target = str(result.get("target_version") or "")
status = str(result.get("status") or "")
if target and status in {"installed", "failed"}:
if report_update(
target,
status,
progress=100 if status == "installed" else 0,
error=str(result.get("error") or ""),
current_version=str(result.get("from_version") or ""),
):
UPDATE_RESULT_FILE.unlink(missing_ok=True)
return result