Files
niumasoftware/update_helper.py
T
2026-10-12 14:57:46 +08:00

382 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
外部独立更新器 —— 主进程退出后完成真正的替换/重启。
不随主进程死、不被主进程文件锁影响、失败可回滚。
用法(主程序 Popen 启动):
update_helper.exe --src <下载包路径> --dst <目标exe路径> --pid <当前PID> [--restart] [--mode auto|onefile|zip] [--keepbackup]
不依赖计划任务,普通用户权限即可覆盖自己的安装目录。
如果用户装在 C:\\Program Files 等受限目录,需配合计划任务提权(保留 update_helper.spec 打包为 exe 的能力)。
"""
from __future__ import annotations
import argparse
import ctypes
import ctypes.wintypes as wt
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import traceback
import zipfile
LOG_FILE = "update.log"
# ────────── 日志 ────────────────────────────────────────────
def _log(msg: str) -> None:
try:
p = os.path.join(tempfile.gettempdir(), LOG_FILE)
with open(p, "a", encoding="utf-8") as f:
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
except Exception:
pass
# ────────── 等待进程退出(Win32 精准版) ───────────────────
def _wait_process_exit(pid: int, timeout_sec: float = 90.0) -> bool:
"""用 OpenProcess + WaitForSingleObject 等进程退出。
比 tasklist 轮询精准:能检测 zombie(已退出但句柄未释放)、
能被进程句柄自动释放(不依赖命令行解析)。"""
if pid <= 0:
_log("wait: no pid, skip")
return True
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
SYNCHRONIZE = 0x00100000
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle = kernel32.OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
# OpenProcess 失败通常意味着进程已不存在(访问拒绝)
_log(f"wait: OpenProcess pid={pid} failed, assume gone")
return True
try:
waited = 0.0
step = 0.5
while True:
rc = kernel32.WaitForSingleObject(handle, int(step * 1000))
waited += step
if rc == 0:
_log(f"wait: pid={pid} exited after {waited:.1f}s")
return True
if waited >= timeout_sec:
_log(f"wait: pid={pid} TIMEOUT after {timeout_sec}s")
return False
finally:
kernel32.CloseHandle(handle)
# ────────── 文件操作(带重试 + 原子替换) ────────────────────
def _atomic_replace(src: str, dst: str, backup: str | None = None, retries: int = 12) -> bool:
"""原子替换:备份旧文件 → os.replace 覆盖 → 失败则恢复备份。"""
# 1. 备份旧文件
old_exists = os.path.exists(dst)
if old_exists and backup:
try:
os.replace(dst, backup)
_log(f"backup: {dst} -> {backup}")
except Exception as e:
_log(f"backup failed: {e}")
return False # 连备份都做不了 = 没权限
# 2. 尝试替换(os.replace 在 Windows 上对目标进程已退出的文件是原子的)
for i in range(max(1, retries)):
try:
os.replace(src, dst)
_log(f"replace: ok ({i} attempts)")
return True
except Exception as e:
_log(f"replace attempt {i + 1}/{retries} failed: {e}")
time.sleep(0.7)
# 3. 全部失败 → 恢复备份
if backup and os.path.exists(backup) and not os.path.exists(dst):
try:
os.replace(backup, dst)
_log("rollback: restored backup")
except Exception as e:
_log(f"rollback failed: {e}")
return False
def _clean_onedir_dead_sibling(target_exe: str) -> None:
"""onefile 覆盖 onedir 启动器后,同级遗留的 _internal/ 等死目录清理。"""
exe_dir = os.path.dirname(os.path.abspath(target_exe))
if not exe_dir:
return
for name in ("_internal", "__pycache__"):
dead = os.path.join(exe_dir, name)
if os.path.isdir(dead):
try:
shutil.rmtree(dead, ignore_errors=True)
_log(f"cleaned dead dir: {dead}")
except Exception as e:
_log(f"clean dead dir failed: {e}")
# ────────── zip 解压 + 路径归一 ─────────────────────────────
def _extract_zip_normalized(zip_path: str, dest_dir: str) -> str:
"""解压 zip 到 dest_dir,自动 strip 单一顶层目录(zip 内嵌套一层目录)。
返回实际 payload 根目录(可能是 dest_dir 本身,也可能是 strip 后的子目录)。"""
os.makedirs(dest_dir, exist_ok=True)
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(dest_dir)
# 检测是否所有文件都在同一顶层目录下
entries = set()
with zipfile.ZipFile(zip_path, "r") as zf:
for name in zf.namelist():
# strip 开头的 ./
parts = name.replace("\\", "/").lstrip("./").split("/")
if parts[0]:
entries.add(parts[0])
if len(entries) == 1:
top = next(iter(entries))
candidate = os.path.join(dest_dir, top)
if os.path.isdir(candidate):
_log(f"zip: strip top folder '{top}', payload={candidate}")
return candidate
_log(f"zip: no single top folder, payload={dest_dir}")
return dest_dir
# ────────── 目录整体替换(zip 模式) ──────────────────────────
def _replace_directory(old_dir: str, new_payload: str, keep_backup: bool = False) -> bool:
"""备份旧目录为 .bak.时间戳 → 移动新 payload 到旧目录位置 → 清理备份(默认保留最近 3 个)。"""
old_dir = os.path.abspath(old_dir)
if not os.path.isdir(old_dir):
_log(f"replace dir: old_dir {old_dir} not found, treat as fresh install")
os.makedirs(old_dir, exist_ok=True)
_copy_tree(new_payload, old_dir)
return True
parent = os.path.dirname(old_dir)
base = os.path.basename(old_dir)
ts = time.strftime("%Y%m%d-%H%M%S")
backup_dir = os.path.join(parent, f"{base}.bak.{ts}")
# 1. 备份旧目录
try:
os.rename(old_dir, backup_dir)
_log(f"backup dir: {old_dir} -> {backup_dir}")
except Exception as e:
_log(f"backup dir failed: {e}")
return False
# 2. 移动新 payload 到目标位置
try:
if os.path.isdir(new_payload):
# 直接 rename 最快,但可能跨盘符 → 用 shutil.move
moved = shutil.move(new_payload, old_dir)
_log(f"move: {new_payload} -> {moved}")
else:
_log(f"new_payload {new_payload} is not a dir")
os.rename(backup_dir, old_dir) # 恢复
return False
except Exception as e:
_log(f"move failed: {e}")
try:
os.rename(backup_dir, old_dir)
except Exception:
pass
return False
# 3. 清理旧备份(保留最近 3 个)
if not keep_backup:
_prune_old_backups(parent, base, keep=3)
return True
def _copy_tree(src: str, dst: str) -> None:
"""递归复制目录树,覆盖目标文件。"""
os.makedirs(dst, exist_ok=True)
for item in os.listdir(src):
s = os.path.join(src, item)
d = os.path.join(dst, item)
if os.path.isdir(s):
_copy_tree(s, d)
else:
shutil.copy2(s, d)
def _prune_old_backups(parent: str, base: str, keep: int = 3) -> None:
"""清理旧的 .bak.* 目录,只保留最近 keep 个。"""
try:
backups = sorted(
[d for d in os.listdir(parent) if d.startswith(f"{base}.bak.")],
reverse=True,
)
for old in backups[keep:]:
old_path = os.path.join(parent, old)
try:
shutil.rmtree(old_path, ignore_errors=True)
_log(f"prune backup: {old_path}")
except Exception as e:
_log(f"prune failed: {e}")
except Exception:
pass
# ────────── 启动新进程 ──────────────────────────────────────
def _launch_new_exe(exe_path: str) -> bool:
try:
# --from-update: 让新进程跳过单实例检测(旧 mutex 可能还没完全释放)
# CREATE_NEW_CONSOLE:新进程创建独立控制台(真正脱离 update_helper)
# CREATE_NO_WINDOW: 控制台窗口不显示
subprocess.Popen(
[exe_path, "--from-update"],
creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NO_WINDOW,
)
_log(f"launched: {exe_path} --from-update")
return True
except Exception as e:
_log(f"launch failed: {e}")
return False
# ────────── 包类型判断 ──────────────────────────────────────
def _detect_mode(src: str) -> str:
ext = os.path.splitext(src)[1].lower()
if ext == ".zip":
return "zip"
if ext in (".exe",):
return "onefile"
# 尝试打开 zip 看看
try:
with zipfile.ZipFile(src, "r"):
return "zip"
except Exception:
pass
return "onefile"
# ────────── 主流程 ───────────────────────────────────────────
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="外部独立更新器")
parser.add_argument("--src", required=True, help="下载包路径(onefile exe 或 zip)")
parser.add_argument("--dst", required=True, help="目标 exe 路径")
parser.add_argument("--pid", type=int, default=0, help="当前运行进程 PID,用于等待退出")
parser.add_argument("--restart", action="store_true", help="替换完成后启动新进程")
parser.add_argument("--mode", choices=["auto", "onefile", "zip"], default="auto")
parser.add_argument("--keepbackup", action="store_true", help="保留备份不清理")
parser.add_argument("--zip-root", default=None,
help="zip 包内的 exe 相对路径(如 niumasoftware/niumasoftware.exe),"
"不传则自动在解压结果中查找同名 exe")
args = parser.parse_args(argv)
_log(f"=== START args={json.dumps(vars(args))}")
src = os.path.abspath(args.src)
dst = os.path.abspath(args.dst)
mode = args.mode if args.mode != "auto" else _detect_mode(src)
if not os.path.exists(src):
_log(f"ERROR: src not found: {src}")
return 2
# 1. 等待主进程退出
if args.pid > 0:
if not _wait_process_exit(args.pid, timeout_sec=90):
_log("WARN: main process still alive after timeout, attempt replacement anyway")
if mode == "onefile":
# ─── onefile 模式 ────────────────────────────────────
_log("mode: onefile exe")
dst_dir = os.path.dirname(dst)
backup_exe = os.path.join(dst_dir, os.path.basename(dst) + ".bak")
# 原子替换
if not _atomic_replace(src, dst, backup=backup_exe):
_log("FATAL: onefile replace failed, cannot continue")
return 3
# 清理遗留的 onedir 死目录
_clean_onedir_dead_sibling(dst)
# 删备份(保留 1 个版本足够回滚)
if os.path.exists(backup_exe):
try:
# 新 exe 启动正常后用户可以手动删,这里只删旧的 .bak
os.remove(backup_exe)
_log("backup cleaned")
except Exception:
pass
if args.restart:
if not _launch_new_exe(dst):
return 4
elif mode == "zip":
# ─── zip 整包模式 ───────────────────────────────────
_log("mode: zip full-package")
tmp_extract = tempfile.mkdtemp(prefix="niumasoft_update_")
try:
payload_root = _extract_zip_normalized(src, tmp_extract)
# 确定旧目录和目标 exe 在旧目录里
old_dir = os.path.dirname(dst)
new_exe_in_payload = dst # 默认同名 exe 相对路径
if args.zip_root:
new_exe_in_payload = os.path.join(payload_root, args.zip_root)
if not os.path.exists(new_exe_in_payload):
_log(f"ERROR: --zip-root exe not found: {new_exe_in_payload}")
return 5
else:
# 自动在 payload 里找同名 exe
target_name = os.path.basename(dst)
found = None
for root, _, files in os.walk(payload_root):
if target_name in files:
found = os.path.join(root, target_name)
break
if not found:
_log(f"ERROR: cannot find {target_name!r} in payload {payload_root}")
return 5
new_exe_in_payload = found
_log(f"auto-found exe in payload: {new_exe_in_payload}")
# payload 根目录 = exe 所在目录的父级(可能就是 payload_root,也可能子级)
payload_exe_dir = os.path.dirname(new_exe_in_payload)
if os.path.abspath(payload_exe_dir) != os.path.abspath(payload_root):
_log(f"payload exe dir != payload root, using exe dir as new dir root")
new_payload = payload_exe_dir
else:
new_payload = payload_root
# 替换整个旧目录
ok = _replace_directory(old_dir, new_payload, keep_backup=args.keepbackup)
if not ok:
_log("FATAL: directory replace failed")
return 3
new_dst = os.path.join(old_dir, os.path.basename(dst))
if args.restart:
if not _launch_new_exe(new_dst):
return 4
finally:
# 清理临时解压目录
shutil.rmtree(tmp_extract, ignore_errors=True)
_log(f"cleaned temp: {tmp_extract}")
else:
_log(f"FATAL: unknown mode {mode}")
return 1
_log("=== DONE")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except SystemExit:
raise
except Exception:
_log(f"UNCAUGHT: {traceback.format_exc()}")
sys.exit(99)