# -*- coding: utf-8 -*- """ 仿 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,不留下垃圾。 依赖:bsdiff4, hashlib, requests, threading, os, sys, subprocess, tkinter。 远端 version.json 支持两种键名:delta 或 delta_updates(兼容旧配置)。 """ import hashlib import json import os import subprocess import sys import threading import tkinter as tk from pathlib import Path from tkinter import messagebox from urllib.request import Request, urlopen from urllib.error import URLError, HTTPError from urllib.parse import urlparse try: import requests _HAS_REQUESTS = True except ImportError: _HAS_REQUESTS = False try: import bsdiff4 _HAS_BSDIFF = True except ImportError: _HAS_BSDIFF = False # 远端 version.json 地址 VERSION_JSON_URL = "http://172.18.180.94:3000/api/file?path=server_manager/build/output/version.json" REQUEST_TIMEOUT = 15 DOWNLOAD_TIMEOUT = 120 def clean_up_old_version(): """ 启动时调用:删除安装目录(或当前 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: 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)或 shell 脚本(Unix) temp_dir = os.environ.get("TEMP") or os.path.expandvars("%TEMP%") bat_path = os.path.join(temp_dir, "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)) if sys.platform == "win32": subprocess.Popen( ["cmd", "/c", bat_path], creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, shell=False, ) else: script = ( "while ! mv -f '%s' '%s' 2>/dev/null; do sleep 1; done; cd '%s' && exec '%s'" % (new_exe_path, current_exe_path, install_dir, current_exe_path) ) subprocess.Popen(["sh", "-c", script], shell=False) except Exception: pass os._exit(0) def _parse_version(v: str): """'x.y.z' -> (x, y, z)""" try: parts = v.strip().split(".") return ( int(parts[0]) if len(parts) > 0 else 0, int(parts[1]) if len(parts) > 1 else 0, int(parts[2]) if len(parts) > 2 else 0, ) except (ValueError, IndexError): return (0, 0, 0) def _version_less(a: str, b: str) -> bool: """True 表示 远端 b > 本地 a""" return _parse_version(a) < _parse_version(b) def _get_delta_map(manifest: dict) -> dict: """兼容 delta 与 delta_updates 两种键名""" d = manifest.get("delta") or manifest.get("delta_updates") or {} return d if isinstance(d, dict) else {} class SeamlessUpdater: """ 隐形增量更新:后台守护线程静默准备,就绪后主线程弹一次「重启以更新」, 用户确认后极速替换 main.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_full_path = None # TEMP 下 Setup.exe 路径(全量就绪) self._ready_remote_version = None self._lock = threading.Lock() def _fetch_manifest(self) -> dict: """请求远端 version.json,失败返回空 dict""" try: if _HAS_REQUESTS: r = requests.get( self.version_url, headers={"User-Agent": "ServerManager-Updater/1.0"}, timeout=REQUEST_TIMEOUT, ) r.raise_for_status() return r.json() if isinstance(r.json(), dict) else {} req = Request(self.version_url, headers={"User-Agent": "ServerManager-Updater/1.0"}) with urlopen(req, timeout=REQUEST_TIMEOUT) as resp: data = json.loads(resp.read().decode("utf-8")) return data if isinstance(data, dict) else {} except (URLError, HTTPError, OSError, json.JSONDecodeError, Exception): return {} def _download_to_path(self, url: str, dest_path: str) -> bool: """静默下载 url 到 dest_path,无 UI。""" dest_path = Path(dest_path) try: dest_path.parent.mkdir(parents=True, exist_ok=True) if _HAS_REQUESTS: r = requests.get( url, headers={"User-Agent": "ServerManager-Updater/1.0"}, stream=True, timeout=DOWNLOAD_TIMEOUT, ) r.raise_for_status() with open(dest_path, "wb") as f: for chunk in r.iter_content(chunk_size=65536): if chunk: f.write(chunk) else: req = Request(url, headers={"User-Agent": "ServerManager-Updater/1.0"}) with urlopen(req, timeout=DOWNLOAD_TIMEOUT) as resp: dest_path.write_bytes(resp.read()) return dest_path.is_file() except Exception: return False def _try_delta_path(self, manifest: dict) -> bool: """ 若存在当前版本的 delta:在 TEMP 静默下载 .patch -> bsdiff4 合成 main_new.exe -> 校验 SHA256。 仅针对 main.exe,不涉及 _internal。合成后删除 TEMP 中的 .patch。 成功则设置 self._ready_delta_path 并返回 True;任何一步失败返回 False。 """ delta_map = _get_delta_map(manifest) delta = delta_map.get(self.local_version) if not isinstance(delta, dict): return False patch_url = (delta.get("patch_url") or "").strip() new_exe_sha256 = (delta.get("new_exe_sha256") or "").strip().lower() if not patch_url or not new_exe_sha256 or len(new_exe_sha256) != 64: return False if not _HAS_BSDIFF or not getattr(sys, "frozen", False): return False temp_dir = os.environ.get("TEMP") or os.path.expandvars("%TEMP%") if not temp_dir or not os.path.isdir(temp_dir): return False patch_path = os.path.join(temp_dir, "ServerManager_update.patch") new_exe_path = os.path.join(temp_dir, "ServerManager_new.exe") if not self._download_to_path(patch_url, patch_path): return False if not os.path.isfile(patch_path): return False try: with open(sys.executable, "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() if got_sha != new_exe_sha256: return False with open(new_exe_path, "wb") as f: f.write(new_data) with self._lock: self._ready_delta_path = new_exe_path return True except Exception: return False finally: try: if os.path.isfile(patch_path): os.remove(patch_path) except Exception: pass def _try_full_path(self, manifest: dict) -> bool: """静默下载 full_installer_url 到 TEMP,成功则设置 _ready_full_path。""" full_url = ( (manifest.get("full_installer_url") or manifest.get("download_url") or "").strip() ) if not full_url: return False temp_dir = os.environ.get("TEMP") or os.path.expandvars("%TEMP%") if not temp_dir or not os.path.isdir(temp_dir): return False name = os.path.basename(urlparse(full_url).path) or "ServerManager_Setup.exe" local_path = os.path.join(temp_dir, name) if not self._download_to_path(full_url, local_path): return False if not os.path.isfile(local_path): return False with self._lock: self._ready_full_path = local_path return True def _worker(self): """后台线程:检测 -> 优先增量静默准备,失败则全量静默准备 -> 就绪后主线程弹窗""" manifest = self._fetch_manifest() if not manifest: return remote_version = (manifest.get("version") or "").strip() if not remote_version or not _version_less(self.local_version, remote_version): return with self._lock: self._ready_remote_version = remote_version self._ready_delta_path = None self._ready_full_path = None # 优先增量:静默下载 + 合成 + 校验 if self._try_delta_path(manifest): self.root.after(0, self._show_restart_dialog) return # 兜底:静默下载完整安装包 if self._try_full_path(manifest): self.root.after(0, self._show_restart_dialog) return def _show_restart_dialog(self): """主线程:仅当有 _ready_delta_path 或 _ready_full_path 时弹出「退出并重启」""" with self._lock: remote = self._ready_remote_version or "?" delta_path = self._ready_delta_path full_path = self._ready_full_path if not delta_path and not full_path: return msg = "新版本 v{} 已经准备就绪。点击「是」退出并重启以应用更新。".format(remote) if not messagebox.askyesno("更新就绪", msg, default="yes"): return if delta_path and os.path.isfile(delta_path): self._apply_delta_and_exit(delta_path) elif full_path and os.path.isfile(full_path): self._run_silent_install_and_exit(full_path) def _apply_delta_and_exit(self, temp_new_exe_path: str): """增量路径:优先调用独立进程外更新器 mini_updater.exe,否则回退到 .bat 重试。""" apply_update_and_restart(temp_new_exe_path, target_exe_name=os.path.basename(sys.executable)) def _run_silent_install_and_exit(self, setup_exe_path: str): """全量路径:静默参数启动 Setup.exe,主进程立即退出""" try: subprocess.Popen( [ setup_exe_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/FORCECLOSEAPPLICATIONS", ], shell=False, ) except Exception: pass os._exit(0) def start_background_worker(self): """启动后台守护线程(静默检测 + 静默准备),不阻塞主线程。""" t = threading.Thread(target=self._worker, daemon=True) t.start() # --------------------------------------------------------------------------- # entrypoints/tk_update_demo.py 调用示例(软件启动时调用) # --------------------------------------------------------------------------- """ # 在 entrypoints/tk_update_demo.py 中(Tkinter 入口): import tkinter as tk from scripts.update.tk_updater import SeamlessUpdater, VERSION_JSON_URL # 从 version.json 或常量读取本地版本号 LOCAL_VERSION = "1.1.3" # 需与 version.json / 安装包一致 def main(): root = tk.Tk() root.withdraw() # 若主界面不是 Tk,可先隐藏,仅用其 messagebox/after # 启动无感更新:后台静默准备,就绪后弹「重启以更新」 SeamlessUpdater(root, LOCAL_VERSION, VERSION_JSON_URL).start_background_worker() # 你的主界面(Tk 或其它) root.deiconify() root.mainloop() if __name__ == "__main__": main() """ # --------------------------------------------------------------------------- # 开发者:如何生成 .patch 文件(发版流程) # --------------------------------------------------------------------------- """ 【重要】采用 PyInstaller 文件夹模式 (-D)。增量仅针对 main.exe,不包含 _internal; 用户端替换时只覆盖 main.exe,绝不修改 _internal 或其它文件。 1. 打包:使用 PyInstaller 文件夹模式生成当前版本: pyinstaller -D -w your_main.py 得到 dist/your_main/main.exe(或 dist/main.exe)。 2. 保存上一版本的 main.exe(仅此单文件,无需 _internal): 将上一发版时的 main.exe 保存为 build/output/main_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() patch = bsdiff4.diff(old, new) open('build/output/patches/v1.0.2_to_v1.0.3.patch', 'wb').write(patch) 4. 计算新 exe 的 SHA256 并写入 version.json: import hashlib new_sha256 = hashlib.sha256(new).hexdigest().lower() # 将 new_sha256 填入 version.json 的 delta["1.0.2"].new_exe_sha256 5. 上传 build/output/patches/*.patch 与 version.json 到服务器;用户端即可通过增量路径静默更新。 """