mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 16:00:43 +00:00
用户这一轮报了两个问题,都修了。
== 一、Win7 上语音播报不能用 ==
用户原话:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"
**原来只有一条路**:
powershell -NoProfile -WindowStyle Hidden -Command
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
这在 Win7 上有三个坑,任何一个都能让它彻底不出声:
1. **`powershell` 只按名字找**
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
精简版系统、或从服务/计划任务拉起时 PATH 里可能没有 ——
CreateProcess 按名字找不到时是**静默失败**的。
2. **Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`**
PS 3.0 起才按 UTF-16 处理。中文经 `-Command` 传进去容易乱码 ——
表现是念出来是乱码,或者干脆不念。
3. **`System.Speech` 需要 .NET Framework 3.0+**
而系统自带的 **SAPI5** 从 Windows XP 就有,**根本不需要 .NET**。
**改成多方案降级**,按"依赖越少越优先"排:
1. cscript + VBScript + SAPI5 <- Win98 起就有,不用装任何东西
2. wscript + VBScript + SAPI5 <- 上一条被安全软件拦时的备选
3. powershell + SAPI5 (COM) <- 绕开 .NET
4. powershell + System.Speech <- 原来的做法,改用绝对路径
5. powershell(PATH) + System.Speech <- 最原始的,兜底
一条通了**记住它**,以后直接用(不用每次从头试)。
**顺带修掉一个"骗人"的行为**:原来 `say()` 用 `Popen` 不等进程结束,
失败了也返回"已播报" —— 明明没出声却报成功。
现在等退出码,**全失败会明确报错并列出试过哪几条**。
代价是同步等待(念多久等多久),但"报成功其实没出声"比"慢一点"糟糕得多。
还加了 `_build/diag_tts.py`:在一台机器上把 5 条方案全试一遍并报告
哪条能用 —— 下次再有"某台机器上不行",跑一下就知道。
VBScript 的细节:**必须用 ANSI(mbcs) 写**,写成 UTF-8 的话
cscript 读出来中文是乱码(VBScript 的老规矩,很隐蔽)。
长文本还要分句 —— VBScript 单行有长度上限。
== 二、快捷栏按钮太小 + 4 格都要能换 ==
用户原话:"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点,
然后呢,这个每每个四格算换一下吧,第一个第二个第三个
第四个都能换东西"
* **按钮做大**:30dp -> 46dp(接近 Android 建议的最小可点区域 48),
字号 11 -> 14。30dp 在表盘上手指按上去一半在栏外。
**栏高做成常量 QUICK_BAR_DP,栏高 / 面板 topMargin / addView
三处用同一个值** —— 分三处写的话改一个忘一个,
表现是"内容被盖住"或"上面空一条"。
* **4 格全部可自定义**。用户最早说"其中有一个按钮不能改,
功能就是一键返回桌面",第一版就按"第 4 格写死 desktop"做的。
现在他改主意了 —— 所以 desktop 变成**普通可选动作**,
默认仍放第 4 格(习惯不用改),但想换也能换。
可选动作从 7 个变成 8 个。
**教训(写进测试注释了)**:别把"用户当时的一句话"当成永久约束
焊进结构里;真是约束的话,注释里要写清是什么时候的要求,
下次他改主意时才知道该动哪儿。
== 三、修一个"测试自己没擦干净"造成的假 bug ==
全量回归报:
[FAIL] 配对码正确写入 实际 '(旧的残留值)'
而 `(旧的残留值)` 是**上一次测试遗留**的配对码 —— 数据目录
`C:\ProgramData\ykxmz\data` 在卸载时**故意不删**(那里存着用户的
中转地址、配对码等,卸载不该删),所以机器上会留着上一轮的
settings.json。测试装的时候虽然传了 `/CODE=517284`,但环境没擦干净时
就可能读到旧值 —— 表现成"安装包有 bug"。
**修法**:`test_installer_cycle.py` 加一步"装前先清残留"
(正规卸载 -> 杀进程 -> 删安装目录 -> **删数据目录**)。
数据目录那一步是关键 —— 卸载器不删它。
== 四、其他 ==
* `docs/项目规矩.md` 第一节改成**按预案执行**:
预案一 = 做完测试没问题就上传;预案二 = 做完测试但不上传,直接给用户。
用户没说预案几时默认按预案一,并在交付时说明。
* 顺手把那份文档里的老域名 `relay.xxmxxs.top` 改成现在的
`yk.xxmxxs.top`(老服务器早就退役了)。
* 修掉自己造的重复定义:往 actions.py 插降级链时,
`list_voices` / `say` 各插了两份(pyflakes 报 redefinition)。
* 一次危险的误操作:`Path.write_text(..., newline='\n')` 会**先截断文件
再校验参数**,参数非法时文件已经空了 —— 我用它把
`_tests/test_quickbar.py` 清空了。已重写。
**以后不给 write_text 传 newline。**
== 验证 ==
全量回归(21 关)全部 PASS
新增关卡:3a13 手表快捷栏 / 3a14 语音播报降级
安装包周期 23 项全过(装 -> 验 -> 卸 -> 再验)
Java 编译通过(android-34, --release 8)
本机实测语音播报:cscript 那条通了(2.4 秒)
全部产物重建,Win7 兼容校验通过
275 lines
11 KiB
Python
275 lines
11 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 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:
|
||
"""把上一次安装/测试的残留清掉,让这一轮从零开始。
|
||
|
||
要清三样:
|
||
1. 先**卸载**(走正规卸载器,能清注册表和自启项)
|
||
2. 删安装目录
|
||
3. 删数据目录 —— 卸载器**故意不删**它(存着用户的配置),
|
||
所以必须手动删。**这一步就是修 `(旧的残留值)` 那个假 bug 的关键。**
|
||
|
||
每一步都 best-effort:清不掉也不该让测试崩,后面会有断言报出来。
|
||
"""
|
||
try:
|
||
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
||
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
||
if found and found.strip():
|
||
subprocess.run(["cmd", "/c", '"%s" /VERYSILENT /SUPPRESSMSGBOXES '
|
||
'/NORESTART' % found.strip()],
|
||
capture_output=True, timeout=180)
|
||
time.sleep(4)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
# 万一还有进程占着文件,先杀掉
|
||
try:
|
||
ps("Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||
"Stop-Process -Force -ErrorAction SilentlyContinue")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
time.sleep(1)
|
||
for target in (APP, DATA):
|
||
try:
|
||
subprocess.run(["cmd", "/c", 'rmdir /s /q "%s"' % target],
|
||
capture_output=True, timeout=120)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
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())
|