仓库初始化

This commit is contained in:
lwt
2026-05-22 00:16:08 +08:00
commit 95c96dce42
118 changed files with 23005 additions and 0 deletions
+373
View File
@@ -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)