Files
server_manager/src/services/hot_update.py
T
2026-05-22 00:16:08 +08:00

374 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)