#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 自动更新模块:支持全量安装包与 bsdiff4 增量更新(Delta Update) 远端 version.json(或 update_url 指向的 JSON)格式: { "version": "1.1.0", "release_notes": "修复了 UI 卡顿", "full_installer_url": "http://.../Setup_v1.1.0.exe", "delta_updates": { "1.0.0": { "patch_url": "http://.../v1.0.0_to_v1.1.0.patch", "new_exe_sha256": "abc123..." } } } - 若当前版本在 delta_updates 中,则优先使用增量(下载 patch,bsdiff4 打补丁后替换 exe) - 否则下载 full_installer_url 进行完整安装 """ import hashlib import json import os import subprocess import sys import tempfile from pathlib import Path from typing import Any, Dict, Optional, Tuple # 可选:requests 用于带进度的下载;若无则回退 urllib try: import requests _HAS_REQUESTS = True except ImportError: _HAS_REQUESTS = False try: import bsdiff4 _HAS_BSDIFF = True except ImportError: _HAS_BSDIFF = False def _version_json_path() -> Path: """获取 version.json 的路径。 打包后:优先从安装目录(exe 同目录)读取,便于安装包展开的 version.json 生效;若无则从 MEIPASS 读取。 未打包:从项目根目录或运行目录读取。 """ if getattr(sys, "frozen", False): # 安装包会将 version.json 展开到 {app},与 main.exe 同目录 install_dir = Path(sys.executable).resolve().parent external = install_dir / "version.json" if external.exists(): return external return Path(sys._MEIPASS) / "version.json" project_root = Path(__file__).resolve().parents[2] resource_version = project_root / "resources" / "version.json" if resource_version.exists(): return resource_version return project_root / "version.json" def get_current_version() -> str: """读取当前应用版本号""" path = _version_json_path() if not path.exists(): path = Path.cwd() / "resources" / "version.json" if not path.exists(): path = Path.cwd() / "version.json" if not path.exists(): return "0.0.0" try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) return data.get("version", "0.0.0").strip() except Exception: return "0.0.0" def get_version_info() -> Dict[str, Any]: """读取当前 version.json 完整内容""" path = _version_json_path() if not path.exists(): path = Path.cwd() / "resources" / "version.json" if not path.exists(): path = Path.cwd() / "version.json" if not path.exists(): return {"version": "0.0.0", "release_notes": ""} try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {"version": "0.0.0", "release_notes": ""} def _parse_version(v: str) -> Tuple[int, ...]: """将版本字符串转为可比较的元组""" parts = [] for s in v.strip().replace("-", ".").split("."): s = "".join(c for c in s if c.isdigit()) parts.append(int(s) if s else 0) return tuple(parts) def version_less(a: str, b: str) -> bool: """True 表示 a < b(有可用更新)""" return _parse_version(a) < _parse_version(b) def fetch_update_manifest(update_url: str, timeout: int = 15) -> Optional[Dict[str, Any]]: """ 从 update_url 拉取更新清单,解析新格式: version, release_notes, full_installer_url, delta_updates { "from_ver": { patch_url, new_exe_sha256 } } 兼容旧格式:download_url 视为 full_installer_url。 """ url = update_url.strip().rstrip("/") if not url.lower().startswith("http"): return None if not url.lower().endswith(".json"): url = url + "/version.json" if not url.endswith("version.json") else url try: if _HAS_REQUESTS: r = requests.get(url, headers={"User-Agent": "ServerManager-Updater/1.0"}, timeout=timeout) r.raise_for_status() data = r.json() else: import urllib.request req = urllib.request.Request(url, headers={"User-Agent": "ServerManager-Updater/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: data = json.loads(resp.read().decode("utf-8")) if isinstance(data, dict) and "version" in data: if "full_installer_url" not in data and "download_url" in data: data["full_installer_url"] = data["download_url"] return data return None except Exception: return None def get_delta_for_current(manifest: Dict[str, Any], current_version: str) -> Optional[Dict[str, Any]]: """ 从 manifest 的 delta_updates 中取当前版本对应的增量信息。 返回 { "patch_url": "...", "new_exe_sha256": "..." } 或 None。 """ delta_updates = manifest.get("delta_updates") or {} if not isinstance(delta_updates, dict): return None d = delta_updates.get(current_version.strip()) if isinstance(d, dict) and d.get("patch_url") and d.get("new_exe_sha256"): return d return None def download_file( url: str, dest_path: Path, progress_callback: Optional[Any] = None, timeout: int = 60, ) -> bool: """下载文件到 dest_path,可选进度回调 progress_callback(percent: int)""" dest_path = Path(dest_path) try: if _HAS_REQUESTS: r = requests.get( url, headers={"User-Agent": "ServerManager-Updater/1.0"}, stream=True, timeout=timeout, ) r.raise_for_status() total = int(r.headers.get("Content-Length", 0)) or None read = 0 dest_path.parent.mkdir(parents=True, exist_ok=True) with open(dest_path, "wb") as f: for chunk in r.iter_content(chunk_size=65536): if chunk: f.write(chunk) read += len(chunk) if progress_callback and total and total > 0: progress_callback(min(100, int(100 * read / total))) if progress_callback: progress_callback(100) return True else: import urllib.request req = urllib.request.Request(url, headers={"User-Agent": "ServerManager-Updater/1.0"}) with urllib.request.urlopen(req, timeout=timeout) as resp: total = int(resp.headers.get("Content-Length", 0)) or None data = b"" read = 0 chunk_size = 8192 while True: part = resp.read(chunk_size) if not part: break data += part read += len(part) if progress_callback and total and total > 0: progress_callback(min(100, int(100 * read / total))) dest_path.parent.mkdir(parents=True, exist_ok=True) dest_path.write_bytes(data) if progress_callback: progress_callback(100) return True except Exception: return False def clean_up_old_version() -> None: """ 启动时调用:删除当前 exe 所在目录下以 .old 结尾的残留文件, 即上一次「重命名大法」更新退下来的旧程序,静默删除不报错。 """ if getattr(sys, "frozen", False): install_dir = os.path.dirname(os.path.abspath(sys.executable)) else: install_dir = os.path.dirname(os.path.abspath(__file__)) or os.getcwd() try: for name in os.listdir(install_dir) or []: if name.endswith(".old"): path = os.path.join(install_dir, name) if os.path.isfile(path): try: os.remove(path) except Exception: pass except Exception: pass def _write_update_log(install_dir: str, message: str) -> None: """向安装目录下的 logs/update_launcher.log 写入,便于确认是否走了 mini_updater。""" try: from datetime import datetime log_dir = os.path.join(install_dir, "logs") os.makedirs(log_dir, exist_ok=True) log_path = os.path.join(log_dir, "update_launcher.log") with open(log_path, "a", encoding="utf-8") as f: f.write("[%s] %s\n" % (datetime.now().isoformat(), message)) except Exception: pass def apply_update_and_restart( new_exe_path: str, target_exe_name: Optional[str] = None, ) -> None: """ 独立进程外更新:拉起 mini_updater.exe,由其等待本进程退出后替换 exe 并以正确 cwd 重启。 若 mini_updater.exe 不存在则回退到“死循环重试”的 .bat。 调用后本进程会立即 os._exit(0),不会返回。 """ new_exe_path = os.path.abspath(new_exe_path) current_exe_path = os.path.abspath(sys.executable) if not os.path.isfile(new_exe_path): return install_dir = os.path.normpath(os.path.dirname(current_exe_path)) target_name = (target_exe_name or os.path.basename(current_exe_path) or "main.exe").strip() or "main.exe" # 优先使用安装目录下的 mini_updater.exe(与 main.exe 同目录),必须用绝对路径 mini_updater_exe = os.path.normpath(os.path.join(install_dir, "mini_updater.exe")) if sys.platform == "win32" and os.path.isfile(mini_updater_exe): try: pid = os.getpid() creationflags = 0x00000008 # DETACHED_PROCESS subprocess.Popen( [ mini_updater_exe, "--pid", str(pid), "--install-dir", install_dir, "--new-exe-path", new_exe_path, "--target-exe-name", target_name, ], cwd=install_dir, close_fds=True, creationflags=creationflags, shell=False, ) _write_update_log(install_dir, "已拉起 mini_updater.exe (pid=%s)" % pid) os._exit(0) return except Exception as e: _write_update_log(install_dir, "拉起 mini_updater.exe 失败: %s,改用 .bat" % e) else: _write_update_log( install_dir, "mini_updater.exe 未找到 (路径: %s),使用 .bat 回退" % mini_updater_exe, ) # 回退:.bat 死循环重试(仅 Windows) tmp = tempfile.gettempdir() bat_path = os.path.join(tmp, "apply_update_%s.bat" % os.getpid()) bat_lines = [ "@echo off", ":retry", 'move /y "%s" "%s" >nul 2>&1' % (new_exe_path, current_exe_path), "if errorlevel 1 (", " ping 127.0.0.1 -n 2 >nul", " goto retry", ")", 'cd /d "%s"' % install_dir, 'start "" "%s"' % current_exe_path, 'del "%~f0"', ] try: with open(bat_path, "w", encoding="utf-8") as f: f.write("\r\n".join(bat_lines)) subprocess.Popen( ["cmd", "/c", bat_path], creationflags=subprocess.CREATE_NO_WINDOW if (sys.platform == "win32" and hasattr(subprocess, "CREATE_NO_WINDOW")) else 0, shell=False, ) except Exception: pass os._exit(0) def apply_delta_patch(patch_path: Path, expected_new_sha256: str) -> Tuple[bool, str]: """ 使用 bsdiff4 应用增量补丁:当前 exe + patch -> 新 exe,校验 sha256 后在本进程内 用「重命名大法」替换并拉起新进程再退出(无需 .bat 或 --do-replace)。 仅在被 PyInstaller 打包为单文件 exe 时可用;非 frozen 时返回 (False, reason)。 成功时本函数不会返回(进程会退出);返回 (False, "原因") 表示未执行或失败。 """ if not getattr(sys, "frozen", False): return False, "当前未以打包方式运行" if not _HAS_BSDIFF: return False, "未包含 bsdiff4 组件,请使用全量更新" patch_path = Path(patch_path) if not patch_path.is_file(): return False, "补丁文件不存在" current_exe_path = os.path.abspath(sys.executable) try: with open(current_exe_path, "rb") as f: old_data = f.read() with open(patch_path, "rb") as f: patch_data = f.read() new_data = bsdiff4.patch(old_data, patch_data) got_sha = hashlib.sha256(new_data).hexdigest().lower() expected_sha = (expected_new_sha256 or "").strip().lower() if expected_sha and got_sha != expected_sha: return False, "校验未通过(补丁结果与预期不一致),请使用全量更新" tmp = tempfile.gettempdir() temp_new_exe_path = os.path.join(tmp, "ServerManager_new.exe") with open(temp_new_exe_path, "wb") as f: f.write(new_data) if not os.path.isfile(temp_new_exe_path): return False, "写入临时 exe 失败" # 独立进程外更新器 或 回退 .bat apply_update_and_restart(temp_new_exe_path, target_exe_name=os.path.basename(current_exe_path)) except Exception as e: return False, f"应用补丁时出错: {e!s},请使用全量更新" return False, "未知错误" def run_installer_and_exit(installer_path: Path, silent: bool = True) -> None: """运行完整安装包并退出本进程。Windows 下 silent 时传 /VERYSILENT 等参数。""" path = str(Path(installer_path).resolve()) if sys.platform == "win32": args = [path] if silent: args.extend(["/VERYSILENT", "/SUPPRESSMSGBOXES", "/FORCECLOSEAPPLICATIONS"]) subprocess.Popen(args, shell=False) else: subprocess.Popen( ["open" if sys.platform == "darwin" else "xdg-open", path], shell=False, ) if getattr(sys, "frozen", False): os._exit(0) else: sys.exit(0)