优化程序

This commit is contained in:
hero920103 committed 2026-10-12 14:57:46 +08:00
1 parent 68e8eb91a7
commit 6df29c9254
27 files changed
+1974 -203

No files matched your search

+336 -96
View File
@@ -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)