"""甄 AI 客服独立更新器;仅依赖 Python 标准库,打包为单文件 EXE。""" from __future__ import annotations import argparse import ctypes import hashlib import json import os import subprocess import time from pathlib import Path 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 _process_running(pid: int) -> bool: if pid <= 0: return False if os.name == "nt": process_query_limited_information = 0x1000 handle = ctypes.windll.kernel32.OpenProcess( process_query_limited_information, False, pid ) if not handle: return False ctypes.windll.kernel32.CloseHandle(handle) return True try: os.kill(pid, 0) return True except OSError: return False def _write_result(path: Path, payload: dict) -> 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 _wait_for_parent(pid: int, timeout: int = 120) -> None: deadline = time.monotonic() + timeout while _process_running(pid): if time.monotonic() >= deadline: raise RuntimeError("主程序在 120 秒内没有退出") time.sleep(0.25) def _start_available_version(install_dir: Path, preferred: Path | None = None) -> bool: """启动指定新版本;失败时回退到安装目录中最近的可执行旧版本。""" candidates = [] if preferred is not None: candidates.append(preferred) try: discovered = sorted( install_dir.glob("ZhenAI-WeCom-Assistant-v*.exe"), key=lambda item: item.stat().st_mtime, reverse=True, ) except OSError: discovered = [] candidates.extend(item for item in discovered if item not in candidates) for candidate in candidates: if not candidate.is_file(): continue try: subprocess.Popen([str(candidate)], cwd=install_dir, close_fds=True) return True except OSError: continue return False def main() -> int: parser = argparse.ArgumentParser(description="甄 AI 客服独立更新器") parser.add_argument("--installer", required=True) parser.add_argument("--parent-pid", type=int, required=True) parser.add_argument("--install-dir", required=True) parser.add_argument("--target-version", required=True) parser.add_argument("--result-file", required=True) parser.add_argument("--expected-sha256", default="") parser.add_argument("--from-version", default="") args = parser.parse_args() installer = Path(args.installer).resolve() install_dir = Path(args.install_dir).resolve() result_file = Path(args.result_file).resolve() result = { "target_version": args.target_version, "from_version": args.from_version, "status": "failed", "error": "", "updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), } try: _wait_for_parent(args.parent_pid) if not installer.is_file(): raise RuntimeError("升级安装包不存在") if args.expected_sha256 and _sha256(installer) != args.expected_sha256.lower(): raise RuntimeError("安装前 SHA256 复核失败") completed = subprocess.run( [str(installer), "/S"], timeout=600, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) if completed.returncode != 0: raise RuntimeError(f"静默安装失败,退出码 {completed.returncode}") target = install_dir / f"ZhenAI-WeCom-Assistant-v{args.target_version}.exe" if not target.is_file(): raise RuntimeError("安装完成但没有找到新版本主程序") result["status"] = "installed" _write_result(result_file, result) if not _start_available_version(install_dir, target): raise RuntimeError("安装成功,但无法启动新版本主程序") return 0 except Exception as exc: result["error"] = str(exc)[:1000] _write_result(result_file, result) _start_available_version(install_dir) return 1 if __name__ == "__main__": raise SystemExit(main())