Files
ykxmz/_build/test_installer_cycle.py
xmz7891 1d368979aa 2.2.0:手表端连上后先到「远控 / 静默更新被控端」选择页
== 用户要求 ==

"安卓那个逻辑也要改一下,就是你输入配对码后,进入一个页面,
 页面有两个按钮,第一个按钮是远控,第二个按钮是静默更新被控端"

== 做成什么样 ==

新增 choicePanel,连接成功后先进这一页:

    已连接「电脑名」
    电脑端 2.1.5 → 可更新到 2.2.0

    [        远控        ]   <- 第一个按钮
    [   静默更新被控端    ]   <- 第二个按钮

按钮顺序按用户说的来(远控在前)。

页面顶上显示电脑名和**电脑端当前版本** —— 用户之前要求
"手表端连接那个电脑端也可以查看电脑端目前所处的版本",
这里是最自然的展示位置(不弹框,只显示一行)。

为什么不让它直接进控制页:老版本的被控端可能缺功能或有 bug,
"先更新再远控"是更合理的顺序 —— 但不强制,给用户自己选。

== 一个刻意不改的地方 ==

自动切到中转服务器**恢复**时的 `showControlPanel()` **没动** ——
用户本来就在远控页,中途弹一层选择页会打断他。
测试里专门断言了这一点,免得以后有人"顺手统一一下"改坏。

== 顺手修的几个问题 ==

1. **忘了定义 lastDeviceName** —— 写选择页时凭直觉用了这个变量,
   编译报"找不到符号"。补上字段并在连接回调里赋值。
   (教训:写代码时要用的变量,先确认它存在。)

2. **泄漏闸门拦下我自己** —— 这两天写的一批服务器诊断/配置脚本里
   硬编码了服务器 IP(26 处,5 个文件)。规则是 IP 不能进任何
   被跟踪的文件。已全部改成占位符,真实值仍从
   _build/.lightsail_host(已 gitignore)读。

3. **verify_all.ps1 删掉「锁屏时记 SKIP」的逻辑** ——
   用户明确要求删。那个好意带来一个更糟的后果:锁屏时几关根本不跑,
   回归"全绿"了但关键测试其实一跳而过。
   **"因为环境不满足所以不测"比"测了发现失败"更危险。**
   删掉之后果然暴露出 3 个 FAIL —— 屏幕解锁后重跑全部通过,
   确认是环境问题而不是代码问题。

== 服务器这边(不在这份提交里,但同一天做的)==

  * nginx 按用户要求重排:
      IP(80) 任何路径 -> 404(没东西)
      域名 / -> 官网;/zhongzhuanzhan -> 中转站设置
      9100 -> 同样的内容(等 AWS 开防火墙)
  * 删掉早年 Cloudflare Tunnel 的 systemd 残留
  * **补上 ACME 挑战例外** —— 这是最要紧的一条:
    80 端口原来把所有请求都 301 到 https,包括
    /.well-known/acme-challenge/,**续期时会失败**。
    而且它是延迟爆发的(最长 90 天后证书到期才暴露)。
  * 清理 1Panel 安装包残留(223MB)

== 验证 ==

    全量回归 21 关全部 PASS(零 FAIL,桌面可用状态)
    Java 编译通过(android-34, --release 8)
    623+ Python 文件语法通过
    泄漏闸门通过
    全部产物重建,Win7 兼容校验通过
2026-10-10 17:13:24 +08:00

300 lines
12 KiB
Python
Raw Permalink 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 -*-
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())