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