优化程序
This commit is contained in:
1 parent
68e8eb91a7
commit
6df29c9254
27 files changed
+1974
-203
No files matched your search
+336
-96
@@ -1,141 +1,381 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
以“计划任务(最高权限)”方式执行静默覆盖更新。
|
||||
外部独立更新器 —— 主进程退出后完成真正的替换/重启。
|
||||
|
||||
工作流:
|
||||
- 主程序(普通权限)下载新 exe 到可写目录(建议 ProgramData)并写入 update_request.json
|
||||
- 计划任务以 SYSTEM/管理员身份运行本程序
|
||||
- 本程序读取请求文件 -> 等待主程序退出 -> 覆盖安装目录 exe -> 重启 -> 清理请求文件
|
||||
不随主进程死、不被主进程文件锁影响、失败可回滚。
|
||||
|
||||
用法(主程序 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
|
||||
|
||||
|
||||
APP_NAME = "CleanDesktopOrganizer"
|
||||
REQUEST_FILE = "update_request.json"
|
||||
LOG_FILE = "update.log"
|
||||
|
||||
|
||||
def _programdata_dir() -> str:
|
||||
base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData"
|
||||
return os.path.join(base, APP_NAME)
|
||||
|
||||
|
||||
def _request_path() -> str:
|
||||
return os.path.join(_programdata_dir(), REQUEST_FILE)
|
||||
|
||||
|
||||
def _log(msg: str):
|
||||
# ────────── 日志 ────────────────────────────────────────────
|
||||
def _log(msg: str) -> None:
|
||||
try:
|
||||
os.makedirs(_programdata_dir(), exist_ok=True)
|
||||
p = os.path.join(_programdata_dir(), LOG_FILE)
|
||||
p = os.path.join(tempfile.gettempdir(), LOG_FILE)
|
||||
with open(p, "a", encoding="utf-8") as f:
|
||||
f.write(msg.rstrip() + "\n")
|
||||
f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _read_request() -> dict:
|
||||
p = _request_path()
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _pid_exists(pid: int) -> bool:
|
||||
# ────────── 等待进程退出(Win32 精准版) ───────────────────
|
||||
def _wait_process_exit(pid: int, timeout_sec: float = 90.0) -> bool:
|
||||
"""用 OpenProcess + WaitForSingleObject 等进程退出。
|
||||
比 tasklist 轮询精准:能检测 zombie(已退出但句柄未释放)、
|
||||
能被进程句柄自动释放(不依赖命令行解析)。"""
|
||||
if pid <= 0:
|
||||
return False
|
||||
_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:
|
||||
# tasklist 不需要管理员权限,SYSTEM 下也可用
|
||||
out = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
).stdout
|
||||
return str(pid) in out
|
||||
except Exception:
|
||||
return False
|
||||
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 _copy_with_retries(src: str, dst: str, retries: int = 10) -> bool:
|
||||
for _ in range(max(1, retries)):
|
||||
# ────────── 文件操作(带重试 + 原子替换) ────────────────────
|
||||
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.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
if os.path.exists(dst):
|
||||
try:
|
||||
os.chmod(dst, 0o666)
|
||||
except Exception:
|
||||
pass
|
||||
# 用二进制流复制,避免权限/锁问题时直接抛错
|
||||
with open(src, "rb") as rf:
|
||||
data = rf.read()
|
||||
with open(dst, "wb") as wf:
|
||||
wf.write(data)
|
||||
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:
|
||||
time.sleep(0.8)
|
||||
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 main() -> int:
|
||||
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:
|
||||
req = _read_request()
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
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:
|
||||
_log("ERROR: cannot read request file")
|
||||
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
|
||||
|
||||
src = str(req.get("src") or "")
|
||||
dst = str(req.get("dst") or "")
|
||||
pid = int(req.get("pid") or 0)
|
||||
restart = bool(req.get("restart", True))
|
||||
# 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 not src or not dst:
|
||||
_log(f"ERROR: bad request src={src!r} dst={dst!r} pid={pid}")
|
||||
return 3
|
||||
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")
|
||||
|
||||
_log(f"START: pid={pid} src={src} dst={dst}")
|
||||
# 原子替换
|
||||
if not _atomic_replace(src, dst, backup=backup_exe):
|
||||
_log("FATAL: onefile replace failed, cannot continue")
|
||||
return 3
|
||||
|
||||
# 等待主程序退出,最多 60 秒
|
||||
waited = 0.0
|
||||
while _pid_exists(pid) and waited < 60.0:
|
||||
time.sleep(0.5)
|
||||
waited += 0.5
|
||||
# 清理遗留的 onedir 死目录
|
||||
_clean_onedir_dead_sibling(dst)
|
||||
|
||||
ok = _copy_with_retries(src, dst, retries=15)
|
||||
_log(f"COPY: ok={ok}")
|
||||
try:
|
||||
os.remove(src)
|
||||
except Exception:
|
||||
pass
|
||||
# 删备份(保留 1 个版本足够回滚)
|
||||
if os.path.exists(backup_exe):
|
||||
try:
|
||||
# 新 exe 启动正常后用户可以手动删,这里只删旧的 .bak
|
||||
os.remove(backup_exe)
|
||||
_log("backup cleaned")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 清理请求文件(成功/失败都尽量清掉,避免重复执行)
|
||||
try:
|
||||
os.remove(_request_path())
|
||||
except Exception:
|
||||
pass
|
||||
if args.restart:
|
||||
if not _launch_new_exe(dst):
|
||||
return 4
|
||||
|
||||
if ok and restart:
|
||||
elif mode == "zip":
|
||||
# ─── zip 整包模式 ───────────────────────────────────
|
||||
_log("mode: zip full-package")
|
||||
tmp_extract = tempfile.mkdtemp(prefix="niumasoft_update_")
|
||||
try:
|
||||
subprocess.Popen([dst], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW)
|
||||
_log("RESTART: started")
|
||||
except Exception:
|
||||
_log("RESTART: failed")
|
||||
pass
|
||||
return 0
|
||||
payload_root = _extract_zip_normalized(src, tmp_extract)
|
||||
|
||||
_log("DONE: failed")
|
||||
return 4
|
||||
# 确定旧目录和目标 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__":
|
||||
raise SystemExit(main())
|
||||
|
||||
try:
|
||||
sys.exit(main())
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception:
|
||||
_log(f"UNCAUGHT: {traceback.format_exc()}")
|
||||
sys.exit(99)
|
||||
Reference in new issue
Block a user