换位置

This commit is contained in:
lwt
2026-06-09 14:01:20 +08:00
parent 6d86678e11
commit 0b34f2e3d0
53 changed files with 1762 additions and 603 deletions
+195 -30
View File
@@ -8,10 +8,15 @@
"""
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
import tempfile
import zipfile
# 仅标准库:PID 检测用 ctypesWindows)或 os.killUnix
if sys.platform == "win32":
@@ -63,17 +68,173 @@ def is_process_alive(pid: int) -> bool:
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, required=True, help="已准备好的新版本 main.exe 的完整路径(通常在 TEMP)")
parser.add_argument("--target-exe-name", type=str, default="main.exe", help="主程序文件名,如 main.exe")
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)
target_exe_name = args.target_exe_name.strip() or "main.exe"
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"
@@ -93,36 +254,40 @@ def main() -> None:
_log_error(install_dir, "切换到安装目录失败", e)
sys.exit(1)
# 若存在上次遗留的 .old,先静默删除
if os.path.isfile(old_exe_full):
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.remove(old_exe_full)
os.rename(target_exe_full, old_exe_full)
except Exception as e:
_log_error(install_dir, "删除旧 .old 文件失败: %s" % old_exe_full, e)
_log_error(install_dir, "重命名当前 exe 为 .old 失败", e)
sys.exit(1)
# 将当前主程序重命名为 .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)
# 将新 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:
os.rename(old_exe_full, target_exe_full)
except Exception:
pass
sys.exit(1)
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