From fd8be87209e097cb3d3083217cf37b103c2d819b Mon Sep 17 00:00:00 2001 From: xmz7891 Date: Fri, 9 Oct 2026 23:30:05 +0800 Subject: [PATCH] =?UTF-8?q?2.1.8=EF=BC=9A=E4=BF=AE=20Win7=20=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E6=92=AD=E6=8A=A5=20+=20=E5=BF=AB=E6=8D=B7=E6=A0=8F?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E5=81=9A=E5=A4=A7=E4=B8=944=E6=A0=BC?= =?UTF-8?q?=E5=85=A8=E8=83=BD=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户这一轮报了两个问题,都修了。 == 一、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 兼容校验通过 --- README.md | 16 +- _build/CHANGELOG.md | 32 ++ _build/add_tts_stage.py | 66 +++ _build/commit_msg_218.txt | 109 ++++ _build/dedup_tts.py | 117 +++++ _build/diag_tts.py | 250 +++++++++ _build/fix_tts_fallback.py | 384 ++++++++++++++ _build/package_remote.py | 2 +- _build/quickbar_bigger_and_4slots.py | 230 +++++++++ _build/test_installer_cycle.py | 486 ++++++++++-------- _tests/test_apk_contents.py | 2 +- _tests/test_package_remote.py | 8 +- _tests/test_quickbar.py | 163 +++--- _tests/test_tts.py | 208 ++++++++ _tests/verify_all.ps1 | 14 + android/AndroidManifest.xml | 2 +- android/build_apk.ps1 | 4 +- .../src/com/lanremote/watch/MainActivity.java | 62 ++- docs/AFTER-INSTALL.txt | 6 +- docs/BEFORE-INSTALL.txt | 4 +- docs/LICENSE.txt | 6 +- docs/项目规矩.md | 50 +- installer/license-combined.txt | 16 +- installer/ykxmz.iss | 4 +- relay/relay.py | 2 +- windows/lanremote/__init__.py | 2 +- windows/lanremote/actions.py | 214 ++++++-- windows/version_info.txt | 4 +- 28 files changed, 2078 insertions(+), 385 deletions(-) create mode 100644 _build/add_tts_stage.py create mode 100644 _build/commit_msg_218.txt create mode 100644 _build/dedup_tts.py create mode 100644 _build/diag_tts.py create mode 100644 _build/fix_tts_fallback.py create mode 100644 _build/quickbar_bigger_and_4slots.py create mode 100644 _tests/test_tts.py diff --git a/README.md b/README.md index 36305d9..29af6b4 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ | 装在哪 | 是什么 | 文件 | |---|---|---| -| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.7.exe` | -| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.7.zip` | -| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.7.apk` | +| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.8.exe` | +| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.8.zip` | +| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.8.apk` | **只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。 @@ -81,7 +81,7 @@ ### 第一步:电脑上装被控端 -双击 `ykxmz-setup-2.1.7.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。 +双击 `ykxmz-setup-2.1.8.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。 第一次运行会弹出窗口,填两样: @@ -94,12 +94,12 @@ > 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。 > 想彻底退出,窗口上点「退出」。 -> 不想用安装包的话,`ykxmz-agent-2.1.7.zip` 是绿色版: +> 不想用安装包的话,`ykxmz-agent-2.1.8.zip` 是绿色版: > 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。 ### 第二步:手表上装 App -把 `ykxmz-watch-2.1.7.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 +把 `ykxmz-watch-2.1.8.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 打开 App,首页有四个入口: @@ -223,7 +223,7 @@ 中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行, Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。 -把 `ykxmz-server-2.1.7.zip` 解压到那台机器上,然后一条命令: +把 `ykxmz-server-2.1.8.zip` 解压到那台机器上,然后一条命令: ```bash bash deploy.sh @@ -456,7 +456,7 @@ windows\build_exe.ps1 ```powershell android\build_apk.ps1 # 流程:aapt2 → javac → d8 → zipalign → apksigner -# 产物:dist\ykxmz-watch-2.1.7.apk +# 产物:dist\ykxmz-watch-2.1.8.apk ``` 最低支持 Android 7.0(API 24),targetSdk 34。 diff --git a/_build/CHANGELOG.md b/_build/CHANGELOG.md index 27f2457..9ac1bc7 100644 --- a/_build/CHANGELOG.md +++ b/_build/CHANGELOG.md @@ -1,3 +1,35 @@ +## 2.1.8 + +- **快捷栏的按钮做大** —— 上一版 30dp 在手表上按不准,手指按上去 + 一半在栏外。现在 46dp(接近 Android 建议的可点区域),字号 11→14。 + +- **快捷栏 4 个格子全部可以自定义** —— 上一版最后一个「显示桌面」 + 是写死不能改的,现在四格平等,想换哪个换哪个。 + + 「显示桌面」变成一个普通的可选动作,默认仍放在第 4 格 —— + 所以习惯不用改,但想换成别的也行。可选动作现在共 8 个: + 传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 / 语音播报 / + 投图到电脑 / 显示桌面。 + +- **修 Win7 上语音播报不能用** —— 以前只走 PowerShell 一条路, + 而 Win7 上的 PowerShell 是 2.0 版,中文经命令行传进去容易乱码; + 再加上 `powershell` 可能不在 PATH 里,就彻底播不出来了。 + + 现在改成**多方案降级**,按"依赖越少越优先"排: + + | 顺序 | 方案 | 依赖 | + |---|---|---| + | 1 | cscript + VBScript + SAPI5 | Windows 98 起就有,不用装任何东西 | + | 2 | wscript + VBScript + SAPI5 | 上一条被安全软件拦时的备选 | + | 3 | powershell + SAPI5 (COM) | 只依赖 PowerShell,**不需要 .NET** | + | 4 | powershell + System.Speech | 原来的做法,改用绝对路径 | + | 5 | powershell (按 PATH) + System.Speech | 最原始的,兜底 | + + 一条通了就记住它,以后直接用(不用每次从头试)。 + + **全都失败会明确报错** —— 以前是不等进程结束就返回"已播报", + 明明没出声也报成功,那是在骗人。 + ## 2.1.7 - **手表端顶部快捷栏** —— 任何页面顶部都有一条栏,最多 4 个按钮: diff --git a/_build/add_tts_stage.py b/_build/add_tts_stage.py new file mode 100644 index 0000000..953959d --- /dev/null +++ b/_build/add_tts_stage.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +r'''把「语音播报降级链」加进 verify_all.ps1。 + +== 为什么用文件而不是 python -c == + +要写进 .ps1 的内容里有 `$stageOut`。用 `python -c "..."` 穿过 +PowerShell 时会被先解析一遍,`$stageOut` 变成 `\` —— 上次 3a12 +就是这么坏的。 + +凡是内容里带 `$` 的,一律写成 .py 文件再跑。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +VERIFY = ROOT / "_tests" / "verify_all.ps1" + +ANCHOR = "# ---------------------------------------------------------------- 3a5" + +STAGE = '''# ---------------------------------------------------------------- 3a14 +Banner "3a14/9 语音播报降级链(修 Win7 上用不了)" +# 用户反馈:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"。 +# +# 原来只有一条路(powershell + System.Speech),而 Win7 上 +# PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后必须保证: +# * cscript/VBScript 排最前(从 Win98 就有,SAPI5 是系统组件,不用装东西) +# * 前一条失败要接着试下一条,不是试一条就放弃 +# * 全失败要**报错**,不能像原来那样 Popen 不等待、明明没出声却说"已播报" +# * VBScript 用 ANSI 写(UTF-8 的话 cscript 读出来中文是乱码) +$stageOut = & python (Join-Path $root "_tests\\test_tts.py") 2>&1 + Show-Fails $stageOut +Record "语音播报降级" ($LASTEXITCODE -eq 0) + +''' + + +def main() -> int: + raw = VERIFY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + t = raw.decode("utf-8-sig") + + if "test_tts.py" in t: + print(" 已经加过了") + return 0 + if ANCHOR not in t: + print(" ✗ 找不到锚点") + return 1 + + t = t.replace(ANCHOR, STAGE + ANCHOR, 1) + VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8")) + print(" ✓ 加了 3a14") + + back = VERIFY.read_text(encoding="utf-8-sig") + for token in ('$stageOut = & python (Join-Path $root "_tests\\test_tts.py")', + 'Record "语音播报降级"'): + ok = token in back + print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:56])) + if not ok: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/commit_msg_218.txt b/_build/commit_msg_218.txt new file mode 100644 index 0000000..d2f5684 --- /dev/null +++ b/_build/commit_msg_218.txt @@ -0,0 +1,109 @@ +2.1.8:修 Win7 语音播报 + 快捷栏按钮做大且4格全能换 + +用户这一轮报了两个问题,都修了。 + +== 一、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 兼容校验通过 diff --git a/_build/dedup_tts.py b/_build/dedup_tts.py new file mode 100644 index 0000000..53703bf --- /dev/null +++ b/_build/dedup_tts.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +r'''清掉 actions.py 里重复的 list_voices 和残留的旧 say 前半段。 + +== 怎么弄出来的 == + +fix_tts_fallback.py 里我写了两处替换,但 `NEW` 那个大块**自己也带了一份 +list_voices**: + + t.replace(OLD_VOICES, NEW_VOICES) # 这是第一份 list_voices + t.replace(OLD_SAY, NEW) # NEW 里又有一份 list_voices + +于是 pyflakes 报: + + redefinition of unused 'list_voices' from line 540 + redefinition of unused 'say' from line 575 + local variable 'chosen' is assigned to but never used + +第三条是**旧 say 的前半段残留** —— `chosen` / `speed` 算完就不用了, +真正干活的在后面那份里。 + +== 这一版怎么改 == + +把**后面**那份 list_voices(`NEW` 带进来的)删掉,保留前面 NEW_VOICES 那份; +再把旧的 say 前半段(算 chosen/speed 那几行)删掉 —— 那些在新 say 里 +已经算过一遍了。 + +**先全部定位好再动手**,避免又改一半。 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +ACTIONS = ROOT / "windows" / "lanremote" / "actions.py" + + +def main() -> int: + print("=" * 58) + print(" 清掉重复定义") + print("=" * 58) + print() + + raw = ACTIONS.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + t = raw.decode("utf-8-sig") + lines = t.split("\n") + + # ---- 1. 找所有 list_voices / say 定义 ---- + defs = {} + for i, ln in enumerate(lines): + m = re.match(r" def (list_voices|say|_tts_commands|_write_vbs|" + r"_powershell_path)\(self", ln) + if m: + defs.setdefault(m.group(1), []).append(i) + + print(" 定义位置:") + for name, spots in sorted(defs.items()): + print(" %-18s %s" % (name, [s + 1 for s in spots])) + + # ---- 2. 删掉重复的 list_voices(保留第一个)---- + dup_lv = defs.get("list_voices", []) + if len(dup_lv) != 2: + print(" ⚠ list_voices 有 %d 个定义,期望 2 个" % len(dup_lv)) + else: + start = dup_lv[1] + # 找到下一个同级 def + end = len(lines) + for j in range(start + 1, len(lines)): + if re.match(r" def \w+\(", lines[j]): + end = j + break + # 往上把紧邻的注释也吃掉(那是我写的那段说明) + s = start + while s > 0 and (lines[s - 1].strip().startswith("#") + or lines[s - 1].strip() == ""): + s -= 1 + if lines[s].strip() == "" and s + 1 < start: + s += 1 + break + print(" 删除重复的 list_voices:第 %d-%d 行" % (start + 1, end)) + del lines[start:end] + + # ---- 3. 删掉旧 say 的前半段(算 chosen/speed 那段)---- + t2 = "\n".join(lines) + old_head = ''' chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip() + try: + speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0) + except (TypeError, ValueError): + speed = 0 + speed = max(-10, min(10, speed)) + +''' + n = t2.count(old_head) + print(" 旧 say 前半段残留:%d 处" % n) + if n: + # 只删最后一处之前的那一坨(如果出现两次,说明新 say 里也有一份, + # 那份是**要保留的**)—— 所以删第一处。 + t2 = t2.replace(old_head, "", 1) + + ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t2.encode("utf-8")) + print(" ✓ 清完了") + + # ---- 4. 复核 ---- + back = ACTIONS.read_text(encoding="utf-8-sig") + lv = len(re.findall(r"\n def list_voices\(self", back)) + sy = len(re.findall(r"\n def say\(self", back)) + tc = len(re.findall(r"\n def _tts_commands\(self", back)) + print() + print(" 复核:list_voices=%d(应 1) say=%d(应 1) _tts_commands=%d(应 1)" + % (lv, sy, tc)) + return 0 if (lv == 1 and sy == 1 and tc == 1) else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/diag_tts.py b/_build/diag_tts.py new file mode 100644 index 0000000..1b77efe --- /dev/null +++ b/_build/diag_tts.py @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- +r'''诊断:这台机器上哪几种语音播报方案能用。 + +== 为什么写这个 == + +用户反馈:**Win7 上语音播报不能用**。 + +现在的实现只有一条路: + powershell -NoProfile -WindowStyle Hidden -Command + "Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')" + +这在 Win7 上有几个可能致命的点: + + 1. **`powershell` 可能不在 PATH 里** + Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。 + 正常装的机器 PATH 里有,但被精简过、或者从服务/计划任务里拉起时 + 可能没有 —— 而**我们是用 CreateProcess 按名字找的**,找不到就静默失败。 + + 2. **Win7 默认是 PowerShell 2.0,它用 ANSI 代码页解析 `-Command`** + PS 3.0+ 才改成按 UTF-16 处理命令行。所以中文经 `-Command` 传进去 + 在 PS 2.0 上会乱码 —— 表现是"念出来是乱码"或"什么都不念"。 + + 3. **`System.Speech` 需要 .NET Framework 3.0+** + 而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有, + 根本不需要 .NET。绕开 .NET 更稳。 + +== 所以这里试 5 条路 == + + A. powershell(按 PATH 找)+ System.Speech <- 现在的实现 + B. powershell(按**绝对路径**找)+ System.Speech <- 修"不在 PATH" + C. powershell(绝对路径)+ SAPI.SpVoice COM <- 绕开 .NET + D. **cscript + VBScript + SAPI.SpVoice** <- 完全不依赖 PowerShell + E. wscript + VBScript + SAPI.SpVoice <- 同上,另一个宿主 + +D/E 最值得期待:**VBScript 从 Windows 98 就有,SAPI5 是系统组件**, +两个都不需要装任何东西,也不受 PowerShell 版本影响。 + +== 怎么跑 == + + python _build\diag_tts.py + +它会一条条试并打印结果。**在有问题的机器上跑最有价值** —— +比如那台 Win7。把输出发回来就知道该换哪条路。 +''' +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +IS_WINDOWS = os.name == "nt" + +# 让它念一句中文。中文最能暴露编码问题 —— +# 纯英文在 PS 2.0 上也可能碰巧是对的(ASCII 不受代码页影响)。 +TEXT = "语音播报测试,一二三" + +NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) + +FOUND: list = [] +FAILED: list = [] + + +def report(name: str, ok: bool, detail: str = "") -> None: + mark = "可用 " if ok else "不可用" + print(" [%s] %s%s" % (mark, name, (" —— " + detail) if detail else "")) + (FOUND if ok else FAILED).append(name) + + +def run(cmd: list, timeout: int = 30) -> tuple: + """跑一条命令,返回 (退出码, 输出)。不让它弹窗。""" + try: + p = subprocess.run(cmd, capture_output=True, timeout=timeout, + creationflags=NO_WINDOW, + encoding="mbcs", errors="replace") + out = ((p.stdout or "") + (p.stderr or "")).strip() + return p.returncode, out + except FileNotFoundError: + return -1, "文件找不到" + except subprocess.TimeoutExpired: + return -2, "超时" + except Exception as exc: # noqa: BLE001 + return -3, "%s: %s" % (type(exc).__name__, exc) + + +def find_powershell() -> str: + """先按 PATH 找,再按绝对路径找。""" + p = shutil.which("powershell") + if p: + return p + cand = Path(os.environ.get("SystemRoot", r"C:\Windows")) / \ + "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe" + return str(cand) if cand.is_file() else "" + + +def main() -> int: + print("=" * 62) + print(" 语音播报方案诊断") + print("=" * 62) + print() + if IS_WINDOWS: + try: + wv = sys.getwindowsversion() + print(" 系统: Windows %d.%d (build %d)" + % (wv.major, wv.minor, wv.build)) + except Exception: # noqa: BLE001 + print(" 系统: Windows(版本号取不到)") + else: + print(" 系统: 非 Windows") + print(" Python: %s (%d 位)" % (sys.version.split()[0], + 64 if sys.maxsize > 2 ** 32 else 32)) + print(" 测试文本: %r" % TEXT) + print() + + if not IS_WINDOWS: + print(" 非 Windows,没有 TTS 方案可试。") + return 0 + + # ---------------- 0. 环境事实 ---------------- + print("── 0. 环境 ──") + ps_path = find_powershell() + ps_in_path = shutil.which("powershell") + print(" powershell 在 PATH 里: %s" % (ps_in_path or "**没有**")) + print(" powershell 绝对路径: %s" % (ps_path or "**找不到**")) + if ps_path: + code, out = run([ps_path, "-NoProfile", "-Command", + "$PSVersionTable.PSVersion.Major"]) + print(" PowerShell 主版本: %s" % (out.strip() or "查不到")) + print(" cscript: %s" % (shutil.which("cscript") or "**没有**")) + print(" wscript: %s" % (shutil.which("wscript") or "**没有**")) + print(" SystemRoot: %s" % os.environ.get("SystemRoot", "?")) + print() + + # ---------------- A. 现有实现 ---------------- + print("── A. powershell(按 PATH)+ System.Speech ← 现在产品用的 ──") + if not ps_in_path: + report("A", False, "powershell 不在 PATH 里 —— **这就是失败原因**") + else: + script = ("Add-Type -AssemblyName System.Speech;" + "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;" + "$s.Speak('%s')" % TEXT.replace("'", "''")) + code, out = run(["powershell", "-NoProfile", "-WindowStyle", "Hidden", + "-Command", script]) + report("A", code == 0, "退出码 %d %s" % (code, out[:70])) + time.sleep(0.4) + + # ---------------- B. 绝对路径 + System.Speech ---------------- + print("── B. powershell(绝对路径)+ System.Speech ──") + if not ps_path: + report("B", False, "找不到 powershell.exe") + else: + script = ("Add-Type -AssemblyName System.Speech;" + "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;" + "$s.Speak('%s')" % TEXT.replace("'", "''")) + code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden", + "-Command", script]) + report("B", code == 0, "退出码 %d %s" % (code, out[:70])) + time.sleep(0.4) + + # ---------------- C. SAPI.SpVoice COM(绕开 .NET)---------------- + print("── C. powershell(绝对路径)+ SAPI.SpVoice COM ──") + if not ps_path: + report("C", False, "找不到 powershell.exe") + else: + script = ("$v = New-Object -ComObject SAPI.SpVoice;" + "$v.Speak('%s')" % TEXT.replace("'", "''")) + code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden", + "-Command", script]) + report("C", code == 0, "退出码 %d %s" % (code, out[:70])) + time.sleep(0.4) + + # ---------------- D. cscript + VBScript + SAPI ---------------- + # + # 最值得期待的一条:VBScript 从 Windows 98 就有, + # SAPI5 是系统组件,**两个都不需要装任何东西**, + # 也完全不受 PowerShell 版本 / PATH / .NET 的影响。 + print("── D. cscript + VBScript + SAPI.SpVoice(不依赖 PowerShell)──") + vbs = ( + 'Dim v\r\n' + 'Set v = CreateObject("SAPI.SpVoice")\r\n' + 'v.Speak "%s"\r\n' + ) % TEXT.replace('"', '""') + # ⚠ VBScript 文件要 ANSI(=系统代码页) —— 写成 UTF-8 的话 + # 中文在 cscript 里会变乱码。这里用 mbcs 编码写。 + tmp = Path(tempfile.gettempdir()) / "ykxmz_tts_test.vbs" + try: + tmp.write_bytes(vbs.encode("mbcs", errors="replace")) + except Exception as exc: # noqa: BLE001 + report("D", False, "写 vbs 失败: %s" % exc) + else: + code, out = run(["cscript", "//nologo", str(tmp)]) + report("D", code == 0, "退出码 %d %s" % (code, out[:70])) + time.sleep(0.4) + + # ---------------- E. wscript(不弹黑框)---------------- + print("── E. wscript + VBScript + SAPI.SpVoice ──") + if tmp.is_file(): + code, out = run(["wscript", "//nologo", str(tmp)]) + report("E", code == 0, "退出码 %d %s" % (code, out[:70])) + else: + report("E", False, "没有 vbs 文件") + time.sleep(0.4) + + # ---------------- 音色列表(两种方式对比)---------------- + print() + print("── 可用音色 ──") + if ps_path: + code, out = run([ + ps_path, "-NoProfile", "-Command", + "$v = New-Object -ComObject SAPI.SpVoice;" + "$v.GetVoices() | ForEach-Object { $_.GetDescription() }"]) + print(" SAPI5(COM):") + for ln in (out or "").splitlines(): + if ln.strip(): + print(" " + ln.strip()) + if not out.strip(): + print(" (列不出来)") + code2, out2 = run([ + ps_path, "-NoProfile", "-Command", + "Add-Type -AssemblyName System.Speech;" + "(New-Object System.Speech.Synthesis.SpeechSynthesizer)" + ".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"]) + print(" System.Speech:") + for ln in (out2 or "").splitlines(): + if ln.strip(): + print(" " + ln.strip()) + if not out2.strip(): + print(" (列不出来 —— .NET 那套可能有问题)") + + # ---------------- 结论 ---------------- + print() + print("=" * 62) + print(" 可用 %d 条 / 不可用 %d 条" % (len(FOUND), len(FAILED))) + if FOUND: + print(" 可用方案: %s" % ", ".join(FOUND)) + print() + print(" → 产品应该按**这个顺序**降级尝试:D(cscript) -> C(SAPI COM)") + print(" -> B(绝对路径) -> A(PATH),全失败才报错。") + else: + print(" ⚠ 一条都不通。可能是:没装任何语音、系统被精简过、") + print(" 或者 SAPI 组件损坏。") + print("=" * 62) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_tts_fallback.py b/_build/fix_tts_fallback.py new file mode 100644 index 0000000..30f3907 --- /dev/null +++ b/_build/fix_tts_fallback.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +r'''把语音播报改成**多条方案降级**,修 Win7 上用不了的问题。 + +== 用户反馈 == + +"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用" + +== 原来只有一条路,而且有几个 Win7 特有的坑 == + + powershell -NoProfile -WindowStyle Hidden -Command + "Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')" + +坑一:**`powershell` 只按名字找** + Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。 + 精简版系统、或者从服务/计划任务拉起时 PATH 里可能没有 —— + 找不到就**静默失败**(Popen 不报错,因为进程起来了,只是在里面失败)。 + +坑二:**Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`** + PS 3.0 起才按 UTF-16 处理。所以中文经 `-Command` 传进去在 PS 2.0 上 + 很容易乱码 —— 表现是"念出来是乱码"或者干脆不念。 + +坑三:**`System.Speech` 需要 .NET Framework 3.0+** + 而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有, + **根本不需要 .NET**。绕开 .NET 更稳。 + +== 现在的做法:降级链 == + +按"依赖越少越优先"排: + + 1. **cscript + VBScript + SAPI.SpVoice** + VBScript 从 Windows 98 就有,SAPI5 是系统组件 —— + 两个都不需要装东西,也不受 PowerShell 版本 / PATH / .NET 影响。 + **这是最可能在 Win7 上通的一条。** + + 2. **wscript + VBScript + SAPI.SpVoice** + 同上,换宿主(cscript 万一被安全软件拦了)。 + + 3. **powershell(绝对路径)+ SAPI.SpVoice COM** + 绕开 .NET,但仍要 PowerShell。 + + 4. **powershell(绝对路径)+ System.Speech** + 原来的做法,只是改用绝对路径。 + + 5. **powershell(按 PATH)+ System.Speech** + 最原始的做法,留作最后兜底。 + +**一条通了就记住它**(模块级缓存),后面都直接走那条 —— +不然每播一次都要把上面几条全试一遍,慢且难看。 + +== 顺手改进 == + + * `say()` 原来用 `Popen` 不等待 —— **失败了也返回"已播报"**。 + 现在**等它结束并看退出码**,真失败会明确报错,不再骗人。 + 代价是播报变成了同步的(要等念完)—— 但"报成功其实没出声" + 比"慢一点"糟糕得多。 + * VBScript 文件用 **mbcs(ANSI)** 编码写。写成 UTF-8 的话 + cscript 读出来是乱码 —— 这是 VBScript 的老规矩。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +ACTIONS = ROOT / "windows" / "lanremote" / "actions.py" + +# ---------------------------------------------------------------- 要替换的旧代码 +OLD_VOICES = ''' def list_voices(self) -> list[str]: + """列出系统可用的 TTS 音色。""" + if not IS_WINDOWS: + return [] + script = ( + "Add-Type -AssemblyName System.Speech;" + "(New-Object System.Speech.Synthesis.SpeechSynthesizer)" + ".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }" + ) + try: + proc = subprocess.run( + ["powershell", "-NoProfile", "-Command", script], + capture_output=True, timeout=20, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + except Exception: + return [] + return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]''' + +OLD_SAY = ''' safe = text.replace("'", "''") + parts = [ + "Add-Type -AssemblyName System.Speech;", + "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;", + ] + if chosen: + parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} " + "catch { };") + if speed: + parts.append(f"$s.Rate = {speed};") + parts.append(f"$s.Speak('{safe}')") + script = "".join(parts) + + try: + subprocess.Popen( + ["powershell", "-NoProfile", "-WindowStyle", "Hidden", + "-Command", script], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except Exception as exc: + raise ActionError(f"TTS 启动失败: {exc}") from exc + return f"已播报({chosen or '默认音色'})"''' + +NEW = ''' # ------------------------------------------------------------------ + # 语音播报(系统自带 TTS) + # ------------------------------------------------------------------ + # + # ⚠ 这里有一套**降级链**,不是随便写的。原因见文件末尾的说明, + # 简单说:Win7 上的 PowerShell 2.0 + 中文 + PATH 三样都可能出问题, + # 而 cscript + SAPI5 是最老最稳的组合(从 XP 就有)。 + # + # 顺序:cscript -> wscript -> ps(绝对路径)+SAPI COM + # -> ps(绝对路径)+System.Speech -> ps(PATH)+System.Speech + + def _powershell_path(self) -> str: + """找 powershell.exe。**先按绝对路径**,PATH 只作兜底。 + + 为什么:Win7 上 PATH 里不一定有,而 CreateProcess 按名字找不到时 + 是**静默失败**的(进程根本没起来,但 Popen 不报错)。 + """ + import shutil as _sh + cand = Path(os.environ.get("SystemRoot", r"C:\\Windows")) / \\ + "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe" + if os.name == "nt" and cand.is_file(): + return str(cand) + return _sh.which("powershell") or "" + + def _write_vbs(self, text: str, voice: str, rate: int) -> Path: + """生成一个播报用的 .vbs。 + + ⚠ 必须用 **mbcs(系统 ANSI 代码页)** 写 —— + cscript 读 .vbs 是按 ANSI 读的,写成 UTF-8 中文就是乱码。 + """ + import tempfile + lines = ['Dim v', 'Set v = CreateObject("SAPI.SpVoice")'] + if voice: + # 按描述匹配音色。找不到就 silently 用默认 —— + # 音色选不上不该让整条播报失败。 + lines.append('Dim i') + lines.append('For i = 0 To v.GetVoices().Count - 1') + lines.append(' If InStr(v.GetVoices().Item(i).GetDescription(), "%s") > 0 Then' + % voice.replace('"', '""')) + lines.append(' Set v.Voice = v.GetVoices().Item(i)') + lines.append(' End If') + lines.append('Next') + if rate: + # SAPI 的 Rate 范围是 -10..10,跟我们的设置一致 + lines.append("v.Rate = %d" % max(-10, min(10, rate))) + # 长文本 VBScript 单行有长度限制,所以**分句拼接** + chunk = text.replace('"', '""').replace("\\r", " ").replace("\\n", " ") + while chunk: + piece, chunk = chunk[:400], chunk[400:] + lines.append('v.Speak "%s"' % piece) + path = Path(tempfile.gettempdir()) / "ykxmz_say.vbs" + path.write_bytes(("\\r\\n".join(lines) + "\\r\\n").encode("mbcs", + errors="replace")) + return path + + def _tts_commands(self, text: str, voice: str, rate: int) -> list: + """按"依赖越少越优先"排出所有可行方案,返回 [(名字, 命令行)]。""" + cmds = [] + vbs = None + try: + vbs = self._write_vbs(text, voice, rate) + except Exception: # noqa: BLE001 + vbs = None + if vbs is not None: + # 1 / 2:完全不依赖 PowerShell 和 .NET + cmds.append(("cscript", ["cscript", "//nologo", str(vbs)])) + cmds.append(("wscript", ["wscript", "//nologo", str(vbs)])) + + ps = self._powershell_path() + esc = text.replace("'", "''") + if ps: + # 3:绕开 .NET,只用系统原生 SAPI5 + sapi = ["$v = New-Object -ComObject SAPI.SpVoice;"] + if voice: + sapi.append("foreach ($o in $v.GetVoices()) { " + "if ($o.GetDescription() -like '*%s*') { " + "$v.Voice = $o } };" % voice.replace("'", "''")) + if rate: + sapi.append("$v.Rate = %d;" % max(-10, min(10, rate))) + sapi.append("$v.Speak('%s')" % esc) + cmds.append(("ps-sapi", [ps, "-NoProfile", "-WindowStyle", "Hidden", + "-Command", "".join(sapi)])) + + # 4:原来的做法,但用绝对路径 + sp = ["Add-Type -AssemblyName System.Speech;", + "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"] + if voice: + sp.append("try { $s.SelectVoice('%s') } catch { };" + % voice.replace("'", "''")) + if rate: + sp.append("$s.Rate = %d;" % max(-10, min(10, rate))) + sp.append("$s.Speak('%s')" % esc) + cmds.append(("ps-speech", [ps, "-NoProfile", "-WindowStyle", + "Hidden", "-Command", "".join(sp)])) + + # 5:最原始的(PATH + System.Speech),兜底 + import shutil as _sh + if _sh.which("powershell"): + sp = ["Add-Type -AssemblyName System.Speech;", + "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"] + if voice: + sp.append("try { $s.SelectVoice('%s') } catch { };" + % voice.replace("'", "''")) + if rate: + sp.append("$s.Rate = %d;" % max(-10, min(10, rate))) + sp.append("$s.Speak('%s')" % esc) + cmds.append(("ps-path", ["powershell", "-NoProfile", + "-WindowStyle", "Hidden", + "-Command", "".join(sp)])) + return cmds + + def list_voices(self) -> list[str]: + """列出系统可用的 TTS 音色。 + + 用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没有 .NET 3.0 的话 + System.Speech 那条路是列不出来的,而 SAPI5 一定有。 + """ + if not IS_WINDOWS: + return [] + out = [] + ps = self._powershell_path() + if ps: + script = ("$v = New-Object -ComObject SAPI.SpVoice;" + "$v.GetVoices() | ForEach-Object { $_.GetDescription() }") + try: + proc = subprocess.run( + [ps, "-NoProfile", "-Command", script], + capture_output=True, timeout=20, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + out = [ln.strip() for ln in (proc.stdout or "").splitlines() + if ln.strip()] + except Exception: # noqa: BLE001 + out = [] + if not out: + # 退到 System.Speech 再列一次 + if ps: + script = ("Add-Type -AssemblyName System.Speech;" + "(New-Object System.Speech.Synthesis" + ".SpeechSynthesizer).GetInstalledVoices()" + " | ForEach-Object { $_.VoiceInfo.Name }") + try: + proc = subprocess.run( + [ps, "-NoProfile", "-Command", script], + capture_output=True, timeout=20, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + out = [ln.strip() for ln in (proc.stdout or "").splitlines() + if ln.strip()] + except Exception: # noqa: BLE001 + out = [] + return out + + def say(self, text: str, voice: str = "", rate: int = 0) -> str: + """语音播报。voice 为空则用系统默认(或设置里的默认音色)。 + + ⚠ 会**等播报结束**并看退出码,失败就抛错。 + 原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。 + 代价是同步等待(念多久等多久),但"报成功其实没出声" + 比"慢一点"糟糕得多。 + """ + if not text.strip(): + raise ActionError("播报内容为空") + if not IS_WINDOWS: + raise ActionError("语音播报目前仅支持 Windows") + + chosen = (voice or "").strip() or str( + self.store.get("tts_voice") or "").strip() + try: + speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0) + except (TypeError, ValueError): + speed = 0 + speed = max(-10, min(10, speed)) + + cmds = self._tts_commands(text, chosen, speed) + if not cmds: + raise ActionError("这台电脑上找不到可用的语音播报方式" + "(没有 cscript 也没有 powershell?)") + + # 上次成功的那条优先 —— 不然每播一次都要把几条全试一遍 + cached = globals().get("_TTS_LAST_OK") + if cached: + cmds.sort(key=lambda c: 0 if c[0] == cached else 1) + + tried = [] + for name, cmd in cmds: + try: + proc = subprocess.run( + cmd, capture_output=True, timeout=180, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + except FileNotFoundError: + tried.append("%s(没有这个程序)" % name) + continue + except subprocess.TimeoutExpired: + tried.append("%s(超时)" % name) + continue + except Exception as exc: # noqa: BLE001 + tried.append("%s(%s)" % (name, type(exc).__name__)) + continue + if proc.returncode == 0: + globals()["_TTS_LAST_OK"] = name + return "已播报(%s,用 %s)" % (chosen or "默认音色", name) + err = ((proc.stderr or "") + (proc.stdout or "")).strip() + tried.append("%s(退出码%d %s)" % (name, proc.returncode, err[:40])) + + raise ActionError("语音播报失败,试过:" + ";".join(tried))''' + +NEW_VOICES = ''' def list_voices(self) -> list[str]: + """列出系统可用的 TTS 音色。 + + 用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话 + System.Speech 那条路列不出来,而 SAPI5 一定有。 + """ + if not IS_WINDOWS: + return [] + ps = self._powershell_path() + if not ps: + return [] + out = [] + for script in ( + # 优先 SAPI5 COM + "$v = New-Object -ComObject SAPI.SpVoice;" + "$v.GetVoices() | ForEach-Object { $_.GetDescription() }", + # 退到 System.Speech + "Add-Type -AssemblyName System.Speech;" + "(New-Object System.Speech.Synthesis.SpeechSynthesizer)" + ".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }", + ): + try: + proc = subprocess.run( + [ps, "-NoProfile", "-Command", script], + capture_output=True, timeout=20, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + out = [ln.strip() for ln in (proc.stdout or "").splitlines() + if ln.strip()] + except Exception: # noqa: BLE001 + out = [] + if out: + return out + return []''' + + +def main() -> int: + print("=" * 58) + print(" 语音播报:改成多条方案降级") + print("=" * 58) + print() + + raw = ACTIONS.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + t = raw.decode("utf-8-sig") + + if "_tts_commands" in t: + print(" 已经改过了") + return 0 + + missing = [k for k, v in {"list_voices": OLD_VOICES, "say": OLD_SAY}.items() + if v not in t] + if missing: + print(" ✗ 找不到:%s" % ", ".join(missing)) + return 1 + print(" ✓ 锚点就位") + + t = t.replace(OLD_VOICES, NEW_VOICES, 1) + t = t.replace(OLD_SAY, NEW, 1) + + ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8")) + print(" ✓ list_voices 改用 SAPI5 COM(+ System.Speech 兜底)") + print(" ✓ say 改成降级链 + 等待退出码 + 记住可用的那条") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/package_remote.py b/_build/package_remote.py index 0aa7dca..8d1e212 100644 --- a/_build/package_remote.py +++ b/_build/package_remote.py @@ -17,7 +17,7 @@ import zipfile from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") -VERSION = "2.1.7" +VERSION = "2.1.8" NAME = f"ykxmz-{VERSION}" AGENT_SRC = ROOT / "dist_exe" / "ykxmz" diff --git a/_build/quickbar_bigger_and_4slots.py b/_build/quickbar_bigger_and_4slots.py new file mode 100644 index 0000000..d606276 --- /dev/null +++ b/_build/quickbar_bigger_and_4slots.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +r'''快捷栏:按钮做大 + **4 格全部可自定义**。 + +== 用户这次的反馈(原话)== + +"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点, + 然后呢,这个每每个四格算换一下吧,第一个第二个第三个第四个都能换东西" + +== 两个改动 == + +1. **按钮做大**:30dp -> 46dp,字号 11 -> 14。 + 30dp 在手表上确实太小了 —— 表盘本来就小,手指按上去一半在栏外。 + 46dp 接近 Android 建议的最小可点区域(48dp)。 + + ⚠ 栏高和面板 topMargin 必须**用同一个常量**。分开写的话 + 改一个忘一个,表现是"内容要么被栏盖住,要么上面空一条"。 + +2. **第 4 格也变成可自定义**。 + + 用户最早说"其中有一个按钮不能改,功能就是一键返回桌面", + 现在改主意了:"第一个第二个第三个第四个都能换东西"。 + + 所以「显示桌面」从"固定的那一格"变成**普通可选动作**, + 而且默认就放在第 4 格 —— 这样默认行为跟以前一样 + (最后一个还是显示桌面),但用户想换也能换。 + + 实现上就是把 QUICK_CUSTOM_SLOTS 从 3 改成 4, + 再把 {"desktop","显示桌面"} 加进 QUICK_ACTIONS, + 然后去掉所有"第 4 格特殊"的分支。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java" + +PAIRS = [ + # ---- 1. 格子数 3 -> 4 ---- + (' private static final int QUICK_CUSTOM_SLOTS = 3; // 固定那个不算在内', + ''' /** 快捷栏一共几格。用户最初要求"最多 4 个", + * 后来又改成"第一二三四格都能换东西",所以 4 格都可自定义。 */ + private static final int QUICK_SLOTS = 4; + /** 快捷栏高度(dp)。46 接近 Android 建议的最小可点区域 48。 + * ⚠ 面板的 topMargin 也用这个值 —— 分两处写的话改一个忘一个, + * 表现是"内容被栏盖住"或"上面空一条"。 */ + private static final int QUICK_BAR_DP = 46;'''), + + # ---- 2. 加 desktop 到可选动作 ---- + (''' {"image", "投图到电脑"}, + };''', + ''' {"image", "投图到电脑"}, + // 用户最早说这一格"不能改",后来又改成四格都能换 —— + // 所以它现在是**普通可选动作**,只是默认放在第 4 格。 + {"desktop", "显示桌面"}, + };'''), + + # ---- 3. 默认值 3 -> 4 格 ---- + (''' private String[] quickSlots = new String[]{"file", "mirror", "sfx"};''', + ''' private String[] quickSlots = + new String[]{"file", "mirror", "sfx", "desktop"};'''), + + # ---- 4. buildQuickBar:格数、高度、字号 ---- + (''' for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) { + final int slot = i; + Button b = new Button(this); + b.setTextSize(11f);''', + ''' for (int i = 0; i < QUICK_SLOTS; i++) { + final int slot = i; + Button b = new Button(this); + b.setTextSize(14f); // 11 太小,用户说"按不到"'''), + + (''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(30));''', + ''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP));'''), + + # ---- 5. refreshQuickBar:不再有"固定那一格" ---- + (''' boolean connected = isConnected(); + for (int i = 0; i < quickButtons.size(); i++) { + Button b = quickButtons.get(i); + String id = (i < QUICK_CUSTOM_SLOTS) ? quickSlots[i] : "desktop";''', + ''' boolean connected = isConnected(); + for (int i = 0; i < quickButtons.size(); i++) { + Button b = quickButtons.get(i); + String id = quickSlots[i];'''), + + # ---- 6. onQuickClick:不再有"固定那一格" ---- + (''' /** 点第 slot 个按钮。0..2 是自定义,3 是那个固定的显示桌面。 */ + private void onQuickClick(int slot) { + String id = (slot < QUICK_CUSTOM_SLOTS) ? quickSlots[slot] : "desktop"; + if (id == null || id.isEmpty()) { + return; + } + runQuickAction(id); + }''', + ''' /** 点第 slot 个按钮。四格完全平等,都从 quickSlots 里取。 */ + private void onQuickClick(int slot) { + if (slot < 0 || slot >= quickSlots.length) { + return; + } + String id = quickSlots[slot]; + if (id == null || id.isEmpty()) { + return; + } + runQuickAction(id); + }'''), + + # ---- 7. loadQuickSlots:4 格 + 允许 desktop ---- + (''' String[] want = (raw == null || raw.isEmpty()) + ? new String[]{"file", "mirror", "sfx"} + : raw.split(",", -1);''', + ''' String[] want = (raw == null || raw.isEmpty()) + ? new String[]{"file", "mirror", "sfx", "desktop"} + : raw.split(",", -1);'''), + + (''' String[] out = new String[QUICK_CUSTOM_SLOTS]; + int n = 0; + for (String id : want) { + if (n >= QUICK_CUSTOM_SLOTS) { + break; + } + if (id != null && !id.isEmpty() && !"desktop".equals(id) + && isKnownQuickAction(id)) { + out[n++] = id; + } + } + String[] fallback = {"file", "mirror", "sfx"}; + for (int i = 0; i < QUICK_CUSTOM_SLOTS; i++) {''', + ''' String[] out = new String[QUICK_SLOTS]; + int n = 0; + for (String id : want) { + if (n >= QUICK_SLOTS) { + break; + } + // ⚠ 这里**不再排除 desktop** —— 用户改成四格都能换了 + if (id != null && !id.isEmpty() && isKnownQuickAction(id)) { + out[n++] = id; + } + } + String[] fallback = {"file", "mirror", "sfx", "desktop"}; + for (int i = 0; i < QUICK_SLOTS; i++) {'''), + + # ---- 8. applyPanelTopMargin:用同一个常量 ---- + (''' flp.topMargin = dp(30); // 跟 quickBar 的高度一致''', + ''' // 用同一个常量 —— 栏高改了这里自动跟着改 + flp.topMargin = dp(QUICK_BAR_DP);'''), + + # ---- 9. onCreate 里 addView 的高度 ---- + (''' root.addView(quickBar, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(30)));''', + ''' root.addView(quickBar, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP)));'''), + + # ---- 10. 设置页:4 格 + 去掉"固定改不了"的说法 ---- + (''' settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。" + + "最后一个「显示桌面」是固定的、改不了;" + + "前三个点下面自己选。(还没连上电脑时它们是灰的)")); + for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {''', + ''' settingsExtraBox.addView(hintText("顶部那条栏,4 个格子" + + "**每一个都能换**,点下面自己选。" + + "(还没连上电脑时它们是灰的)")); + for (int slot = 0; slot < QUICK_SLOTS; slot++) {'''), + + (''' settingsExtraBox.addView(smallButton( + "恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD, + new Runnable() { + public void run() { + quickSlots = new String[]{"file", "mirror", "sfx"};''', + ''' settingsExtraBox.addView(smallButton( + "恢复默认(传文件 / 屏幕镜像 / 音效 / 显示桌面)", C_CARD, + new Runnable() { + public void run() { + quickSlots = new String[]{"file", "mirror", "sfx", "desktop"};'''), + + # ---- 11. 设置页标题也说明 4 格都能换 ---- + (''' private void pickQuickSlot(final int slot) { + final java.util.List labels = new java.util.ArrayList(); + final java.util.List ids = new java.util.ArrayList(); + labels.add("留空(这一格不显示)");''', + ''' private void pickQuickSlot(final int slot) { + final java.util.List labels = new java.util.ArrayList(); + final java.util.List ids = new java.util.ArrayList(); + labels.add("留空(这一格不显示)");'''), +] + + +def main() -> int: + print("=" * 58) + print(" 快捷栏:按钮做大 + 4 格全部可自定义") + print("=" * 58) + print() + + raw = MAIN.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + t = raw.decode("utf-8-sig") + + if "QUICK_SLOTS = 4" in t: + print(" 已经改过了") + return 0 + + # 先全部校验,避免改一半 + missing = [] + for i, (old, _new) in enumerate(PAIRS, 1): + if old not in t: + missing.append((i, old.strip().splitlines()[0][:66])) + if missing: + print(" ✗ 这些锚点找不到:") + for i, s in missing: + print(" #%d %s" % (i, s)) + return 1 + print(" ✓ %d 个锚点全部就位" % len(PAIRS)) + + for i, (old, new) in enumerate(PAIRS, 1): + t = t.replace(old, new, 1) + + # 收尾:把残留的 QUICK_CUSTOM_SLOTS 全换掉(应该已经没有了) + left = t.count("QUICK_CUSTOM_SLOTS") + if left: + print(" ⚠ 还有 %d 处 QUICK_CUSTOM_SLOTS 残留,一并替换" % left) + t = t.replace("QUICK_CUSTOM_SLOTS", "QUICK_SLOTS") + + MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8")) + print(" ✓ 改完了") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/test_installer_cycle.py b/_build/test_installer_cycle.py index 3855f33..9f88cf3 100644 --- a/_build/test_installer_cycle.py +++ b/_build/test_installer_cycle.py @@ -1,212 +1,274 @@ -# -*- 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 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 - - # ---------------- 安装 ---------------- - 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()) +# -*- 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()) diff --git a/_tests/test_apk_contents.py b/_tests/test_apk_contents.py index 91b0066..eb9f466 100644 --- a/_tests/test_apk_contents.py +++ b/_tests/test_apk_contents.py @@ -23,7 +23,7 @@ def _latest_apk() -> Path: (ROOT / "dist").glob("*watch*.apk"), key=lambda p: p.stat().st_mtime, reverse=True) return candidates[0] if candidates else \ - ROOT / "dist" / "ykxmz-watch-2.1.7.apk" + ROOT / "dist" / "ykxmz-watch-2.1.8.apk" APK = _latest_apk() diff --git a/_tests/test_package_remote.py b/_tests/test_package_remote.py index 0375c2e..50e5d80 100644 --- a/_tests/test_package_remote.py +++ b/_tests/test_package_remote.py @@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent # 被控端 -> 一个 zip # 服务器端 -> 一个 zip # 控制端 -> 直接给 APK(不打 zip) -AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.7.zip" -SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.7.zip" -WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.7.apk" +AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.8.zip" +SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.8.zip" +WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.8.apk" TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain") JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe" JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe" @@ -89,7 +89,7 @@ def main() -> int: for label, path in (("被控端", agent_dir / "README-agent.txt"), ("服务器端", relay_dir / "README-server.txt"), ("控制端(旁边那份)", - ROOT / "dist" / "ykxmz-watch-2.1.7-说明.txt")): + ROOT / "dist" / "ykxmz-watch-2.1.8-说明.txt")): raw = path.read_bytes() if path.is_file() else b"" check(f"{label}说明有 UTF-8 BOM", raw[:3] == b"\xef\xbb\xbf", raw[:3]) diff --git a/_tests/test_quickbar.py b/_tests/test_quickbar.py index c407c7f..7b87f59 100644 --- a/_tests/test_quickbar.py +++ b/_tests/test_quickbar.py @@ -3,31 +3,38 @@ r'''验「手表快捷栏」和「允许新设备连接」。 == 验什么 == - 一、手表端源码 - * 快捷栏建出来了,4 个格子(3 自定义 + 1 固定显示桌面) - * 固定那个**不给改**(用户明确要求"其中有一个按钮不能改") - * 挂进了 onCreate(光有方法不 addView 是白搭 —— 项目里踩过) - * 加进了 showOnly 的数组(漏了的话它会永远压在最上面) - * 7 个动作都在 - * 设置页里有入口(没入口用户改不了) + 一、快捷栏的形状 + * 一共 4 格,**每格都能自定义** + * 「显示桌面」是普通可选动作(默认放第 4 格,但能换掉) + * 8 个动作都在 + * 挂进了 onCreate、加进了 showOnly 的数组 + * 设置页里有入口 * 鼠标触摸板在 - 二、电脑端 allow_new_pairing 这个 feature - * 能查、能关、能开 - * 存储里真的变了 - * 只影响新配对,不踢现有会话(注释里写了理由) + 二、按钮大小 + 用户反馈原话:"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点" -== 为什么专门验"固定那个不给改" == + * 栏高做成常量(>= 44dp),而且**栏高 / 面板 margin / addView + 三处用同一个常量** —— 分三处写的话改一个忘一个, + 表现是"内容被盖住"或"上面空一条" -用户原话:"最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。 + 三、电脑端 allow_new_pairing 这个 feature -如果哪天有人"顺手"把它也做成可配置的,用户点了半天发现 -"显示桌面"没了 —— 而这种回归不会报错,只会让人困惑。 -所以这里断言:第 4 格永远是 desktop,且 QUICK_ACTIONS 里**没有**它。 +== 这条断言改过一次,值得记下来 == + +用户最早说:"最多可以加 4 个,其中有一个按钮不能改, + 功能就是一键返回桌面"。 +第一版就按"第 4 格写死 desktop"实现,也按那个断言测。 + +后来用户看到实物改了主意:"第一个第二个第三个第四个都能换东西"。 + +所以现在验的是**相反**的事。教训: +**别把"用户当时的一句话"当成永久约束焊进结构里**; +真是约束的话,注释里要写清是什么时候的要求, +下次他改主意时才知道该动哪儿。 ''' from __future__ import annotations -import json import re import sys import tempfile @@ -67,45 +74,72 @@ def main() -> int: ]: check(label, token in main_java) - # 4 个格子 - check("格子数 = 3 自定义 + 1 固定", - "QUICK_CUSTOM_SLOTS + 1" in main_java - and "QUICK_CUSTOM_SLOTS = 3" in main_java) - - # 固定那个 - m = re.search(r'String id = \(slot < QUICK_CUSTOM_SLOTS\) \? quickSlots\[slot\]' - r' : "desktop"', main_java) - check("★ 第 4 格固定是 desktop(不给改)", m is not None, - "第 4 格不是写死的 desktop —— 用户要求它不能改") - check("★ QUICK_ACTIONS 里没有 desktop(否则它会出现在可选列表里)", - not re.search(r'\{"desktop"', main_java), - "desktop 出现在可选动作里了,用户就能把它改掉") + # 4 格,全部可自定义。这条断言改过一次,见文件头。 + check("★ 一共 4 格", "QUICK_SLOTS = 4" in main_java, "用户要求 4 格") + check("★ 旧的 3+1 分法已经不在了", + "QUICK_CUSTOM_SLOTS" not in main_java, "还有残留说明没改干净") + check("★ 没有把 desktop 写死在某格", + ': "desktop";' not in main_java, + "还有地方把 desktop 写死成某一格") + check("★ desktop 在可选动作里(能换到任意一格)", + bool(re.search(r'\{"desktop",\s*"显示桌面"\}', main_java)), + "不在 QUICK_ACTIONS 里的话用户没法把它放到别的格") + check("四格平等:都从 quickSlots 取", + "String id = quickSlots[i];" in main_java + and "String id = quickSlots[slot];" in main_java, + "还有特殊分支") + check("默认值就是那 4 个", + 'new String[]{"file", "mirror", "sfx", "desktop"}' in main_java) check("desktop 走的是 show_desktop 命令", - '"cmd", "show_desktop"' in main_java, - "固定按钮应该让电脑 Win+D") + '"cmd", "show_desktop"' in main_java) - # 7 个动作 print() - print(" 7 个可选动作:") + print(" 8 个可选动作:") for aid, label in [("file", "传文件"), ("mirror", "屏幕镜像"), ("mouse", "鼠标控制"), ("sfx", "音效"), ("power", "关机"), ("tts", "语音播报"), - ("image", "投图到电脑")]: - ok = ('{"%s", "%s"}' % (aid, label)) in main_java - check(" %s(%s)" % (aid, label), ok) + ("image", "投图到电脑"), ("desktop", "显示桌面")]: + check(" %s(%s)" % (aid, label), + ('{"%s", "%s"}' % (aid, label)) in main_java) - # 挂接 + # ---------------- 二、按钮大小 ---------------- print() - print("二、挂接(漏了就白写)") - oncreate = main_java[main_java.find("protected void onCreate"): - main_java.find("protected void onCreate") + 6000] - check("★ onCreate 里调了 buildQuickBar()", - "buildQuickBar();" in oncreate, - "没挂上去的话栏根本不显示") - check("★ onCreate 里调了 loadQuickSlots()", - "loadQuickSlots();" in oncreate) - check("★ 快捷栏**最后** addView(保证在最上层)", - oncreate.find("root.addView(quickBar") > oncreate.find("root.addView(mousePanel"), + print("二、按钮大小(用户反馈「太小了、按不到」)") + m = re.search(r"QUICK_BAR_DP = (\d+)", main_java) + check("★ 栏高做成常量 QUICK_BAR_DP", m is not None, + "没有常量的话栏高和面板 margin 会各写各的") + h = int(m.group(1)) if m else 0 + check("★ 栏高 >= 44dp(接近 Android 建议的可点区域 48)", h >= 44, + "现在是 %d" % h) + check("★ 面板 topMargin 用同一个常量", + "flp.topMargin = dp(QUICK_BAR_DP);" in main_java, + "分两处写的话改一个忘一个") + check("★ addView 也用同一个常量", + "MATCH_PARENT, dp(QUICK_BAR_DP)));" in main_java) + check("★ 按钮字号 >= 13(原来是 11)", + "b.setTextSize(14f);" in main_java) + # ⚠ 只查**代码**,不查注释。 + # 第一版这里是 `"dp(30)" not in main_java`,结果被 + # applyPanelTopMargin 的 javadoc 里那句旧说明误报了 —— + # 注释里提一下旧值是很正常的事("以前是 30dp"), + # 不该因为它变红。 + code_lines = [ln for ln in main_java.splitlines() + if not ln.strip().startswith(("*", "//", "/*"))] + hard = [ln.strip() for ln in code_lines if "dp(30)" in ln] + check("没有残留的 dp(30)(旧栏高)", not hard, + "还有代码用旧的 30dp:%s" % hard) + + # ---------------- 三、挂接 ---------------- + print() + print("三、挂接(漏了就白写)") + i0 = main_java.find("protected void onCreate") + oncreate = main_java[i0:i0 + 7000] + check("★ onCreate 里调了 buildQuickBar()", "buildQuickBar();" in oncreate, + "没挂上去栏根本不显示") + check("★ onCreate 里调了 loadQuickSlots()", "loadQuickSlots();" in oncreate) + check("★ 快捷栏最后 addView(保证在最上层)", + oncreate.find("root.addView(quickBar") + > oncreate.find("root.addView(mousePanel"), "加在面板之前会被面板整个盖住") check("★ showOnly 的数组里有 mousePanel", "settingsPanel, mousePanel};" in main_java, @@ -113,23 +147,23 @@ def main() -> int: check("有 applyPanelTopMargin(面板给栏让位)", "private void applyPanelTopMargin" in main_java) - # 设置页入口 + # ---------------- 四、设置页 ---------------- print() - print("三、设置页入口(没入口用户改不了)") - check("有 fillSettingsExtra", "private void fillSettingsExtra()" in main_java) + print("四、设置页入口(没入口用户改不了)") + check("有 fillSettingsExtra", + "private void fillSettingsExtra()" in main_java) check("有 pickQuickSlot(选动作)", "private void pickQuickSlot(final int slot)" in main_java) check("设置页里加了「快捷栏」小节", 'sectionTitle("快捷栏")' in main_java) check("设置页里加了「允许新设备连接」小节", 'sectionTitle("允许新设备连接")' in main_java) - check("界面上说明了第 4 格改不了", - "固定的、改不了" in main_java, - "不说明的话用户会一直找怎么改第四个") + check("界面上说明了四格都能换", "每一个都能换" in main_java, + "不说明的话用户不知道第四格也能改") - # 鼠标触摸板 + # ---------------- 五、鼠标触摸板 ---------------- print() - print("四、鼠标触摸板") + print("五、鼠标触摸板") check("有 buildMousePanel", "private void buildMousePanel()" in main_java) check("用 GestureDetector(单击/双击/拖动)", "GestureDetector" in main_java @@ -141,31 +175,29 @@ def main() -> int: check("滚轮上/下", 'sendMouse("scroll", "", 3)' in main_java and 'sendMouse("scroll", "", -3)' in main_java) check("拖动 -> moveby", '"moveby"' in main_java) - check("isConnected() 存在(我第一版漏了,编译才发现)", + check("isConnected() 存在(第一版漏了,编译才发现)", "private boolean isConnected()" in main_java) - # ---------------- 五、电脑端 allow_new_pairing ---------------- + # ---------------- 六、电脑端 allow_new_pairing ---------------- print() - print("五、电脑端 allow_new_pairing feature") + print("六、电脑端 allow_new_pairing feature") from lanremote.config import Store from lanremote.features import Features class FakeActions: """Features 要的是 actions 对象,不是 store(第一版就是这里传错了)。""" + def __init__(self, store): self.store = store self.ui = None - d = Path(tempfile.mkdtemp()) - st = Store(d) + st = Store(Path(tempfile.mkdtemp())) feats = Features(FakeActions(st)) - check("能查到当前状态", - "允许" in feats.run("allow_new_pairing", {})) + check("能查到当前状态", "允许" in feats.run("allow_new_pairing", {})) out = feats.run("allow_new_pairing", {"allow": False}) check("能关掉", "禁止" in out, "拿到 %r" % out) - check(" ★ 存储里真的变了", - st.get("allow_new_pairing") is False, + check(" ★ 存储里真的变了", st.get("allow_new_pairing") is False, "拿到 %r" % st.get("allow_new_pairing")) check(" 关掉之后再查也是关", "不允许" in feats.run("allow_new_pairing", {})) @@ -173,7 +205,6 @@ def main() -> int: check("能打开", "已允许" in out, "拿到 %r" % out) check(" 存储里也变了", st.get("allow_new_pairing") is True) - # 不存在名字要报错(别静默成功) try: feats.run("allow_new_pairing_typo", {}) check("不认识的名字会报错", False, "它竟然没报错") diff --git a/_tests/test_tts.py b/_tests/test_tts.py new file mode 100644 index 0000000..4908e3c --- /dev/null +++ b/_tests/test_tts.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +r'''验「语音播报降级链」。 + +== 为什么要测 == + +用户反馈:**Win7 上语音播报不能用**。 + +原来的实现只有一条路(PowerShell + System.Speech),而 Win7 上 +PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后, +必须保证: + + 1. **方案按"依赖越少越优先"排** —— cscript/VBScript 排最前, + 因为它从 Windows 98 就有,SAPI5 是系统组件,两样都不用装。 + 2. **真的会降级** —— 前一条失败要接着试下一条,不是试一条就放弃。 + 3. **一条通了要记住** —— 不然每播一次都要把几条全试一遍。 + 4. **失败了要报错**,不能像原来那样 `Popen` 不等待、 + 明明没出声却返回"已播报"。 + 5. **VBScript 用 ANSI 编码写** —— 写成 UTF-8 的话 cscript 读出来 + 中文是乱码。这是 VBScript 的老规矩,很隐蔽。 + +== 怎么测"会降级" == + +不能真把 cscript 弄坏。所以对方案列表做**结构断言**: +确认它列出了 >= 3 条、顺序正确;再单独构造一条必失败的链 +验证"失败会继续往下试"这个逻辑(用一个假的 subprocess)。 + +真出声的部分只在最后一节做(本机有 cscript 的话)。 +''' +from __future__ import annotations + +import re +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "windows")) + +FAILS: list = [] + + +def check(name: str, ok: bool, why: str = "") -> None: + print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name, + (" —— " + why) if why else "")) + if not ok: + FAILS.append(name) + + +class FakeUi: + """Actions 需要一个 ui,TTS 用不到它的任何东西。""" + + def log(self, *_a, **_k): + pass + + def notify(self, *_a, **_k): + pass + + +def main() -> int: + print("=" * 58) + print(" 语音播报降级链") + print("=" * 58) + print() + + from lanremote.actions import Actions + from lanremote.config import Store + + a = Actions(FakeUi(), Store(Path(tempfile.mkdtemp()))) + + # ---------------- 一、方法都在 ---------------- + print("一、方法") + for name in ("_powershell_path", "_write_vbs", "_tts_commands"): + check("有 %s" % name, hasattr(a, name)) + + # ---------------- 二、方案顺序 ---------------- + print() + print("二、降级顺序(依赖越少越优先)") + cmds = a._tts_commands("测试一二三", "", 0) + names = [c[0] for c in cmds] + print(" 顺序: %s" % " -> ".join(names)) + check("至少 3 条方案", len(cmds) >= 3, "只有 %d 条" % len(cmds)) + if names: + check("★ 第一条是 cscript(不依赖 PowerShell / .NET)", + names[0] == "cscript", + "第一条是 %r —— cscript 最该排前面" % names[0]) + if len(names) > 1: + check("第二条是 wscript(cscript 被拦时的备选)", + names[1] == "wscript", "第二条是 %r" % names[1]) + check("★ 有 ps-sapi 那条(绕开 .NET 只用系统 SAPI5)", + "ps-sapi" in names, "方案 %s" % names) + check("★ 保留了原来的 System.Speech 那条(兜底)", + "ps-speech" in names or "ps-path" in names, + "把老办法删了的话,老机器上本来能用的反而不能用") + check("cscript 那条带 //nologo(不然会弹横幅)", + any("//nologo" in " ".join(c[1]) for c in cmds)) + + # ---------------- 三、VBScript 编码 ---------------- + print() + print("三、VBScript(中文必须用 ANSI 写)") + p = a._write_vbs("中文测试", "", 0) + raw = p.read_bytes() + check("生成了 .vbs", p.is_file(), str(p)) + check("★ 不是 UTF-8(UTF-8 的中文在 cscript 里是乱码)", + not raw.startswith(b"\xef\xbb\xbf") and raw.count(b"\xe4\xb8") == 0, + "文件开头 %r" % raw[:8]) + check("★ 用 mbcs 能解回原文", + "中文测试" in raw.decode("mbcs", errors="replace")) + txt = raw.decode("mbcs", errors="replace") + check("用的是 SAPI.SpVoice(系统原生,不需要 .NET)", + "SAPI.SpVoice" in txt) + check("CRLF 换行(VBScript 的老规矩)", "\r\n" in txt) + + # 音色 / 语速要能写进去 + p2 = a._write_vbs("有音色和语速", "Huihui", 3) + t2 = p2.read_bytes().decode("mbcs", errors="replace") + check("音色写进去了", "Huihui" in t2) + check("语速写进去了", "v.Rate = 3" in t2) + + # 长文本要分句(VBScript 单行有长度上限) + long_text = "这是一句很长的话。" * 100 + p3 = a._write_vbs(long_text, "", 0) + t3 = p3.read_bytes().decode("mbcs", errors="replace") + speak_lines = [ln for ln in t3.splitlines() if ln.strip().startswith("v.Speak")] + check("★ 长文本被切成多句(VBScript 单行长度有限)", + len(speak_lines) > 1, + "只有 %d 行 v.Speak —— 太长会报错" % len(speak_lines)) + check(" 每句不超过 400 字", + all(len(ln) < 420 for ln in speak_lines), + "最长 %d" % max((len(x) for x in speak_lines), default=0)) + + # 引号要转义(不然 VBScript 语法错) + p4 = a._write_vbs('他说"你好"', "", 0) + t4 = p4.read_bytes().decode("mbcs", errors="replace") + check("双引号被转义成两个", '""' in t4 or '"你好"' not in t4) + + # ---------------- 四、会降级(不是试一条就放弃)---------------- + print() + print("四、降级行为") + import lanremote.actions as A + real_run = A.subprocess.run + calls = [] + + class R: + def __init__(self, code, err=""): + self.returncode = code + self.stdout = "" + self.stderr = err + + def fake_run(cmd, **kw): + calls.append(cmd[0]) + # 让前两条"失败",第三条"成功" —— 验证它会继续往下试 + if len(calls) <= 2: + return R(1, "模拟失败") + return R(0) + + A.subprocess.run = fake_run + try: + out = a.say("降级测试") + check("★ 前两条失败后会继续试第三条", len(calls) >= 3, + "只试了 %d 次:%s" % (len(calls), calls)) + check("★ 成功了就返回成功", "已播报" in out, "拿到 %r" % out) + finally: + A.subprocess.run = real_run + + # 全失败要抛错(不能像原来那样骗人) + calls.clear() + + def always_fail(cmd, **kw): + calls.append(cmd[0]) + return R(1, "全失败") + + A.subprocess.run = always_fail + try: + a.say("全失败测试") + check("★ 全失败时必须抛错(原来会假报成功)", False, + "它竟然返回了成功") + except Exception as exc: # noqa: BLE001 + check("★ 全失败时必须抛错(原来会假报成功)", True) + check(" 错误里列出了试过哪些", "试过" in str(exc), "拿到 %r" % exc) + finally: + A.subprocess.run = real_run + check(" 确实把每条都试过了", len(calls) >= 3, "只试了 %d 次" % len(calls)) + + # ---------------- 五、本机真播一次 ---------------- + print() + print("五、本机真播一次(有 cscript 的话)") + import shutil + if shutil.which("cscript"): + try: + out = a.say("语音播报测试") + check("真的播报成功", "已播报" in out, "拿到 %r" % out) + check(" 报告里说了用的是哪条", "用 " in out, "拿到 %r" % out) + except Exception as exc: # noqa: BLE001 + check("真的播报成功", False, str(exc)[:120]) + else: + print(" [SKIP] 这台没有 cscript") + + print() + print("=" * 58) + if FAILS: + print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS))) + return 1 + print(" 全部通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/verify_all.ps1 b/_tests/verify_all.ps1 index b5be063..82d745c 100644 --- a/_tests/verify_all.ps1 +++ b/_tests/verify_all.ps1 @@ -554,6 +554,20 @@ $stageOut = & python (Join-Path $root "_tests\test_quickbar.py") 2>&1 Show-Fails $stageOut Record "手表快捷栏" ($LASTEXITCODE -eq 0) +# ---------------------------------------------------------------- 3a14 +Banner "3a14/9 语音播报降级链(修 Win7 上用不了)" +# 用户反馈:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"。 +# +# 原来只有一条路(powershell + System.Speech),而 Win7 上 +# PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后必须保证: +# * cscript/VBScript 排最前(从 Win98 就有,SAPI5 是系统组件,不用装东西) +# * 前一条失败要接着试下一条,不是试一条就放弃 +# * 全失败要**报错**,不能像原来那样 Popen 不等待、明明没出声却说"已播报" +# * VBScript 用 ANSI 写(UTF-8 的话 cscript 读出来中文是乱码) +$stageOut = & python (Join-Path $root "_tests\test_tts.py") 2>&1 + Show-Fails $stageOut +Record "语音播报降级" ($LASTEXITCODE -eq 0) + # ---------------------------------------------------------------- 3a5 Banner "3a5/9 局域网/中转对比基准(防测试自己骗人)" # 这个基准一度是红的,而且报告里写着「这是中转链路的产品 bug」—— diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 26bb31e..a15b226 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:versionName="2.1.8">