382 lines
15 KiB
Python
382 lines
15 KiB
Python
# -*- 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)
|