换位置
This commit is contained in:
+195
-30
@@ -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 检测用 ctypes(Windows)或 os.kill(Unix)
|
||||
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
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"""
|
||||
仿 Cursor / VS Code 的无感增量更新模块(Tkinter 入口用)
|
||||
|
||||
【打包前提】采用 PyInstaller 文件夹模式 (-D)。安装目录含 main.exe 与 _internal 等依赖。
|
||||
【增量范围】.patch 仅针对 main.exe:在 TEMP 合成 main_new.exe 并校验 SHA256;
|
||||
替换时只覆盖安装目录下的 main.exe,绝不修改 _internal 或其它文件。
|
||||
【临时清理】替换脚本会清理 TEMP 中的 .patch 与 main_new.exe,不留下垃圾。
|
||||
【打包前提】采用 PyInstaller 文件夹模式 (-D)。安装目录含 ServerManager.exe 与 _internal 等依赖。
|
||||
【增量范围】.patch 仅针对 ServerManager.exe:在 TEMP 合成 ServerManager_new.exe 并校验 SHA256;
|
||||
替换时只覆盖安装目录下的 ServerManager.exe,绝不修改 _internal 或其它文件。
|
||||
【临时清理】替换脚本会清理 TEMP 中的 .patch 与 ServerManager_new.exe,不留下垃圾。
|
||||
|
||||
依赖:bsdiff4, hashlib, requests, threading, os, sys, subprocess, tkinter。
|
||||
远端 version.json 支持两种键名:delta 或 delta_updates(兼容旧配置)。
|
||||
@@ -91,9 +91,9 @@ def apply_update_and_restart(
|
||||
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"
|
||||
target_name = (target_exe_name or os.path.basename(current_exe_path) or "ServerManager.exe").strip() or "ServerManager.exe"
|
||||
|
||||
# 优先使用安装目录下的 mini_updater.exe(与 main.exe 同目录),必须用绝对路径
|
||||
# 优先使用安装目录下的 mini_updater.exe(与 ServerManager.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:
|
||||
@@ -185,14 +185,14 @@ def _get_delta_map(manifest: dict) -> dict:
|
||||
class SeamlessUpdater:
|
||||
"""
|
||||
隐形增量更新:后台守护线程静默准备,就绪后主线程弹一次「重启以更新」,
|
||||
用户确认后极速替换 main.exe(或静默安装完整包)并退出。
|
||||
用户确认后极速替换 ServerManager.exe(或静默安装完整包)并退出。
|
||||
"""
|
||||
|
||||
def __init__(self, root: tk.Tk, local_version: str, version_url: str = None):
|
||||
self.root = root
|
||||
self.local_version = (local_version or "").strip()
|
||||
self.version_url = (version_url or VERSION_JSON_URL).strip()
|
||||
self._ready_delta_path = None # TEMP 下 main_new.exe 路径(增量就绪)
|
||||
self._ready_delta_path = None # TEMP 下 ServerManager_new.exe 路径(增量就绪)
|
||||
self._ready_full_path = None # TEMP 下 Setup.exe 路径(全量就绪)
|
||||
self._ready_remote_version = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -242,8 +242,8 @@ class SeamlessUpdater:
|
||||
|
||||
def _try_delta_path(self, manifest: dict) -> bool:
|
||||
"""
|
||||
若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 main_new.exe -> 校验 SHA256。
|
||||
仅针对 main.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。
|
||||
若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 ServerManager_new.exe -> 校验 SHA256。
|
||||
仅针对 ServerManager.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。
|
||||
成功则设置 self._ready_delta_path 并返回 True;任何一步失败返回 False。
|
||||
"""
|
||||
delta_map = _get_delta_map(manifest)
|
||||
@@ -386,7 +386,7 @@ import tkinter as tk
|
||||
from scripts.update.tk_updater import SeamlessUpdater, VERSION_JSON_URL
|
||||
|
||||
# 从 version.json 或常量读取本地版本号
|
||||
LOCAL_VERSION = "1.1.3" # 需与 version.json / 安装包一致
|
||||
LOCAL_VERSION = "1.1.8" # 需与 version.json / 安装包一致
|
||||
|
||||
def main():
|
||||
root = tk.Tk()
|
||||
@@ -408,20 +408,20 @@ if __name__ == "__main__":
|
||||
# 开发者:如何生成 .patch 文件(发版流程)
|
||||
# ---------------------------------------------------------------------------
|
||||
"""
|
||||
【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 main.exe,不包含 _internal;
|
||||
用户端替换时只覆盖 main.exe,绝不修改 _internal 或其它文件。
|
||||
【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 ServerManager.exe,不包含 _internal;
|
||||
用户端替换时只覆盖 ServerManager.exe,绝不修改 _internal 或其它文件。
|
||||
|
||||
1. 打包:使用 PyInstaller 文件夹模式生成当前版本:
|
||||
pyinstaller -D -w your_main.py
|
||||
得到 dist/your_main/main.exe(或 dist/main.exe)。
|
||||
pyinstaller -D -w -n ServerManager entrypoints/main.py
|
||||
得到 dist/ServerManager/ServerManager.exe。
|
||||
|
||||
2. 保存上一版本的 main.exe(仅此单文件,无需 _internal):
|
||||
将上一发版时的 main.exe 保存为 build/output/main_1.0.2.exe(版本号与 version.json 中 delta 的键一致)。
|
||||
2. 保存上一版本的 ServerManager.exe(仅此单文件,无需 _internal):
|
||||
将上一发版时的 ServerManager.exe 保存为 build/output/ServerManager_1.0.2.exe(版本号与 version.json 中 delta 的键一致)。
|
||||
|
||||
3. 生成差分补丁(需安装 bsdiff4:pip install bsdiff4):
|
||||
import bsdiff4
|
||||
old = open('build/output/main_1.0.2.exe', 'rb').read()
|
||||
new = open('dist/your_main/main.exe', 'rb').read()
|
||||
old = open('build/output/ServerManager_1.0.2.exe', 'rb').read()
|
||||
new = open('dist/ServerManager/ServerManager.exe', 'rb').read()
|
||||
patch = bsdiff4.diff(old, new)
|
||||
open('build/output/patches/v1.0.2_to_v1.0.3.patch', 'wb').write(patch)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user