mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 12:30:44 +00:00
== 用户要求 ==
"安卓那个逻辑也要改一下,就是你输入配对码后,进入一个页面,
页面有两个按钮,第一个按钮是远控,第二个按钮是静默更新被控端"
== 做成什么样 ==
新增 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 兼容校验通过
300 lines
12 KiB
Python
300 lines
12 KiB
Python
# -*- 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())
|