mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 16:00:43 +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:
@@ -45,9 +45,9 @@
|
|||||||
|
|
||||||
| 装在哪 | 是什么 | 文件 |
|
| 装在哪 | 是什么 | 文件 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.7.exe` |
|
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.8.exe` |
|
||||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.7.zip` |
|
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.8.zip` |
|
||||||
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.7.apk` |
|
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.8.apk` |
|
||||||
|
|
||||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 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`,整个文件夹拷走就能换台机器用,不写注册表。
|
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||||
|
|
||||||
### 第二步:手表上装 App
|
### 第二步:手表上装 App
|
||||||
|
|
||||||
把 `ykxmz-watch-2.1.7.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
把 `ykxmz-watch-2.1.8.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||||
|
|
||||||
打开 App,首页有四个入口:
|
打开 App,首页有四个入口:
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||||
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||||
|
|
||||||
把 `ykxmz-server-2.1.7.zip` 解压到那台机器上,然后一条命令:
|
把 `ykxmz-server-2.1.8.zip` 解压到那台机器上,然后一条命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash deploy.sh
|
bash deploy.sh
|
||||||
@@ -456,7 +456,7 @@ windows\build_exe.ps1
|
|||||||
```powershell
|
```powershell
|
||||||
android\build_apk.ps1
|
android\build_apk.ps1
|
||||||
# 流程:aapt2 → javac → d8 → zipalign → apksigner
|
# 流程: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。
|
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||||
|
|||||||
@@ -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
|
## 2.1.7
|
||||||
|
|
||||||
- **手表端顶部快捷栏** —— 任何页面顶部都有一条栏,最多 4 个按钮:
|
- **手表端顶部快捷栏** —— 任何页面顶部都有一条栏,最多 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
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
VERSION = "2.1.7"
|
VERSION = "2.1.8"
|
||||||
NAME = f"ykxmz-{VERSION}"
|
NAME = f"ykxmz-{VERSION}"
|
||||||
|
|
||||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
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())
|
||||||
@@ -114,6 +114,43 @@ def install() -> tuple:
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def uninstall() -> tuple:
|
||||||
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
||||||
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
||||||
@@ -129,6 +166,31 @@ def main() -> int:
|
|||||||
if not SETUP.is_file():
|
if not SETUP.is_file():
|
||||||
return 1
|
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()
|
||||||
print(" 一、静默安装(配对码 %s)" % CODE)
|
print(" 一、静默安装(配对码 %s)" % CODE)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def _latest_apk() -> Path:
|
|||||||
(ROOT / "dist").glob("*watch*.apk"),
|
(ROOT / "dist").glob("*watch*.apk"),
|
||||||
key=lambda p: p.stat().st_mtime, reverse=True)
|
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
return candidates[0] if candidates else \
|
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()
|
APK = _latest_apk()
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||||||
# 被控端 -> 一个 zip
|
# 被控端 -> 一个 zip
|
||||||
# 服务器端 -> 一个 zip
|
# 服务器端 -> 一个 zip
|
||||||
# 控制端 -> 直接给 APK(不打 zip)
|
# 控制端 -> 直接给 APK(不打 zip)
|
||||||
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.7.zip"
|
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.8.zip"
|
||||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.7.zip"
|
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.8.zip"
|
||||||
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.7.apk"
|
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.8.apk"
|
||||||
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
||||||
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
||||||
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
||||||
@@ -89,7 +89,7 @@ def main() -> int:
|
|||||||
for label, path in (("被控端", agent_dir / "README-agent.txt"),
|
for label, path in (("被控端", agent_dir / "README-agent.txt"),
|
||||||
("服务器端", relay_dir / "README-server.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""
|
raw = path.read_bytes() if path.is_file() else b""
|
||||||
check(f"{label}说明有 UTF-8 BOM",
|
check(f"{label}说明有 UTF-8 BOM",
|
||||||
raw[:3] == b"\xef\xbb\xbf", raw[:3])
|
raw[:3] == b"\xef\xbb\xbf", raw[:3])
|
||||||
|
|||||||
+97
-66
@@ -3,31 +3,38 @@ r'''验「手表快捷栏」和「允许新设备连接」。
|
|||||||
|
|
||||||
== 验什么 ==
|
== 验什么 ==
|
||||||
|
|
||||||
一、手表端源码
|
一、快捷栏的形状
|
||||||
* 快捷栏建出来了,4 个格子(3 自定义 + 1 固定显示桌面)
|
* 一共 4 格,**每格都能自定义**
|
||||||
* 固定那个**不给改**(用户明确要求"其中有一个按钮不能改")
|
* 「显示桌面」是普通可选动作(默认放第 4 格,但能换掉)
|
||||||
* 挂进了 onCreate(光有方法不 addView 是白搭 —— 项目里踩过)
|
* 8 个动作都在
|
||||||
* 加进了 showOnly 的数组(漏了的话它会永远压在最上面)
|
* 挂进了 onCreate、加进了 showOnly 的数组
|
||||||
* 7 个动作都在
|
* 设置页里有入口
|
||||||
* 设置页里有入口(没入口用户改不了)
|
|
||||||
* 鼠标触摸板在
|
* 鼠标触摸板在
|
||||||
|
|
||||||
二、电脑端 allow_new_pairing 这个 feature
|
二、按钮大小
|
||||||
* 能查、能关、能开
|
用户反馈原话:"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点"
|
||||||
* 存储里真的变了
|
|
||||||
* 只影响新配对,不踢现有会话(注释里写了理由)
|
|
||||||
|
|
||||||
== 为什么专门验"固定那个不给改" ==
|
* 栏高做成常量(>= 44dp),而且**栏高 / 面板 margin / addView
|
||||||
|
三处用同一个常量** —— 分三处写的话改一个忘一个,
|
||||||
|
表现是"内容被盖住"或"上面空一条"
|
||||||
|
|
||||||
用户原话:"最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。
|
三、电脑端 allow_new_pairing 这个 feature
|
||||||
|
|
||||||
如果哪天有人"顺手"把它也做成可配置的,用户点了半天发现
|
== 这条断言改过一次,值得记下来 ==
|
||||||
"显示桌面"没了 —— 而这种回归不会报错,只会让人困惑。
|
|
||||||
所以这里断言:第 4 格永远是 desktop,且 QUICK_ACTIONS 里**没有**它。
|
用户最早说:"最多可以加 4 个,其中有一个按钮不能改,
|
||||||
|
功能就是一键返回桌面"。
|
||||||
|
第一版就按"第 4 格写死 desktop"实现,也按那个断言测。
|
||||||
|
|
||||||
|
后来用户看到实物改了主意:"第一个第二个第三个第四个都能换东西"。
|
||||||
|
|
||||||
|
所以现在验的是**相反**的事。教训:
|
||||||
|
**别把"用户当时的一句话"当成永久约束焊进结构里**;
|
||||||
|
真是约束的话,注释里要写清是什么时候的要求,
|
||||||
|
下次他改主意时才知道该动哪儿。
|
||||||
'''
|
'''
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -67,45 +74,72 @@ def main() -> int:
|
|||||||
]:
|
]:
|
||||||
check(label, token in main_java)
|
check(label, token in main_java)
|
||||||
|
|
||||||
# 4 个格子
|
# 4 格,全部可自定义。这条断言改过一次,见文件头。
|
||||||
check("格子数 = 3 自定义 + 1 固定",
|
check("★ 一共 4 格", "QUICK_SLOTS = 4" in main_java, "用户要求 4 格")
|
||||||
"QUICK_CUSTOM_SLOTS + 1" in main_java
|
check("★ 旧的 3+1 分法已经不在了",
|
||||||
and "QUICK_CUSTOM_SLOTS = 3" in main_java)
|
"QUICK_CUSTOM_SLOTS" not in main_java, "还有残留说明没改干净")
|
||||||
|
check("★ 没有把 desktop 写死在某格",
|
||||||
# 固定那个
|
': "desktop";' not in main_java,
|
||||||
m = re.search(r'String id = \(slot < QUICK_CUSTOM_SLOTS\) \? quickSlots\[slot\]'
|
"还有地方把 desktop 写死成某一格")
|
||||||
r' : "desktop"', main_java)
|
check("★ desktop 在可选动作里(能换到任意一格)",
|
||||||
check("★ 第 4 格固定是 desktop(不给改)", m is not None,
|
bool(re.search(r'\{"desktop",\s*"显示桌面"\}', main_java)),
|
||||||
"第 4 格不是写死的 desktop —— 用户要求它不能改")
|
"不在 QUICK_ACTIONS 里的话用户没法把它放到别的格")
|
||||||
check("★ QUICK_ACTIONS 里没有 desktop(否则它会出现在可选列表里)",
|
check("四格平等:都从 quickSlots 取",
|
||||||
not re.search(r'\{"desktop"', main_java),
|
"String id = quickSlots[i];" in main_java
|
||||||
"desktop 出现在可选动作里了,用户就能把它改掉")
|
and "String id = quickSlots[slot];" in main_java,
|
||||||
|
"还有特殊分支")
|
||||||
|
check("默认值就是那 4 个",
|
||||||
|
'new String[]{"file", "mirror", "sfx", "desktop"}' in main_java)
|
||||||
check("desktop 走的是 show_desktop 命令",
|
check("desktop 走的是 show_desktop 命令",
|
||||||
'"cmd", "show_desktop"' in main_java,
|
'"cmd", "show_desktop"' in main_java)
|
||||||
"固定按钮应该让电脑 Win+D")
|
|
||||||
|
|
||||||
# 7 个动作
|
|
||||||
print()
|
print()
|
||||||
print(" 7 个可选动作:")
|
print(" 8 个可选动作:")
|
||||||
for aid, label in [("file", "传文件"), ("mirror", "屏幕镜像"),
|
for aid, label in [("file", "传文件"), ("mirror", "屏幕镜像"),
|
||||||
("mouse", "鼠标控制"), ("sfx", "音效"),
|
("mouse", "鼠标控制"), ("sfx", "音效"),
|
||||||
("power", "关机"), ("tts", "语音播报"),
|
("power", "关机"), ("tts", "语音播报"),
|
||||||
("image", "投图到电脑")]:
|
("image", "投图到电脑"), ("desktop", "显示桌面")]:
|
||||||
ok = ('{"%s", "%s"}' % (aid, label)) in main_java
|
check(" %s(%s)" % (aid, label),
|
||||||
check(" %s(%s)" % (aid, label), ok)
|
('{"%s", "%s"}' % (aid, label)) in main_java)
|
||||||
|
|
||||||
# 挂接
|
# ---------------- 二、按钮大小 ----------------
|
||||||
print()
|
print()
|
||||||
print("二、挂接(漏了就白写)")
|
print("二、按钮大小(用户反馈「太小了、按不到」)")
|
||||||
oncreate = main_java[main_java.find("protected void onCreate"):
|
m = re.search(r"QUICK_BAR_DP = (\d+)", main_java)
|
||||||
main_java.find("protected void onCreate") + 6000]
|
check("★ 栏高做成常量 QUICK_BAR_DP", m is not None,
|
||||||
check("★ onCreate 里调了 buildQuickBar()",
|
"没有常量的话栏高和面板 margin 会各写各的")
|
||||||
"buildQuickBar();" in oncreate,
|
h = int(m.group(1)) if m else 0
|
||||||
"没挂上去的话栏根本不显示")
|
check("★ 栏高 >= 44dp(接近 Android 建议的可点区域 48)", h >= 44,
|
||||||
check("★ onCreate 里调了 loadQuickSlots()",
|
"现在是 %d" % h)
|
||||||
"loadQuickSlots();" in oncreate)
|
check("★ 面板 topMargin 用同一个常量",
|
||||||
check("★ 快捷栏**最后** addView(保证在最上层)",
|
"flp.topMargin = dp(QUICK_BAR_DP);" in main_java,
|
||||||
oncreate.find("root.addView(quickBar") > oncreate.find("root.addView(mousePanel"),
|
"分两处写的话改一个忘一个")
|
||||||
|
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",
|
check("★ showOnly 的数组里有 mousePanel",
|
||||||
"settingsPanel, mousePanel};" in main_java,
|
"settingsPanel, mousePanel};" in main_java,
|
||||||
@@ -113,23 +147,23 @@ def main() -> int:
|
|||||||
check("有 applyPanelTopMargin(面板给栏让位)",
|
check("有 applyPanelTopMargin(面板给栏让位)",
|
||||||
"private void applyPanelTopMargin" in main_java)
|
"private void applyPanelTopMargin" in main_java)
|
||||||
|
|
||||||
# 设置页入口
|
# ---------------- 四、设置页 ----------------
|
||||||
print()
|
print()
|
||||||
print("三、设置页入口(没入口用户改不了)")
|
print("四、设置页入口(没入口用户改不了)")
|
||||||
check("有 fillSettingsExtra", "private void fillSettingsExtra()" in main_java)
|
check("有 fillSettingsExtra",
|
||||||
|
"private void fillSettingsExtra()" in main_java)
|
||||||
check("有 pickQuickSlot(选动作)",
|
check("有 pickQuickSlot(选动作)",
|
||||||
"private void pickQuickSlot(final int slot)" in main_java)
|
"private void pickQuickSlot(final int slot)" in main_java)
|
||||||
check("设置页里加了「快捷栏」小节",
|
check("设置页里加了「快捷栏」小节",
|
||||||
'sectionTitle("快捷栏")' in main_java)
|
'sectionTitle("快捷栏")' in main_java)
|
||||||
check("设置页里加了「允许新设备连接」小节",
|
check("设置页里加了「允许新设备连接」小节",
|
||||||
'sectionTitle("允许新设备连接")' in main_java)
|
'sectionTitle("允许新设备连接")' in main_java)
|
||||||
check("界面上说明了第 4 格改不了",
|
check("界面上说明了四格都能换", "每一个都能换" in main_java,
|
||||||
"固定的、改不了" in main_java,
|
"不说明的话用户不知道第四格也能改")
|
||||||
"不说明的话用户会一直找怎么改第四个")
|
|
||||||
|
|
||||||
# 鼠标触摸板
|
# ---------------- 五、鼠标触摸板 ----------------
|
||||||
print()
|
print()
|
||||||
print("四、鼠标触摸板")
|
print("五、鼠标触摸板")
|
||||||
check("有 buildMousePanel", "private void buildMousePanel()" in main_java)
|
check("有 buildMousePanel", "private void buildMousePanel()" in main_java)
|
||||||
check("用 GestureDetector(单击/双击/拖动)",
|
check("用 GestureDetector(单击/双击/拖动)",
|
||||||
"GestureDetector" in main_java
|
"GestureDetector" in main_java
|
||||||
@@ -141,31 +175,29 @@ def main() -> int:
|
|||||||
check("滚轮上/下", 'sendMouse("scroll", "", 3)' in main_java
|
check("滚轮上/下", 'sendMouse("scroll", "", 3)' in main_java
|
||||||
and 'sendMouse("scroll", "", -3)' in main_java)
|
and 'sendMouse("scroll", "", -3)' in main_java)
|
||||||
check("拖动 -> moveby", '"moveby"' in main_java)
|
check("拖动 -> moveby", '"moveby"' in main_java)
|
||||||
check("isConnected() 存在(我第一版漏了,编译才发现)",
|
check("isConnected() 存在(第一版漏了,编译才发现)",
|
||||||
"private boolean isConnected()" in main_java)
|
"private boolean isConnected()" in main_java)
|
||||||
|
|
||||||
# ---------------- 五、电脑端 allow_new_pairing ----------------
|
# ---------------- 六、电脑端 allow_new_pairing ----------------
|
||||||
print()
|
print()
|
||||||
print("五、电脑端 allow_new_pairing feature")
|
print("六、电脑端 allow_new_pairing feature")
|
||||||
from lanremote.config import Store
|
from lanremote.config import Store
|
||||||
from lanremote.features import Features
|
from lanremote.features import Features
|
||||||
|
|
||||||
class FakeActions:
|
class FakeActions:
|
||||||
"""Features 要的是 actions 对象,不是 store(第一版就是这里传错了)。"""
|
"""Features 要的是 actions 对象,不是 store(第一版就是这里传错了)。"""
|
||||||
|
|
||||||
def __init__(self, store):
|
def __init__(self, store):
|
||||||
self.store = store
|
self.store = store
|
||||||
self.ui = None
|
self.ui = None
|
||||||
|
|
||||||
d = Path(tempfile.mkdtemp())
|
st = Store(Path(tempfile.mkdtemp()))
|
||||||
st = Store(d)
|
|
||||||
feats = Features(FakeActions(st))
|
feats = Features(FakeActions(st))
|
||||||
|
|
||||||
check("能查到当前状态",
|
check("能查到当前状态", "允许" in feats.run("allow_new_pairing", {}))
|
||||||
"允许" in feats.run("allow_new_pairing", {}))
|
|
||||||
out = feats.run("allow_new_pairing", {"allow": False})
|
out = feats.run("allow_new_pairing", {"allow": False})
|
||||||
check("能关掉", "禁止" in out, "拿到 %r" % out)
|
check("能关掉", "禁止" in out, "拿到 %r" % out)
|
||||||
check(" ★ 存储里真的变了",
|
check(" ★ 存储里真的变了", st.get("allow_new_pairing") is False,
|
||||||
st.get("allow_new_pairing") is False,
|
|
||||||
"拿到 %r" % st.get("allow_new_pairing"))
|
"拿到 %r" % st.get("allow_new_pairing"))
|
||||||
check(" 关掉之后再查也是关",
|
check(" 关掉之后再查也是关",
|
||||||
"不允许" in feats.run("allow_new_pairing", {}))
|
"不允许" in feats.run("allow_new_pairing", {}))
|
||||||
@@ -173,7 +205,6 @@ def main() -> int:
|
|||||||
check("能打开", "已允许" in out, "拿到 %r" % out)
|
check("能打开", "已允许" in out, "拿到 %r" % out)
|
||||||
check(" 存储里也变了", st.get("allow_new_pairing") is True)
|
check(" 存储里也变了", st.get("allow_new_pairing") is True)
|
||||||
|
|
||||||
# 不存在名字要报错(别静默成功)
|
|
||||||
try:
|
try:
|
||||||
feats.run("allow_new_pairing_typo", {})
|
feats.run("allow_new_pairing_typo", {})
|
||||||
check("不认识的名字会报错", False, "它竟然没报错")
|
check("不认识的名字会报错", False, "它竟然没报错")
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -554,6 +554,20 @@ $stageOut = & python (Join-Path $root "_tests\test_quickbar.py") 2>&1
|
|||||||
Show-Fails $stageOut
|
Show-Fails $stageOut
|
||||||
Record "手表快捷栏" ($LASTEXITCODE -eq 0)
|
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
|
# ---------------------------------------------------------------- 3a5
|
||||||
Banner "3a5/9 局域网/中转对比基准(防测试自己骗人)"
|
Banner "3a5/9 局域网/中转对比基准(防测试自己骗人)"
|
||||||
# 这个基准一度是红的,而且报告里写着「这是中转链路的产品 bug」——
|
# 这个基准一度是红的,而且报告里写着「这是中转链路的产品 bug」——
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.lanremote.watch"
|
package="com.lanremote.watch"
|
||||||
android:versionCode="5"
|
android:versionCode="5"
|
||||||
android:versionName="2.1.7">
|
android:versionName="2.1.8">
|
||||||
|
|
||||||
<!-- ⚠ 允许明文 HTTP。
|
<!-- ⚠ 允许明文 HTTP。
|
||||||
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
|
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat"
|
|||||||
|
|
||||||
$work = Join-Path $android "build"
|
$work = Join-Path $android "build"
|
||||||
$outDir = Join-Path $root "dist"
|
$outDir = Join-Path $root "dist"
|
||||||
$apkName = "ykxmz-watch-2.1.7.apk"
|
$apkName = "ykxmz-watch-2.1.8.apk"
|
||||||
|
|
||||||
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
|
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk"
|
|||||||
--min-sdk-version 24 `
|
--min-sdk-version 24 `
|
||||||
--target-sdk-version 34 `
|
--target-sdk-version 34 `
|
||||||
--version-code 5 `
|
--version-code 5 `
|
||||||
--version-name "2.1.7" `
|
--version-name "2.1.8" `
|
||||||
--auto-add-overlay
|
--auto-add-overlay
|
||||||
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
|
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
|
||||||
|
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
private final java.util.List<Button> quickButtons =
|
private final java.util.List<Button> quickButtons =
|
||||||
new java.util.ArrayList<Button>();
|
new java.util.ArrayList<Button>();
|
||||||
/** 设置页里那 3 个位置各放什么动作。null 表示这个位置空着。 */
|
/** 设置页里那 3 个位置各放什么动作。null 表示这个位置空着。 */
|
||||||
private String[] quickSlots = new String[]{"file", "mirror", "sfx"};
|
private String[] quickSlots =
|
||||||
|
new String[]{"file", "mirror", "sfx", "desktop"};
|
||||||
|
|
||||||
/** 快捷栏上能放的动作。id 会存进设置,**改名会让老配置失效**,
|
/** 快捷栏上能放的动作。id 会存进设置,**改名会让老配置失效**,
|
||||||
* 所以这几个英文 id 不要动,界面上显示的中文随便改。 */
|
* 所以这几个英文 id 不要动,界面上显示的中文随便改。 */
|
||||||
@@ -327,9 +328,18 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
{"power", "关机"},
|
{"power", "关机"},
|
||||||
{"tts", "语音播报"},
|
{"tts", "语音播报"},
|
||||||
{"image", "投图到电脑"},
|
{"image", "投图到电脑"},
|
||||||
|
// 用户最早说这一格"不能改",后来又改成四格都能换 ——
|
||||||
|
// 所以它现在是**普通可选动作**,只是默认放在第 4 格。
|
||||||
|
{"desktop", "显示桌面"},
|
||||||
};
|
};
|
||||||
private static final String QUICK_PREF = "quick_slots";
|
private static final String QUICK_PREF = "quick_slots";
|
||||||
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;
|
||||||
|
|
||||||
// ---------------- 鼠标控制(触摸板)----------------
|
// ---------------- 鼠标控制(触摸板)----------------
|
||||||
private FrameLayout mousePanel;
|
private FrameLayout mousePanel;
|
||||||
@@ -435,7 +445,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
mousePanel.setVisibility(View.GONE);
|
mousePanel.setVisibility(View.GONE);
|
||||||
// 快捷栏最后加 -> 永远在最上层
|
// 快捷栏最后加 -> 永远在最上层
|
||||||
root.addView(quickBar, new FrameLayout.LayoutParams(
|
root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30)));
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP)));
|
||||||
|
|
||||||
setContentView(root);
|
setContentView(root);
|
||||||
|
|
||||||
@@ -2435,10 +2445,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
settingsExtraBox.removeAllViews();
|
settingsExtraBox.removeAllViews();
|
||||||
|
|
||||||
settingsExtraBox.addView(sectionTitle("快捷栏"));
|
settingsExtraBox.addView(sectionTitle("快捷栏"));
|
||||||
settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。"
|
settingsExtraBox.addView(hintText("顶部那条栏,4 个格子"
|
||||||
+ "最后一个「显示桌面」是固定的、改不了;"
|
+ "**每一个都能换**,点下面自己选。"
|
||||||
+ "前三个点下面自己选。(还没连上电脑时它们是灰的)"));
|
+ "(还没连上电脑时它们是灰的)"));
|
||||||
for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {
|
for (int slot = 0; slot < QUICK_SLOTS; slot++) {
|
||||||
final int s = slot;
|
final int s = slot;
|
||||||
String now = (quickSlots[s] == null || quickSlots[s].isEmpty())
|
String now = (quickSlots[s] == null || quickSlots[s].isEmpty())
|
||||||
? "空着" : quickLabel(quickSlots[s]);
|
? "空着" : quickLabel(quickSlots[s]);
|
||||||
@@ -2451,10 +2461,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
settingsExtraBox.addView(smallButton(
|
settingsExtraBox.addView(smallButton(
|
||||||
"恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD,
|
"恢复默认(传文件 / 屏幕镜像 / 音效 / 显示桌面)", C_CARD,
|
||||||
new Runnable() {
|
new Runnable() {
|
||||||
public void run() {
|
public void run() {
|
||||||
quickSlots = new String[]{"file", "mirror", "sfx"};
|
quickSlots = new String[]{"file", "mirror", "sfx", "desktop"};
|
||||||
saveQuickSlots();
|
saveQuickSlots();
|
||||||
fillSettingsExtra();
|
fillSettingsExtra();
|
||||||
toast("快捷栏已恢复默认");
|
toast("快捷栏已恢复默认");
|
||||||
@@ -3001,7 +3011,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
* <p>快捷栏是贴在最上层的常驻元素(用户要求"不管干什么上面都有一栏"),
|
* <p>快捷栏是贴在最上层的常驻元素(用户要求"不管干什么上面都有一栏"),
|
||||||
* 面板内容如果不缩进就会被它盖住顶部一截。
|
* 面板内容如果不缩进就会被它盖住顶部一截。
|
||||||
*
|
*
|
||||||
* <p>集中在这里改,是因为"每个面板各写一遍 dp(30)"很容易漏一个 ——
|
* <p>集中在这里改,是因为"每个面板各写一遍高度"很容易漏一个 ——
|
||||||
* 表现就是"别的页正常,就某一页顶部被切掉"。
|
* 表现就是"别的页正常,就某一页顶部被切掉"。
|
||||||
*/
|
*/
|
||||||
private void applyPanelTopMargin(View panel) {
|
private void applyPanelTopMargin(View panel) {
|
||||||
@@ -3011,7 +3021,8 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
ViewGroup.LayoutParams lp = panel.getLayoutParams();
|
ViewGroup.LayoutParams lp = panel.getLayoutParams();
|
||||||
if (lp instanceof FrameLayout.LayoutParams) {
|
if (lp instanceof FrameLayout.LayoutParams) {
|
||||||
FrameLayout.LayoutParams flp = (FrameLayout.LayoutParams) lp;
|
FrameLayout.LayoutParams flp = (FrameLayout.LayoutParams) lp;
|
||||||
flp.topMargin = dp(30); // 跟 quickBar 的高度一致
|
// 用同一个常量 —— 栏高改了这里自动跟着改
|
||||||
|
flp.topMargin = dp(QUICK_BAR_DP);
|
||||||
panel.setLayoutParams(flp);
|
panel.setLayoutParams(flp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3025,10 +3036,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
|
|
||||||
quickButtons.clear();
|
quickButtons.clear();
|
||||||
// 4 个按钮:3 个自定义 + 1 个固定
|
// 4 个按钮:3 个自定义 + 1 个固定
|
||||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) {
|
for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||||
final int slot = i;
|
final int slot = i;
|
||||||
Button b = new Button(this);
|
Button b = new Button(this);
|
||||||
b.setTextSize(11f);
|
b.setTextSize(14f); // 11 太小,用户说"按不到"
|
||||||
b.setAllCaps(false);
|
b.setAllCaps(false);
|
||||||
b.setTextColor(0xFFFFFFFF);
|
b.setTextColor(0xFFFFFFFF);
|
||||||
b.setBackgroundColor(0x33FFFFFF);
|
b.setBackgroundColor(0x33FFFFFF);
|
||||||
@@ -3052,7 +3063,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
}
|
}
|
||||||
// 高度固定,免得按钮里的字多一个字整条栏就变高、页面跟着抖
|
// 高度固定,免得按钮里的字多一个字整条栏就变高、页面跟着抖
|
||||||
FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30));
|
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP));
|
||||||
barLp.gravity = android.view.Gravity.TOP;
|
barLp.gravity = android.view.Gravity.TOP;
|
||||||
quickBar.setLayoutParams(barLp);
|
quickBar.setLayoutParams(barLp);
|
||||||
|
|
||||||
@@ -3072,7 +3083,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
boolean connected = isConnected();
|
boolean connected = isConnected();
|
||||||
for (int i = 0; i < quickButtons.size(); i++) {
|
for (int i = 0; i < quickButtons.size(); i++) {
|
||||||
Button b = quickButtons.get(i);
|
Button b = quickButtons.get(i);
|
||||||
String id = (i < QUICK_CUSTOM_SLOTS) ? quickSlots[i] : "desktop";
|
String id = quickSlots[i];
|
||||||
if (id == null || id.isEmpty()) {
|
if (id == null || id.isEmpty()) {
|
||||||
b.setText("-"); // 这个位置空着
|
b.setText("-"); // 这个位置空着
|
||||||
b.setEnabled(false);
|
b.setEnabled(false);
|
||||||
@@ -3097,9 +3108,12 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 点第 slot 个按钮。0..2 是自定义,3 是那个固定的显示桌面。 */
|
/** 点第 slot 个按钮。四格完全平等,都从 quickSlots 里取。 */
|
||||||
private void onQuickClick(int slot) {
|
private void onQuickClick(int slot) {
|
||||||
String id = (slot < QUICK_CUSTOM_SLOTS) ? quickSlots[slot] : "desktop";
|
if (slot < 0 || slot >= quickSlots.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String id = quickSlots[slot];
|
||||||
if (id == null || id.isEmpty()) {
|
if (id == null || id.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3173,25 +3187,25 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
|||||||
private void loadQuickSlots() {
|
private void loadQuickSlots() {
|
||||||
String raw = prefs().getString(QUICK_PREF, "");
|
String raw = prefs().getString(QUICK_PREF, "");
|
||||||
String[] want = (raw == null || raw.isEmpty())
|
String[] want = (raw == null || raw.isEmpty())
|
||||||
? new String[]{"file", "mirror", "sfx"}
|
? new String[]{"file", "mirror", "sfx", "desktop"}
|
||||||
: raw.split(",", -1);
|
: raw.split(",", -1);
|
||||||
// ⚠ 一定要过滤 + 补齐:
|
// ⚠ 一定要过滤 + 补齐:
|
||||||
// 过滤 —— 万一以后删了某个动作,老配置里的 id 就不认识了,
|
// 过滤 —— 万一以后删了某个动作,老配置里的 id 就不认识了,
|
||||||
// 不过滤的话那格会显示空白,用户以为坏了
|
// 不过滤的话那格会显示空白,用户以为坏了
|
||||||
// 补齐 —— 保证长度正好 3,少了下标会越界
|
// 补齐 —— 保证长度正好 3,少了下标会越界
|
||||||
String[] out = new String[QUICK_CUSTOM_SLOTS];
|
String[] out = new String[QUICK_SLOTS];
|
||||||
int n = 0;
|
int n = 0;
|
||||||
for (String id : want) {
|
for (String id : want) {
|
||||||
if (n >= QUICK_CUSTOM_SLOTS) {
|
if (n >= QUICK_SLOTS) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (id != null && !id.isEmpty() && !"desktop".equals(id)
|
// ⚠ 这里**不再排除 desktop** —— 用户改成四格都能换了
|
||||||
&& isKnownQuickAction(id)) {
|
if (id != null && !id.isEmpty() && isKnownQuickAction(id)) {
|
||||||
out[n++] = id;
|
out[n++] = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
String[] fallback = {"file", "mirror", "sfx"};
|
String[] fallback = {"file", "mirror", "sfx", "desktop"};
|
||||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS; i++) {
|
for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||||
if (out[i] == null) {
|
if (out[i] == null) {
|
||||||
// 补一个没被用过的默认动作
|
// 补一个没被用过的默认动作
|
||||||
for (String f : fallback) {
|
for (String f : fallback) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz
|
软件名称:ykxmz
|
||||||
版本:2.1.7
|
版本:2.1.8
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
|
|
||||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
八、版本记录
|
八、版本记录
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|
||||||
2.1.7
|
2.1.8
|
||||||
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
||||||
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
||||||
合计 10 条连接),一种协议犯病另一种还在服务
|
合计 10 条连接),一种协议犯病另一种还在服务
|
||||||
@@ -218,6 +218,6 @@
|
|||||||
|
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
软件:ykxmz 2.1.7
|
软件:ykxmz 2.1.8
|
||||||
许可条款见《许可协议》(LICENSE.txt)
|
许可条款见《许可协议》(LICENSE.txt)
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz
|
软件名称:ykxmz
|
||||||
版本:2.1.7
|
版本:2.1.8
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
|
|
||||||
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
||||||
@@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
|||||||
|
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
软件:ykxmz 2.1.7
|
软件:ykxmz 2.1.8
|
||||||
继续安装,即表示你已阅读并同意《许可协议》。
|
继续安装,即表示你已阅读并同意《许可协议》。
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz(远程管理工具)
|
软件名称:ykxmz(远程管理工具)
|
||||||
版本:2.1.7
|
版本:2.1.8
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
生效日期:2026 年 10 月 5 日
|
生效日期:2026 年 10 月 5 日
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
> 都不许拿去做下面第三条那些事。**
|
> 都不许拿去做下面第三条那些事。**
|
||||||
>
|
>
|
||||||
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
|
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
|
||||||
> 那和 AGPL 冲突,所以 2.1.7 起拆开了:代码授权归 LICENSE,
|
> 那和 AGPL 冲突,所以 2.1.8 起拆开了:代码授权归 LICENSE,
|
||||||
> 使用约束归这份文件。)
|
> 使用约束归这份文件。)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
|
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
软件:ykxmz 2.1.7
|
软件:ykxmz 2.1.8
|
||||||
|
|
||||||
如果你不同意本条款的任何一条,请不要安装或使用本软件。
|
如果你不同意本条款的任何一条,请不要安装或使用本软件。
|
||||||
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
|
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
|
||||||
|
|||||||
+40
-10
@@ -4,19 +4,49 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、发布流程:先给用户,用户验完才推送
|
## 一、发布流程:按「预案」执行
|
||||||
|
|
||||||
> 用户原话:「你以后就这样,你先把所有东西给我,我运行没问题再告诉你帮我推送」
|
> 用户原话:「以后我会说按照预案几处理」
|
||||||
|
>
|
||||||
|
> 用户原话(预案一):「做完以后测试,没问题上传」
|
||||||
|
> 用户原话(预案二):「做完以后测试,测试完以后别上传,直接给我」
|
||||||
|
|
||||||
**每次发版必须按这个顺序:**
|
**用户会明确说"按预案几",说了就按那个走。**
|
||||||
|
|
||||||
1. 本地把**所有产物**构建完成
|
| 预案 | 做什么 |
|
||||||
2. 跑测试(全量回归 + 安装包周期 + 发布验收)
|
|---|---|
|
||||||
3. **把产物交给用户**去验证
|
| **预案一** | 做完 → **测试** → 没问题 → **上传**(创建 Gitee 发布 + 传附件 + 部署到服务器) |
|
||||||
4. **等用户明确说「可以推」之后**,才创建 Gitee 发布 / 上传发布附件
|
| **预案二** | 做完 → **测试** → **不上传**,产物交给用户 |
|
||||||
|
|
||||||
❌ **绝对不能**自己构建完就直接建发布。
|
### 两个预案共同的部分(都必须做)
|
||||||
✅ 代码提交到 `main` 是可以的(那不是"发布")。
|
|
||||||
|
1. 本地把**所有产物**构建完成(APK / 安装包 / 服务端包)
|
||||||
|
2. 跑测试:全量回归 + 安装包周期 + 发布验收
|
||||||
|
3. **测试不绿就停**,先说清楚哪儿红了 —— 不许带着红测试交付
|
||||||
|
|
||||||
|
### 只有预案一才做的部分
|
||||||
|
|
||||||
|
4. 提交 `main` + 推送
|
||||||
|
5. 创建 Gitee 发布、上传三个附件
|
||||||
|
6. 部署到服务器(`_build/deploy_lightsail_release.py`),并**实测**线上:
|
||||||
|
|
||||||
|
- `/v1/update/latest` 返回的版本 = 刚发的
|
||||||
|
- 官网角标 = 刚发的
|
||||||
|
- 安装包能下(看真实字节数)
|
||||||
|
- `/v1/health` 正常
|
||||||
|
|
||||||
|
### 用户没说预案几的时候
|
||||||
|
|
||||||
|
**默认按预案一**(那是长期以来的既有习惯),但要在交付时**说明**
|
||||||
|
"我按预案一做了,已经上传",让用户有机会纠正。
|
||||||
|
|
||||||
|
### 历史(这条规矩怎么来的)
|
||||||
|
|
||||||
|
最早用户说「你先把所有东西给我,我运行没问题再告诉你帮我推送」,
|
||||||
|
中间放宽过一次(「你自己上传就行了」),后来又要"自己测完再上传"。
|
||||||
|
现在固化成两个预案,由用户每次指定。
|
||||||
|
|
||||||
|
**仍然绝对不做的**:不测就上传。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,7 +63,7 @@
|
|||||||
- **我(AI)在对话里给出的总结、汇报、方案**
|
- **我(AI)在对话里给出的总结、汇报、方案**
|
||||||
- 任何我会贴给用户看、或用户可能转给别人的材料
|
- 任何我会贴给用户看、或用户可能转给别人的材料
|
||||||
|
|
||||||
**只能提官网 `https://relay.xxmxxs.top`。**
|
**只能提官网 `https://yk.xxmxxs.top`。**
|
||||||
|
|
||||||
**不要说**:`/zhongzhuanzhan`、`/admin`、"管理后台"、"管理页"、
|
**不要说**:`/zhongzhuanzhan`、`/admin`、"管理后台"、"管理页"、
|
||||||
"管理入口"、管理密码之类。
|
"管理入口"、管理密码之类。
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz(远程管理工具)
|
软件名称:ykxmz(远程管理工具)
|
||||||
版本:2.1.7
|
版本:2.1.8
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
生效日期:2026 年 10 月 5 日
|
生效日期:2026 年 10 月 5 日
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
> 都不许拿去做下面第三条那些事。**
|
> 都不许拿去做下面第三条那些事。**
|
||||||
>
|
>
|
||||||
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
|
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
|
||||||
> 那和 AGPL 冲突,所以 2.1.7 起拆开了:代码授权归 LICENSE,
|
> 那和 AGPL 冲突,所以 2.1.8 起拆开了:代码授权归 LICENSE,
|
||||||
> 使用约束归这份文件。)
|
> 使用约束归这份文件。)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
|
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
软件:ykxmz 2.1.7
|
软件:ykxmz 2.1.8
|
||||||
|
|
||||||
如果你不同意本条款的任何一条,请不要安装或使用本软件。
|
如果你不同意本条款的任何一条,请不要安装或使用本软件。
|
||||||
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
|
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz
|
软件名称:ykxmz
|
||||||
版本:2.1.7
|
版本:2.1.8
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
|
|
||||||
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
||||||
@@ -334,7 +334,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
|||||||
|
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
软件:ykxmz 2.1.7
|
软件:ykxmz 2.1.8
|
||||||
继续安装,即表示你已阅读并同意《许可协议》。
|
继续安装,即表示你已阅读并同意《许可协议》。
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|
||||||
@@ -345,7 +345,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz
|
软件名称:ykxmz
|
||||||
版本:2.1.7
|
版本:2.1.8
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
|
|
||||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||||
@@ -539,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
|||||||
八、版本记录
|
八、版本记录
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|
||||||
2.1.7
|
2.1.8
|
||||||
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
||||||
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
||||||
合计 10 条连接),一种协议犯病另一种还在服务
|
合计 10 条连接),一种协议犯病另一种还在服务
|
||||||
@@ -560,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
|||||||
|
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
软件:ykxmz 2.1.7
|
软件:ykxmz 2.1.8
|
||||||
许可条款见《许可协议》(LICENSE.txt)
|
许可条款见《许可协议》(LICENSE.txt)
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|||||||
+2
-2
@@ -27,7 +27,7 @@
|
|||||||
; ============================================================================
|
; ============================================================================
|
||||||
|
|
||||||
#define MyAppName "ykxmz"
|
#define MyAppName "ykxmz"
|
||||||
#define MyAppVersion "2.1.7"
|
#define MyAppVersion "2.1.8"
|
||||||
#define MyAppPublisher "小麦子"
|
#define MyAppPublisher "小麦子"
|
||||||
#define MyAppExeName "ykxmz.exe"
|
#define MyAppExeName "ykxmz.exe"
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \
|
|||||||
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
|
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
|
||||||
; (PowerShell 的 Start-Process -Wait 就是)。
|
; (PowerShell 的 Start-Process -Wait 就是)。
|
||||||
; 学校批量部署请用 cmd:
|
; 学校批量部署请用 cmd:
|
||||||
; cmd /c ykxmz-setup-2.1.7.exe /VERYSILENT /CODE=123456
|
; cmd /c ykxmz-setup-2.1.8.exe /VERYSILENT /CODE=123456
|
||||||
; cmd 只等安装器本身,不等孙子进程,不会卡。
|
; cmd 只等安装器本身,不等孙子进程,不会卡。
|
||||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
||||||
Description: "立即启动(后台运行,不显示窗口)"; \
|
Description: "立即启动(后台运行,不显示窗口)"; \
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ from collections import deque
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "2.1.7"
|
VERSION = "2.1.8"
|
||||||
|
|
||||||
# ---------------- 参数(都能用环境变量覆盖)----------------
|
# ---------------- 参数(都能用环境变量覆盖)----------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""局域网遥控 · Windows 被控端。"""
|
"""局域网遥控 · Windows 被控端。"""
|
||||||
|
|
||||||
__version__ = "2.1.7"
|
__version__ = "2.1.8"
|
||||||
APP_NAME = "ykxmz"
|
APP_NAME = "ykxmz"
|
||||||
|
|||||||
+180
-34
@@ -538,59 +538,205 @@ class Actions:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def list_voices(self) -> list[str]:
|
def list_voices(self) -> list[str]:
|
||||||
"""列出系统可用的 TTS 音色。"""
|
"""列出系统可用的 TTS 音色。
|
||||||
|
|
||||||
|
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话
|
||||||
|
System.Speech 那条路列不出来,而 SAPI5 一定有。
|
||||||
|
"""
|
||||||
if not IS_WINDOWS:
|
if not IS_WINDOWS:
|
||||||
return []
|
return []
|
||||||
script = (
|
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;"
|
"Add-Type -AssemblyName System.Speech;"
|
||||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"
|
".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 []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 语音播报(系统自带 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:
|
try:
|
||||||
proc = subprocess.run(
|
vbs = self._write_vbs(text, voice, rate)
|
||||||
["powershell", "-NoProfile", "-Command", script],
|
except Exception: # noqa: BLE001
|
||||||
capture_output=True, timeout=20,
|
vbs = None
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
if vbs is not None:
|
||||||
encoding="mbcs", errors="replace")
|
# 1 / 2:完全不依赖 PowerShell 和 .NET
|
||||||
except Exception:
|
cmds.append(("cscript", ["cscript", "//nologo", str(vbs)]))
|
||||||
return []
|
cmds.append(("wscript", ["wscript", "//nologo", str(vbs)]))
|
||||||
return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]
|
|
||||||
|
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 say(self, text: str, voice: str = "", rate: int = 0) -> str:
|
def say(self, text: str, voice: str = "", rate: int = 0) -> str:
|
||||||
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。"""
|
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。
|
||||||
|
|
||||||
|
⚠ 会**等播报结束**并看退出码,失败就抛错。
|
||||||
|
原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。
|
||||||
|
代价是同步等待(念多久等多久),但"报成功其实没出声"
|
||||||
|
比"慢一点"糟糕得多。
|
||||||
|
"""
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
raise ActionError("播报内容为空")
|
raise ActionError("播报内容为空")
|
||||||
if not IS_WINDOWS:
|
if not IS_WINDOWS:
|
||||||
raise ActionError("语音播报目前仅支持 Windows")
|
raise ActionError("语音播报目前仅支持 Windows")
|
||||||
|
|
||||||
chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip()
|
chosen = (voice or "").strip() or str(
|
||||||
|
self.store.get("tts_voice") or "").strip()
|
||||||
try:
|
try:
|
||||||
speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0)
|
speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
speed = 0
|
speed = 0
|
||||||
speed = max(-10, min(10, speed))
|
speed = max(-10, min(10, speed))
|
||||||
|
|
||||||
safe = text.replace("'", "''")
|
cmds = self._tts_commands(text, chosen, speed)
|
||||||
parts = [
|
if not cmds:
|
||||||
"Add-Type -AssemblyName System.Speech;",
|
raise ActionError("这台电脑上找不到可用的语音播报方式"
|
||||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;",
|
"(没有 cscript 也没有 powershell?)")
|
||||||
]
|
|
||||||
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(
|
cached = globals().get("_TTS_LAST_OK")
|
||||||
["powershell", "-NoProfile", "-WindowStyle", "Hidden",
|
if cached:
|
||||||
"-Command", script],
|
cmds.sort(key=lambda c: 0 if c[0] == cached else 1)
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
||||||
except Exception as exc:
|
tried = []
|
||||||
raise ActionError(f"TTS 启动失败: {exc}") from exc
|
for name, cmd in cmds:
|
||||||
return f"已播报({chosen or '默认音色'})"
|
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))
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 播放本机音频文件(MP3 等)
|
# 播放本机音频文件(MP3 等)
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ VSVersionInfo(
|
|||||||
[
|
[
|
||||||
StringStruct("CompanyName", "ykxmz"),
|
StringStruct("CompanyName", "ykxmz"),
|
||||||
StringStruct("FileDescription", "ykxmz"),
|
StringStruct("FileDescription", "ykxmz"),
|
||||||
StringStruct("FileVersion", "2.1.7.0"),
|
StringStruct("FileVersion", "2.1.8.0"),
|
||||||
StringStruct("InternalName", "ykxmz"),
|
StringStruct("InternalName", "ykxmz"),
|
||||||
StringStruct("LegalCopyright", "ykxmz"),
|
StringStruct("LegalCopyright", "ykxmz"),
|
||||||
StringStruct("OriginalFilename", "ykxmz.exe"),
|
StringStruct("OriginalFilename", "ykxmz.exe"),
|
||||||
StringStruct("ProductName", "ykxmz"),
|
StringStruct("ProductName", "ykxmz"),
|
||||||
StringStruct("ProductVersion", "2.1.7.0"),
|
StringStruct("ProductVersion", "2.1.8.0"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user