155 lines
5.7 KiB
Python
155 lines
5.7 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
独立进程外更新器 (Out-of-Process Updater)
|
||
|
||
无第三方依赖(仅标准库 + 可选 ctypes 探测 PID),
|
||
打包为 mini_updater.exe 后置于安装目录,由主程序在“确认重启”后拉起。
|
||
负责:等待主进程退出 → 重命名替换 exe → 以正确 cwd 与 DETACHED_PROCESS 启动新进程。
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
|
||
# 仅标准库:PID 检测用 ctypes(Windows)或 os.kill(Unix)
|
||
if sys.platform == "win32":
|
||
import ctypes
|
||
try:
|
||
_kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
||
except Exception:
|
||
_kernel32 = None
|
||
else:
|
||
_kernel32 = None
|
||
|
||
|
||
def _log_error(install_dir: str, message: str, exc: Exception = None) -> None:
|
||
"""将错误写入安装目录下的 logs/update_error.log,便于排查。"""
|
||
try:
|
||
log_dir = os.path.join(install_dir, "logs")
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
log_path = os.path.join(log_dir, "update_error.log")
|
||
with open(log_path, "a", encoding="utf-8") as f:
|
||
from datetime import datetime
|
||
line = "[%s] %s" % (datetime.now().isoformat(), message)
|
||
if exc is not None:
|
||
line += " (%s)" % exc
|
||
f.write(line + "\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def is_process_alive(pid: int) -> bool:
|
||
"""
|
||
跨平台判断指定 PID 是否仍存活。
|
||
Windows: ctypes 调用 OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION);
|
||
Unix: os.kill(pid, 0)。
|
||
"""
|
||
if sys.platform == "win32" and _kernel32 is not None:
|
||
# PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||
handle = _kernel32.OpenProcess(0x1000, False, pid)
|
||
if handle is None or handle == 0:
|
||
return False
|
||
try:
|
||
_kernel32.CloseHandle(handle)
|
||
except Exception:
|
||
pass
|
||
return True
|
||
try:
|
||
os.kill(pid, 0)
|
||
return True
|
||
except (OSError, ProcessLookupError, PermissionError):
|
||
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")
|
||
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"
|
||
target_exe_full = os.path.join(install_dir, target_exe_name)
|
||
old_exe_full = target_exe_full + ".old"
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 1. 死神凝视:每隔 0.5 秒检测 --pid 是否存活,必须等到进程彻底消失
|
||
# -------------------------------------------------------------------------
|
||
while is_process_alive(args.pid):
|
||
time.sleep(0.5)
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 2. 障眼法替换 (The Swap)
|
||
# -------------------------------------------------------------------------
|
||
import shutil
|
||
try:
|
||
os.chdir(install_dir)
|
||
except Exception as e:
|
||
_log_error(install_dir, "切换到安装目录失败", e)
|
||
sys.exit(1)
|
||
|
||
# 若存在上次遗留的 .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.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)
|
||
try:
|
||
os.rename(old_exe_full, target_exe_full)
|
||
except Exception:
|
||
pass
|
||
sys.exit(1)
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 3. 干净的重生:Popen 新程序,强制 cwd、close_fds、DETACHED_PROCESS
|
||
# -------------------------------------------------------------------------
|
||
try:
|
||
creationflags = 0
|
||
if sys.platform == "win32":
|
||
# DETACHED_PROCESS = 0x00000008,不继承控制台与父进程句柄
|
||
creationflags = 0x00000008
|
||
p = subprocess.Popen(
|
||
[target_exe_full],
|
||
cwd=install_dir,
|
||
close_fds=True,
|
||
creationflags=creationflags,
|
||
shell=False,
|
||
)
|
||
# 不等待子进程,更新器功成身退
|
||
except Exception as e:
|
||
_log_error(install_dir, "拉起新主程序失败", e)
|
||
sys.exit(1)
|
||
|
||
# -------------------------------------------------------------------------
|
||
# 4. 功成身退
|
||
# -------------------------------------------------------------------------
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|