# -*- coding: utf-8 -*- r'''安装包完整验收:装 → 验 → 卸 → 再验。 用户要求"所有东西做完记得先测试能不能用",所以每一步都实测。 ⚠ 环境注意: 我这个 shell **不是管理员**,而安装器会提升。 所以所有"清理"动作都要交给**安装器/卸载器**自己做, 不能靠 PowerShell 杀进程或删 Program Files(会拒绝访问)。 补丁历史(这轮修的四个真 bug): 1. --uninstall 里 Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死" 2. --uninstall 里 input() 无 stdin 时永久阻塞 -> 控制面板卸载卡死 3. _kill_running 用 taskkill /IM ykxmz.exe 把自己也杀了 -> 后面的删自启/删数据全没执行 4. 安装器关不掉以管理员身份跑着的旧实例 -> "无法关闭应用程序" -> 安装回滚 -> 改成 PrepareToInstall 里自己 taskkill /F ''' from __future__ import annotations import json import re import shutil import subprocess import sys import time from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") def _newest_setup() -> Path: """自动找 dist 下最新的安装包。 ⚠ 这里原来是写死的 `ykxmz-setup-1.8.2.exe`。后果很隐蔽: 测试去装一个**根本不存在的旧版本包** -> 安装静默失败 -> 读到的还是上一次残留的状态 -> 报出一堆莫名其妙的失败 ("配对码实际是 xxx"、"版本显示 v1.8.2")。 版本号每次发版都在变,写死必然过期。 `bump_version_generic.py` 更新了别的测试,唯独漏了这个文件。 改成按版本号排序取最新的 —— 一劳永逸,以后不用再维护。 """ cands = [] for p in (ROOT / "dist").glob("ykxmz-setup-*.exe"): # 文件名里抠版本号,按数字段排序(不能按字符串,否则 1.9.0 > 2.1.4) m = re.search(r"ykxmz-setup-(\d+)\.(\d+)\.(\d+)\.exe$", p.name) if m: cands.append((tuple(int(x) for x in m.groups()), p)) if not cands: return ROOT / "dist" / "ykxmz-setup-不存在.exe" cands.sort(key=lambda t: t[0]) return cands[-1][1] SETUP = _newest_setup() APP = Path(r"C:\Program Files\ykxmz") DATA = Path(r"C:\ProgramData\ykxmz") EXE = APP / "ykxmz.exe" REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1") # ⚠ PowerShell 的注册表 provider 路径**必须带冒号**(HKCU:\...)。 # 写成 HKCU\... 不是合法路径,Get-ItemProperty 静默返回空, # 于是"开机自启"这一项永远误报失败。(这个坑我自己踩了一次。) RUN_KEY = r"HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" CODE = "517284" passed = failed = 0 def check(name, ok, detail=""): global passed, failed if ok: passed += 1 print(" [PASS] " + name) else: failed += 1 print(" [FAIL] " + name + " " + str(detail)) def ps(command: str, timeout: int = 300) -> str: r = subprocess.run( ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], capture_output=True, text=True, timeout=timeout, encoding="utf-8", errors="replace") return (r.stdout or "").strip() def run_bat(lines: list, timeout: int = 600) -> tuple: """把命令写进 .bat 再跑。 为什么不直接 subprocess.run(["cmd", "/c", "..."]): 引号会被 Python 和 cmd 来回吃掉,实测 0.0 秒就返回 exit=1。 写成 .bat 文件最稳,而且和学校实际的批量部署方式一致。 """ bat = ROOT / "_build" / "_run.bat" bat.write_text("\r\n".join(["@echo off"] + lines), encoding="gbk", errors="replace") start = time.perf_counter() r = subprocess.run(["cmd", "/c", str(bat)], capture_output=True, text=True, timeout=timeout, encoding="gbk", errors="replace") return r.returncode, time.perf_counter() - start, (r.stdout or "") def install() -> tuple: return run_bat([ '"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART ' '/LOG="%s" /DIR="C:\\Program Files\\ykxmz" ' '/CODE=%s /TASKS=autostart' % ( SETUP, ROOT / "_build" / "setup.log", CODE), ]) def _clean_previous() -> None: """把上一次安装/测试的残留清掉,让这一轮从零开始。 ⚠ **这个函数第一版是坏的**,记下来免得再犯: 第一版对卸载器和 rmdir 都用了 subprocess.run(["cmd", "/c", '"%s" ...' % path]) 这种**嵌套引号**的写法。而 `run_bat()` 上面那大段注释 就是在说这个坑:"引号会被 Python 和 cmd 来回吃掉, 实测 0.0 秒就返回 exit=1"。 于是"装前清理"实际上什么都没清 —— 目录还在、旧的 settings.json 还在(配对码是上一轮遗留的值),测试报: [FAIL] 安装目录已清 [FAIL] 数据目录已清 [FAIL] 配对码正确写入 实际 '(上一轮的旧值)' 看上去像"安装包有 bug",其实是**清理函数自己没跑通**。 现在: * 卸载器走 `run_bat()`(已经验证过的方式) * 删目录用 `shutil.rmtree` —— **完全不过 cmd**,没有引号问题, 而且失败时能拿到真实原因(权限 / 占用),不会静默吞掉 """ # 1) 走正规卸载器(顺带清注册表和开机自启) try: found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue " "| Select-Object -First 1 -ExpandProperty FullName" % APP) if found and found.strip(): run_bat(['"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' % found.strip()], timeout=240) time.sleep(4) except Exception as exc: # noqa: BLE001 print(" (卸载器这步跳过: %s)" % type(exc).__name__) # 2) 还有进程占着文件就先杀掉 try: ps("Get-Process ykxmz -ErrorAction SilentlyContinue | " "Stop-Process -Force -ErrorAction SilentlyContinue") except Exception: # noqa: BLE001 pass time.sleep(1) # 3) 直接删目录。**不过 cmd** —— shutil.rmtree 能报出为什么删不掉。 for target, label in ((APP, "安装目录"), (DATA, "数据目录")): if not target.exists(): continue problems = [] def _onerr(func, path, exc_info): # noqa: ANN001 problems.append("%s: %s" % (path, exc_info[1])) shutil.rmtree(str(target), onerror=_onerr) if target.exists(): print(" ⚠ %s 没清干净:%s" % (label, problems[:2])) else: print(" 清了 %s" % label) time.sleep(1) def uninstall() -> tuple: found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue " "| Select-Object -First 1 -ExpandProperty FullName" % APP) if not found: return -1, 0.0, "" return run_bat([ '"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' % found.strip(), ]) def main() -> int: check("安装包在", SETUP.is_file(), str(SETUP)) if not SETUP.is_file(): return 1 # ---------------- 装前先清干净 ---------------- # # ⚠ 这一步是必须的,不是保险起见。 # # 踩过的坑:测试报 `配对码正确写入 实际 '(旧的残留值)'`, # 而 `(旧的残留值)` 是**上一次测试遗留**的配对码 —— # 数据目录 `C:\ProgramData\ykxmz\data` 在卸载时**不会**被删掉 # (见 .iss 的说明:那里存着用户的中转地址、配对码等,卸载也不该删), # 所以机器上会留着上一轮的 settings.json。 # # 这次装的是 `/CODE=517284`,正常会覆盖 —— 但如果在**上一次 # 安装的进程还占着端口/文件**的时候装,`--code` 那一步可能失败, # 于是检查就读到了旧值。表现成"安装包有 bug",其实是测试 # **没把环境擦干净**。 # # 所以:装之前先把两个目录删掉,让测试从零开始。 print() print(" 零、清理上一次的残留") _clean_previous() check("安装目录已清", not APP.is_dir(), str(APP)) check("数据目录已清", not DATA.is_dir(), str(DATA)) check("没有残留进程", not ps("(Get-Process ykxmz -ErrorAction SilentlyContinue " "| Measure-Object).Count").strip().strip("0").strip()) # ---------------- 安装 ---------------- print() print(" 一、静默安装(配对码 %s)" % CODE) code, elapsed, _out = install() check("安装器退出码 0", code == 0, "exit=%s" % code) check("安装没卡死", elapsed < 120, "%.1f 秒" % elapsed) print(" %.1f 秒" % elapsed) time.sleep(8) print() print(" 二、文件与数据") check("ykxmz.exe 在", EXE.is_file()) check("_internal 在", (APP / "_internal").is_dir()) check("数据目录在", DATA.is_dir()) settings = DATA / "data" / "settings.json" check("settings.json 生成了", settings.is_file()) if settings.is_file(): saved = json.loads(settings.read_text(encoding="utf-8")) got = saved.get("pairing_code") check("配对码正确写入", got == CODE, "实际 %r" % got) acl = ps("(Get-Acl '%s').Access | Where-Object { $_.IdentityReference " "-like '*Users*' } | Select-Object -First 1 " "-ExpandProperty FileSystemRights" % (DATA / "data")) check("普通用户对数据目录可写", any(w in acl for w in ("Modify", "FullControl", "Write")), acl or "(读不到)") print() print(" 三、--status") status = ps("& '%s' --status" % EXE, timeout=120) check("--status 显示配对码", CODE in status, status[:160]) for line in status.splitlines()[:7]: print(" " + line.strip()) print() print(" 四、进程与端口") count = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | " "Measure-Object).Count") check("被控端在跑", count.strip().splitlines()[-1:] != ["0"], count) listen = ps("(Get-NetTCPConnection -State Listen -ErrorAction " "SilentlyContinue | Where-Object { $_.LocalPort -eq 8765 } | " "Measure-Object).Count") check("8765 在监听", listen.strip().splitlines()[-1:] != ["0"], listen) run_value = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction " "SilentlyContinue).ykxmz" % RUN_KEY) check("开机自启已设置", "ykxmz" in run_value.lower(), run_value or "(无)") # ---------------- 卸载 ---------------- print() print(" 五、静默卸载") code, elapsed, _out = uninstall() check("卸载器执行了", code != -1, "找不到卸载器") check("卸载没卡死", elapsed < 120, "%.1f 秒" % elapsed) print(" %.1f 秒" % elapsed) time.sleep(10) print() print(" 六、卸载是否干净") check("安装目录已删", not APP.is_dir(), "还剩: %s" % ", ".join(p.name for p in APP.iterdir()) if APP.is_dir() else "") check("数据目录已删", not DATA.is_dir()) check("注册表卸载项已清", REG not in ps("if (Test-Path '%s') " "{ 'exists' }" % REG)) left = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | " "Measure-Object).Count") check("进程已退出", left.strip().splitlines()[-1:] == ["0"], left) still = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction " "SilentlyContinue).ykxmz" % RUN_KEY) check("开机自启已清掉", not still, still or "") print() print("=" * 56) print(" 通过 %d 项,失败 %d 项" % (passed, failed)) return 0 if failed == 0 else 1 if __name__ == "__main__": sys.exit(main())