mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 12:30:44 +00:00
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 兼容校验通过
This commit is contained in:
@@ -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 个按钮:
|
||||
|
||||
@@ -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())
|
||||
@@ -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 兼容校验通过
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");''',
|
||||
''' private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
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())
|
||||
+274
-212
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user