# -*- coding: utf-8 -*- """ 独立进程外更新器 (Out-of-Process Updater) 无第三方依赖(仅标准库 + 可选 ctypes 探测 PID), 打包为 mini_updater.exe 后置于安装目录,由主程序在“确认重启”后拉起。 负责:等待主进程退出 → 重命名替换 exe → 以正确 cwd 与 DETACHED_PROCESS 启动新进程。 """ import argparse import hashlib import json import os import shutil import subprocess import sys import time import tempfile import zipfile # 仅标准库:PID 检测用 ctypes(Windows)或 os.kill(Unix) if sys.platform == "win32": import ctypes try: _kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] except Exception: _kernel32 = None else: _kernel32 = None def _log_error(install_dir: str, message: str, exc: Exception = None) -> None: """将错误写入安装目录下的 logs/update_error.log,便于排查。""" try: log_dir = os.path.join(install_dir, "logs") os.makedirs(log_dir, exist_ok=True) log_path = os.path.join(log_dir, "update_error.log") with open(log_path, "a", encoding="utf-8") as f: from datetime import datetime line = "[%s] %s" % (datetime.now().isoformat(), message) if exc is not None: line += " (%s)" % exc f.write(line + "\n") except Exception: pass def is_process_alive(pid: int) -> bool: """ 跨平台判断指定 PID 是否仍存活。 Windows: ctypes 调用 OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION); Unix: os.kill(pid, 0)。 """ if sys.platform == "win32" and _kernel32 is not None: # PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 handle = _kernel32.OpenProcess(0x1000, False, pid) if handle is None or handle == 0: return False try: _kernel32.CloseHandle(handle) except Exception: pass return True try: os.kill(pid, 0) return True except (OSError, ProcessLookupError, PermissionError): return False DELTA_FORMAT = "server-manager-file-delta-v1" def _sha256_file(file_path: str) -> str: h = hashlib.sha256() with open(file_path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest().lower() def _safe_relative_path(value: str) -> str: rel = str(value or "").replace("\\", "/").strip("/") parts = [part for part in rel.split("/") if part] if not parts or any(part in (".", "..") for part in parts): raise ValueError("unsafe relative path: %s" % value) return "/".join(parts) def _safe_join(root: str, rel_path: str) -> str: root_abs = os.path.abspath(root) rel = _safe_relative_path(rel_path) target = os.path.abspath(os.path.join(root_abs, *rel.split("/"))) if target != root_abs and not target.startswith(root_abs + os.sep): raise ValueError("path escapes install dir: %s" % rel_path) return target def _read_delta_manifest(delta_zip_path: str) -> dict: with zipfile.ZipFile(delta_zip_path, "r") as zf: with zf.open("delta_manifest.json") as f: manifest = json.loads(f.read().decode("utf-8-sig")) if not isinstance(manifest, dict): raise ValueError("delta manifest must be an object") if manifest.get("format") != DELTA_FORMAT: raise ValueError("unsupported delta format: %s" % manifest.get("format")) if not isinstance(manifest.get("files"), list): raise ValueError("delta manifest files must be a list") if not isinstance(manifest.get("delete"), list): manifest["delete"] = [] return manifest def _validate_base_files(install_dir: str, manifest: dict) -> None: for entry in manifest.get("files", []): rel = _safe_relative_path(entry.get("path")) old_sha = str(entry.get("old_sha256") or "").lower() target = _safe_join(install_dir, rel) if old_sha: if not os.path.isfile(target): raise ValueError("base file missing: %s" % rel) if _sha256_file(target) != old_sha: raise ValueError("base file hash mismatch: %s" % rel) for entry in manifest.get("delete", []): rel = _safe_relative_path(entry.get("path")) old_sha = str(entry.get("old_sha256") or "").lower() target = _safe_join(install_dir, rel) if os.path.exists(target) and old_sha and os.path.isfile(target) and _sha256_file(target) != old_sha: raise ValueError("delete file hash mismatch: %s" % rel) def _extract_delta_files(delta_zip_path: str, manifest: dict) -> str: staging_dir = tempfile.mkdtemp(prefix="server_manager_delta_") with zipfile.ZipFile(delta_zip_path, "r") as zf: names = set(zf.namelist()) for entry in manifest.get("files", []): rel = _safe_relative_path(entry.get("path")) zip_path = _safe_relative_path(entry.get("zip_path") or entry.get("source") or rel) expected_sha = str(entry.get("sha256") or "").lower() if zip_path not in names: raise ValueError("delta zip entry missing: %s" % zip_path) staged = _safe_join(staging_dir, rel) os.makedirs(os.path.dirname(staged), exist_ok=True) with zf.open(zip_path) as src, open(staged, "wb") as dst: shutil.copyfileobj(src, dst) if expected_sha and _sha256_file(staged) != expected_sha: raise ValueError("delta file hash mismatch: %s" % rel) return staging_dir def apply_delta_zip(install_dir: str, delta_zip_path: str) -> bool: """Apply a file-level delta zip after the main process has exited.""" backup_dir = "" staged_dir = "" moved_backups = [] created_targets = [] try: manifest = _read_delta_manifest(delta_zip_path) _validate_base_files(install_dir, manifest) staged_dir = _extract_delta_files(delta_zip_path, manifest) backup_dir = os.path.join(install_dir, ".update_backup", str(int(time.time()))) for entry in manifest.get("delete", []): rel = _safe_relative_path(entry.get("path")) target = _safe_join(install_dir, rel) if os.path.exists(target): backup = _safe_join(backup_dir, rel) os.makedirs(os.path.dirname(backup), exist_ok=True) shutil.move(target, backup) moved_backups.append((target, backup)) for entry in manifest.get("files", []): rel = _safe_relative_path(entry.get("path")) source = _safe_join(staged_dir, rel) target = _safe_join(install_dir, rel) os.makedirs(os.path.dirname(target), exist_ok=True) if os.path.exists(target): backup = _safe_join(backup_dir, rel) os.makedirs(os.path.dirname(backup), exist_ok=True) shutil.move(target, backup) moved_backups.append((target, backup)) else: created_targets.append(target) shutil.move(source, target) try: if os.path.isdir(staged_dir): shutil.rmtree(staged_dir, ignore_errors=True) if os.path.isdir(backup_dir): shutil.rmtree(backup_dir, ignore_errors=True) if os.path.isfile(delta_zip_path): os.remove(delta_zip_path) except Exception: pass return True except Exception as e: _log_error(install_dir, "apply delta zip failed", e) for target in reversed(created_targets): try: if os.path.exists(target): os.remove(target) except Exception: pass for target, backup in reversed(moved_backups): try: if os.path.exists(target): if os.path.isdir(target): shutil.rmtree(target, ignore_errors=True) else: os.remove(target) os.makedirs(os.path.dirname(target), exist_ok=True) if os.path.exists(backup): shutil.move(backup, target) except Exception as restore_error: _log_error(install_dir, "restore backup failed: %s" % target, restore_error) if staged_dir: shutil.rmtree(staged_dir, ignore_errors=True) return False def main() -> None: parser = argparse.ArgumentParser(description="独立更新守护:等主进程退出后替换 exe 并重启") parser.add_argument("--pid", type=int, required=True, help="主程序进程 ID,必须等其完全退出") parser.add_argument("--install-dir", type=str, required=True, help="主程序安装根目录") parser.add_argument("--new-exe-path", type=str, default="", help="已准备好的新版本 ServerManager.exe 的完整路径(通常在 TEMP)") parser.add_argument("--delta-zip-path", type=str, default="", help="文件级增量包 zip 路径") parser.add_argument("--target-exe-name", type=str, default="ServerManager.exe", help="主程序文件名,如 ServerManager.exe") args = parser.parse_args() install_dir = os.path.abspath(args.install_dir) new_exe_path = os.path.abspath(args.new_exe_path) if args.new_exe_path else "" delta_zip_path = os.path.abspath(args.delta_zip_path) if args.delta_zip_path else "" if bool(new_exe_path) == bool(delta_zip_path): _log_error(install_dir, "exactly one of --new-exe-path or --delta-zip-path is required") sys.exit(1) target_exe_name = args.target_exe_name.strip() or "ServerManager.exe" target_exe_full = os.path.join(install_dir, target_exe_name) old_exe_full = target_exe_full + ".old" # ------------------------------------------------------------------------- # 1. 死神凝视:每隔 0.5 秒检测 --pid 是否存活,必须等到进程彻底消失 # ------------------------------------------------------------------------- while is_process_alive(args.pid): time.sleep(0.5) # ------------------------------------------------------------------------- # 2. 障眼法替换 (The Swap) # ------------------------------------------------------------------------- import shutil try: os.chdir(install_dir) except Exception as e: _log_error(install_dir, "切换到安装目录失败", e) sys.exit(1) if delta_zip_path: if not apply_delta_zip(install_dir, delta_zip_path): sys.exit(1) else: # 若存在上次遗留的 .old,先静默删除 if os.path.isfile(old_exe_full): try: os.remove(old_exe_full) except Exception as e: _log_error(install_dir, "删除旧 .old 文件失败: %s" % old_exe_full, e) # 将当前主程序重命名为 .old,腾出位置 if not os.path.isfile(target_exe_full): _log_error(install_dir, "目标 exe 不存在: %s" % target_exe_full) sys.exit(1) try: os.rename(target_exe_full, old_exe_full) except Exception as e: _log_error(install_dir, "重命名当前 exe 为 .old 失败", e) sys.exit(1) # 将新 exe 移入安装目录并命名为 target-exe-name if not os.path.isfile(new_exe_path): _log_error(install_dir, "新 exe 不存在: %s" % new_exe_path) sys.exit(1) try: shutil.move(new_exe_path, target_exe_full) except Exception as e: _log_error(install_dir, "移动新 exe 到安装目录失败", e) try: os.rename(old_exe_full, target_exe_full) except Exception: pass sys.exit(1) # ------------------------------------------------------------------------- # 3. 干净的重生:Popen 新程序,强制 cwd、close_fds、DETACHED_PROCESS # ------------------------------------------------------------------------- try: creationflags = 0 if sys.platform == "win32": # DETACHED_PROCESS = 0x00000008,不继承控制台与父进程句柄 creationflags = 0x00000008 p = subprocess.Popen( [target_exe_full], cwd=install_dir, close_fds=True, creationflags=creationflags, shell=False, ) # 不等待子进程,更新器功成身退 except Exception as e: _log_error(install_dir, "拉起新主程序失败", e) sys.exit(1) # ------------------------------------------------------------------------- # 4. 功成身退 # ------------------------------------------------------------------------- sys.exit(0) if __name__ == "__main__": main()