340 lines
11 KiB
Python
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
|