仓库初始化
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user