3 Commits
Author SHA1 Message Date
xmz7891 1d368979aa 2.2.0:手表端连上后先到「远控 / 静默更新被控端」选择页
== 用户要求 ==

"安卓那个逻辑也要改一下,就是你输入配对码后,进入一个页面,
 页面有两个按钮,第一个按钮是远控,第二个按钮是静默更新被控端"

== 做成什么样 ==

新增 choicePanel,连接成功后先进这一页:

    已连接「电脑名」
    电脑端 2.1.5 → 可更新到 2.2.0

    [        远控        ]   <- 第一个按钮
    [   静默更新被控端    ]   <- 第二个按钮

按钮顺序按用户说的来(远控在前)。

页面顶上显示电脑名和**电脑端当前版本** —— 用户之前要求
"手表端连接那个电脑端也可以查看电脑端目前所处的版本",
这里是最自然的展示位置(不弹框,只显示一行)。

为什么不让它直接进控制页:老版本的被控端可能缺功能或有 bug,
"先更新再远控"是更合理的顺序 —— 但不强制,给用户自己选。

== 一个刻意不改的地方 ==

自动切到中转服务器**恢复**时的 `showControlPanel()` **没动** ——
用户本来就在远控页,中途弹一层选择页会打断他。
测试里专门断言了这一点,免得以后有人"顺手统一一下"改坏。

== 顺手修的几个问题 ==

1. **忘了定义 lastDeviceName** —— 写选择页时凭直觉用了这个变量,
   编译报"找不到符号"。补上字段并在连接回调里赋值。
   (教训:写代码时要用的变量,先确认它存在。)

2. **泄漏闸门拦下我自己** —— 这两天写的一批服务器诊断/配置脚本里
   硬编码了服务器 IP(26 处,5 个文件)。规则是 IP 不能进任何
   被跟踪的文件。已全部改成占位符,真实值仍从
   _build/.lightsail_host(已 gitignore)读。

3. **verify_all.ps1 删掉「锁屏时记 SKIP」的逻辑** ——
   用户明确要求删。那个好意带来一个更糟的后果:锁屏时几关根本不跑,
   回归"全绿"了但关键测试其实一跳而过。
   **"因为环境不满足所以不测"比"测了发现失败"更危险。**
   删掉之后果然暴露出 3 个 FAIL —— 屏幕解锁后重跑全部通过,
   确认是环境问题而不是代码问题。

== 服务器这边(不在这份提交里,但同一天做的)==

  * nginx 按用户要求重排:
      IP(80) 任何路径 -> 404(没东西)
      域名 / -> 官网;/zhongzhuanzhan -> 中转站设置
      9100 -> 同样的内容(等 AWS 开防火墙)
  * 删掉早年 Cloudflare Tunnel 的 systemd 残留
  * **补上 ACME 挑战例外** —— 这是最要紧的一条:
    80 端口原来把所有请求都 301 到 https,包括
    /.well-known/acme-challenge/,**续期时会失败**。
    而且它是延迟爆发的(最长 90 天后证书到期才暴露)。
  * 清理 1Panel 安装包残留(223MB)

== 验证 ==

    全量回归 21 关全部 PASS(零 FAIL,桌面可用状态)
    Java 编译通过(android-34, --release 8)
    623+ Python 文件语法通过
    泄漏闸门通过
    全部产物重建,Win7 兼容校验通过
2026-10-10 17:13:24 +08:00
xmz7891 48bc392466 删掉临时提交说明 2026-10-09 23:30:05 +08:00
xmz7891 fd8be87209 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 兼容校验通过
2026-10-09 23:30:05 +08:00
37 changed files with 3912 additions and 450 deletions
+8 -8
View File
@@ -45,9 +45,9 @@
| 装在哪 | 是什么 | 文件 | | 装在哪 | 是什么 | 文件 |
|---|---|---| |---|---|---|
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.7.exe` | | 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.2.0.exe` |
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.7.zip` | | 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.2.0.zip` |
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.7.apk` | | 手表(安卓) | 控制端 | `ykxmz-watch-2.2.0.apk` |
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。 **只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
@@ -81,7 +81,7 @@
### 第一步:电脑上装被控端 ### 第一步:电脑上装被控端
双击 `ykxmz-setup-2.1.7.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。 双击 `ykxmz-setup-2.2.0.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
第一次运行会弹出窗口,填两样: 第一次运行会弹出窗口,填两样:
@@ -94,12 +94,12 @@
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。 > 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
> 想彻底退出,窗口上点「退出」。 > 想彻底退出,窗口上点「退出」。
> 不想用安装包的话,`ykxmz-agent-2.1.7.zip` 是绿色版: > 不想用安装包的话,`ykxmz-agent-2.2.0.zip` 是绿色版:
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。 > 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
### 第二步:手表上装 App ### 第二步:手表上装 App
把 `ykxmz-watch-2.1.7.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 把 `ykxmz-watch-2.2.0.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.2.0.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.2.0.apk
``` ```
最低支持 Android 7.0(API 24),targetSdk 34。 最低支持 Android 7.0(API 24),targetSdk 34。
+49
View File
@@ -1,3 +1,52 @@
## 2.2.0
- **手表端:输入配对码连上之后,先到一个选择页** ——
上面有两个按钮:
1. **远控** —— 进控制页(和以前一样)
2. **静默更新被控端** —— 让那台电脑自己升级到最新版
页面顶上会显示**已连接的电脑名**和**电脑端当前版本**
(比如「电脑端 2.1.5 → 可更新到 2.2.0」),一眼就知道要不要更新。
**为什么不直接进控制页**:老版本的被控端可能缺功能或者有 bug,
「先更新再远控」是更合理的顺序 —— 但也不强制,所以给用户自己选。
(自动切到中转服务器恢复时**不会**弹这个页面,还是直接回控制页 ——
用户本来就在用,中途弹一层会打断他。)
## 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 个按钮:
+323
View File
@@ -0,0 +1,323 @@
# -*- coding: utf-8 -*-
r'''手表端:输入配对码连上之后,先到一个「选择页」。
== 用户要求(原话)==
"安卓那个逻辑也要改一下,就是你输入配对码后,进入一个页面,
页面有两个按钮,第一个按钮是远控,第二个按钮是静默更新被控端"
== 现状 ==
连接成功后直接 `showControlPanel()`(进控制页)。用户根本没机会
先升级被控端 —— 而老版本的被控端可能缺功能、有 bug,
"先更新再远控"其实是更合理的顺序。
== 改法 ==
新增一个 `choicePanel`:
┌──────────────────────────┐
│ 已连接「电脑名」 │
│ 电脑端版本 2.1.5 │ <- 一眼看到要不要更新
│ │
│ ┌────────────────────┐ │
│ │ 远控 │ │ <- 第一个按钮
│ └────────────────────┘ │
│ ┌────────────────────┐ │
│ │ 静默更新被控端 │ │ <- 第二个按钮
│ └────────────────────┘ │
└──────────────────────────┘
**按钮顺序按用户说的来**:远控在前(主操作),更新在后。
之后:
* 连接成功(`showControlPanel()` 那个调用点)改成 `showChoicePanel()`
* **自动切中转恢复**那个调用点**不改** —— 用户本来就在远控页,
中途弹一个选择页会打断他
* 「静默更新被控端」复用已经做好的 `checkPcUpdate()`
(查版本 -> 确认框 -> 触发更新)
* 版本号显示在这一页上:用户说"手表端连接那个电脑端也可以查看
电脑端目前所处的版本",这里是最自然的展示位置
== 一些细节 ==
* 顶部快捷栏在这一页照样在(用户要求"不管干什么上面都有一栏")
* 面板要加进 `showOnly` 的数组,否则它会永远压在最上面
* 也要 `applyPanelTopMargin`,否则顶部被快捷栏盖住
* **版本号是异步查的**:连上后立刻查一次;查回来之前显示"正在查…"。
不查的话用户不知道要不要点更新。
'''
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"
# ---- 1. 字段 ----
F_ANCHOR = " private FrameLayout mousePanel;"
F_NEW = ''' private FrameLayout mousePanel;
/**
* 连上之后先给用户看的那一页:远控 / 静默更新被控端。
*
* <p>用户要求:"输入配对码后,进入一个页面,页面有两个按钮,
* 第一个按钮是远控,第二个按钮是静默更新被控端"。
*
* <p>为什么不让它直接进控制页:老版本的被控端可能缺功能或者有 bug,
* "先更新再远控"是更合理的顺序 —— 但也不能强制,所以给用户选。
*/
private FrameLayout choicePanel;
/** 选择页上显示"电脑端版本 X.Y.Z"那一行。 */
private TextView choiceVersionText;
/** 选择页上显示"已连接 XXX"。 */
private TextView choiceWhoText;'''
# ---- 2. 建面板的方法,插在 showOnly 之前 ----
M_ANCHOR = " private void showOnly(View keep) {"
M_NEW = ''' // ==================================================================
// 连接后的选择页(远控 / 静默更新)
// ==================================================================
/**
* 建选择页。
*
* <p>布局从上到下:连接信息 -> 版本 -> [远控] -> [静默更新被控端]。
* 按钮顺序就是用户说的顺序(远控在前)。
*/
private void buildChoicePanel() {
choicePanel = new FrameLayout(this);
choicePanel.setBackgroundColor(C_BG);
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setPadding(dp(14), dp(20), dp(14), dp(14));
box.setGravity(android.view.Gravity.CENTER_HORIZONTAL);
choicePanel.addView(box, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
choiceWhoText = new TextView(this);
choiceWhoText.setText("已连接");
choiceWhoText.setTextColor(C_FG);
choiceWhoText.setTextSize(14f);
choiceWhoText.setGravity(android.view.Gravity.CENTER);
box.addView(choiceWhoText);
choiceVersionText = new TextView(this);
choiceVersionText.setText("电脑端版本:正在查…");
choiceVersionText.setTextColor(C_DIM);
choiceVersionText.setTextSize(12f);
choiceVersionText.setGravity(android.view.Gravity.CENTER);
choiceVersionText.setPadding(0, dp(4), 0, dp(10));
box.addView(choiceVersionText);
// 第一个按钮:远控
Button ctrl = bigButton("远控", C_OK, 17);
LinearLayout.LayoutParams ctrlLp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(54));
ctrlLp.setMargins(0, 0, 0, dp(10));
ctrl.setLayoutParams(ctrlLp);
ctrl.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showControlPanel();
}
});
box.addView(ctrl);
// 第二个按钮:静默更新被控端
Button upd = bigButton("静默更新被控端", C_ACCENT, 15);
LinearLayout.LayoutParams updLp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(54));
upd.setLayoutParams(updLp);
upd.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 复用已经做好的那条链路:查版本 -> 确认 -> 触发更新
checkPcUpdate();
}
});
box.addView(upd);
TextView tip = new TextView(this);
tip.setText("更新时电脑会自动下载并静默安装,"
+ "不会弹任何窗口,设置和配对码都不变。");
tip.setTextColor(C_DIM);
tip.setTextSize(10f);
tip.setGravity(android.view.Gravity.CENTER);
tip.setPadding(0, dp(10), 0, 0);
box.addView(tip);
}
/** 显示选择页,并顺手查一下电脑端版本。 */
private void showChoicePanel() {
if (choicePanel == null) {
buildChoicePanel();
}
if (choiceWhoText != null) {
String who = lastDeviceName;
choiceWhoText.setText(who == null || who.isEmpty()
? "已连接" : "已连接「" + who + "」");
}
if (choiceVersionText != null) {
choiceVersionText.setText("电脑端版本:正在查…");
}
showOnly(choicePanel);
queryPcVersionForChoice();
}
/**
* 查电脑端版本填到选择页上。
*
* <p>单独写一个(不直接用 checkPcUpdate)是因为那个会弹确认框 ——
* 这里只是"让你看一眼版本",不该弹东西。
*/
private void queryPcVersionForChoice() {
io.execute(new Runnable() {
@Override
public void run() {
Map<String, Object> reply;
try {
reply = client.call(Json.obj("cmd", "update",
"action", "check"), 25000);
} catch (final Throwable error) {
reply = null;
}
final String text;
if (reply != null && Boolean.TRUE.equals(reply.get("ok"))) {
String cur = String.valueOf(reply.get("current"));
String latest = String.valueOf(reply.get("latest"));
text = Boolean.TRUE.equals(reply.get("needUpdate"))
? "电脑端 " + cur + " → 可更新到 " + latest
: "电脑端 " + cur + "(已是最新)";
} else {
text = "电脑端版本:查不到(可以点下面的更新按钮再试)";
}
ui.post(new Runnable() {
@Override
public void run() {
if (choiceVersionText != null) {
choiceVersionText.setText(text);
}
}
});
}
});
}
'''
# ---- 3. showOnly 数组 ----
O_SO = ''' View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,
settingsPanel, mousePanel};'''
N_SO = ''' View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,
settingsPanel, mousePanel, choicePanel};'''
# ---- 4. onCreate:建 + 挂 + 让位 ----
O_OC = ''' loadQuickSlots();
buildMousePanel(); // 只建,先不显示(showOnly 会切)
buildQuickBar();'''
N_OC = ''' loadQuickSlots();
buildMousePanel(); // 只建,先不显示(showOnly 会切)
buildChoicePanel(); // 同上:连上之后才显示
buildQuickBar();'''
O_OC2 = ''' applyPanelTopMargin(mousePanel);'''
N_OC2 = ''' applyPanelTopMargin(mousePanel);
applyPanelTopMargin(choicePanel);'''
O_OC3 = ''' root.addView(mousePanel, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
mousePanel.setVisibility(View.GONE);'''
N_OC3 = ''' root.addView(mousePanel, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
mousePanel.setVisibility(View.GONE);
root.addView(choicePanel, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
choicePanel.setVisibility(View.GONE);'''
# ---- 5. 连接成功后改成进选择页 ----
O_CONN = ''' showControlPanel();
appendLog("已连接到 " + host);
toast("连接成功");'''
N_CONN = ''' // 用户要求:输入配对码连上之后先到选择页
// (远控 / 静默更新被控端),不直接进控制页。
//
// ⚠ 只有**这个**调用点改。下面"自动切到中转恢复"
// 那个 showControlPanel() 不改 —— 用户本来就在
// 远控页,中途弹选择页会打断他。
showChoicePanel();
appendLog("已连接到 " + host);
toast("连接成功");'''
PAIRS = [
("字段", F_ANCHOR, F_NEW),
("建面板方法", M_ANCHOR, M_NEW + M_ANCHOR),
("showOnly 数组", O_SO, N_SO),
("onCreate 建面板", O_OC, N_OC),
("onCreate 让位", O_OC2, N_OC2),
("onCreate 挂面板", O_OC3, N_OC3),
("连接成功后进选择页", O_CONN, N_CONN),
]
def main() -> int:
print("=" * 58)
print(" 手表端:连上后先到「远控 / 静默更新」选择页")
print("=" * 58)
print()
raw = MAIN.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
t = raw.decode("utf-8-sig")
if "buildChoicePanel" in t:
print(" 已经改过了")
return 0
missing = [(n, o.strip().splitlines()[0][:60]) for n, o, _w in PAIRS
if o not in t]
if missing:
print(" ✗ 这些锚点找不到:")
for n, s in missing:
print(" %s: %s" % (n, s))
return 1
print(" ✓ %d 个锚点全部就位" % len(PAIRS))
for _n, old, new in PAIRS:
t = t.replace(old, new, 1)
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
print(" ✓ 加了 choicePanel + buildChoicePanel + showChoicePanel")
print(" ✓ 连接成功后改成进选择页(自动切中转那个没动)")
# 复核:确认"自动切中转"那个调用点没被误改
back = MAIN.read_text(encoding="utf-8")
n_choice = back.count("showChoicePanel();")
n_ctrl = back.count("showControlPanel();")
print()
print(" 复核:showChoicePanel 调用 %d 处(应 >=2:定义内 + 连接成功处)"
% n_choice)
print(" showControlPanel 调用 %d 处(自动切中转那个应该还在)"
% n_ctrl)
if "已切到中转服务器,连接恢复" in back:
i = back.find("已切到中转服务器,连接恢复")
seg = back[i:i + 200]
ok = "showControlPanel();" in seg
print(" [%s] 自动切中转恢复后仍是 showControlPanel" %
("PASS" if ok else "FAIL"))
if not ok:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+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())
+96
View File
@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
r'''清理服务器上没用的东西。
== 用户要求 ==
"所有没的东西就删了"
== 查到什么没用 ==
1. **1Panel 的安装包残留**(占 223MB)
/root/1panel-v2.3.2-linux-amd64.tar.gz 63MB
/root/1panel-v2.3.2-linux-amd64/ 160MB(解压出来的)
1Panel 早就装好并跑着了,这两个是**装完就没用**的东西。
磁盘现在只用了 7%,不急着腾空间,但"没的东西就删"是用户明确要求。
2. **早年的隧道单元**(上一轮已删)
lanremote-tunnel / cloudflared
3. **旧的抢救报告**(保留最近几份,老的删掉)
== 什么不删 ==
* /opt/ykxmz-relay/ 下的 relay.py / landing.py / admin.py / monitor.sh
—— 这些是**正在运行的东西**
* /opt/ykxmz-relay/data/ —— 中转的配置和数据库
* /opt/ykxmz-relay/downloads/ —— 安装包,用户要下载的
* /opt/ykxmz-relay/rescue_reports/ 最近几份 —— 排查问题时有用
* 1Panel 本体(/root/.docker、容器)—— 用户自己在用
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
REMOTE = r'''
echo "==== 清理前 ===="
df -h / | tail -1 | sed 's/^/ /'
echo
echo "==== 删 1Panel 安装包残留 ===="
du -sh /root/1panel-v2.3.2-linux-amd64* 2>/dev/null | sed 's/^/ 删前: /'
sudo rm -rf /root/1panel-v2.3.2-linux-amd64
sudo rm -f /root/1panel-v2.3.2-linux-amd64.tar.gz
echo " 删完了,还在吗: $(ls -d /root/1panel-v2.3.2-linux-amd64* 2>/dev/null || echo '不在了 ✓')"
echo
echo "==== 清理旧的抢救报告(保留最近 5 份)===="
cd /opt/ykxmz-relay/rescue_reports 2>/dev/null && {
total=$(ls -1 *.txt 2>/dev/null | wc -l)
echo " 一共 $total 份"
ls -1t *.txt 2>/dev/null | tail -n +6 | while read f; do
echo " 删 $f"
rm -f "$f"
done
echo " 剩下 $(ls -1 *.txt 2>/dev/null | wc -l) 份"
} || echo " (没有这个目录)"
echo
echo "==== 清 nginx 的旧日志(保留最近,压缩老的)===="
sudo find /var/log/nginx -name "*.log.*" -size +10M 2>/dev/null | head -5 | sed 's/^/ 大日志: /'
echo
echo "==== 清理后 ===="
df -h / | tail -1 | sed 's/^/ /'
echo
echo "==== 确认重要的东西都还在 ===="
for f in /opt/ykxmz-relay/relay.py /opt/ykxmz-relay/monitor.sh \
/opt/ykxmz-relay/landing.py /opt/ykxmz-relay/admin.py; do
[ -f "$f" ] && echo " ✓ $f" || echo " ✗ 不见了!$f"
done
echo " downloads 里的安装包: $(ls -1 /opt/ykxmz-relay/downloads/ 2>/dev/null | wc -l) 个"
echo
echo "==== 服务状态复核 ===="
for s in nginx ykxmz-relay ykxmz-monitor.timer; do
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>/dev/null)"
done
'''
def main() -> int:
print("=" * 62)
print(" 清理服务器上没用的东西")
print("=" * 62)
print()
out = run(REMOTE, timeout=300, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+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())
+104
View File
@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
r'''查:下午的中断 + 还有哪些没用的东西。
== 为什么要查"下午的中断" ==
用户:"下午网站有一段时间停了"。
已知的硬事实:
* relay 进程从 10/9 15:32 起没重启过
* nginx 从 10/8 14:57 起没重启过
* 系统从 10/8 13:26 起没重启过
* 今天 nginx 的 error.log **一条都没有**
所以**服务端本身没停**。那"停"是什么?几种可能:
A. **Cloudflare 侧的问题**(边缘节点、回源失败)——
服务端日志里看不到,得从监控的探测记录里找
B. **网络抖动**(Lightsail 出网、校园网)
C. **用户那边打不开**(DNS 缓存、本地网络)
监控每 60 秒探测一次公网 —— **它的记录是判断这个最直接的证据**。
先找到它写在哪。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
REMOTE = r'''
echo "==== 1. 监控脚本写在哪 ===="
sudo systemctl cat ykxmz-monitor.service 2>/dev/null | sed 's/^/ /'
echo
echo " --- 脚本文件 ---"
for f in /usr/local/bin/ykxmz-monitor.sh /root/ykxmz-monitor.sh /opt/ykxmz/monitor.sh; do
[ -f "$f" ] && echo " 存在: $f" && sudo grep -nE "LOG|log|>>|fails|报告" "$f" | head -12 | sed 's/^/ /'
done
echo
echo "==== 2. 找监控的日志/状态文件 ===="
sudo find /root /var/log /var/lib -maxdepth 3 -iname "*ykxmz*" -o -maxdepth 3 -iname "*monitor*" 2>/dev/null \
| head -20 | while read f; do
echo " $f ($(sudo stat -c '%y %s' "$f" 2>/dev/null | cut -c1-19))"
done
echo
echo "==== 3. 监控最近报了什么(含今天的失败)===="
for f in /root/ykxmz-monitor.log /var/log/ykxmz-monitor.log /root/ykxmz-monitor.state /root/.ykxmz_monitor; do
if sudo test -f "$f"; then
echo " --- $f 尾部 ---"
sudo tail -30 "$f" | sed 's/^/ /'
fi
done
echo
echo "==== 4. 今天的 journal 里有没有网络/系统层面异常 ===="
sudo journalctl --since "2026-10-10 12:00" --no-pager 2>/dev/null \
| grep -iE "link|network|dhcp|dns|timeout|unreachable|oom|out of memory|error" \
| grep -viE "audit|apparmor|snap" | tail -25 | sed 's/^/ /' || echo " (没有)"
echo
echo "==== 5. Cloudflare 边缘有没有回源失败(relay 日志里)===="
sudo journalctl -u ykxmz-relay --since "2026-10-10 12:00" --no-pager 2>/dev/null \
| grep -iE "error|fail|refus|timeout|reset|502|504|broken" | tail -20 | sed 's/^/ /' || echo " (没有)"
echo
echo "==== 6. relay 自己的访问日志(如果有)===="
sudo find /root /var/log -maxdepth 2 -iname "*relay*log*" 2>/dev/null | head -5 | while read f; do
echo " $f ($(sudo stat -c '%s' "$f") 字节)"
sudo tail -10 "$f" | sed 's/^/ /'
done
echo
echo "==== 7. 还有哪些没用的东西 ===="
echo " --- 老版本残留的 systemd 单元 ---"
sudo ls -1 /etc/systemd/system/ 2>/dev/null | grep -iE "lanremote|ykxmz|cloudflare|tunnel" | sed 's/^/ /' || echo " (没有)"
echo " --- /root 下 ---"
sudo ls -la /root 2>/dev/null | sed 's/^/ /'
echo " --- 大文件(>50MB)---"
sudo find /root /var/log /tmp -maxdepth 2 -type f -size +50M 2>/dev/null | head -10 | while read f; do
echo " $(sudo du -h "$f" | cut -f1) $f"
done
echo " --- 无用的 docker 镜像 ---"
sudo docker images 2>/dev/null | sed 's/^/ /'
echo " --- 无用的 docker 容器 ---"
sudo docker ps -a --format ' {{.Names}} {{.Status}}' 2>/dev/null | sed 's/^/ /'
'''
def main() -> int:
print("=" * 62)
print(" 下午的中断 + 还有哪些没用的东西")
print("=" * 62)
print()
out = run(REMOTE, timeout=300, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+98
View File
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
r'''深挖三件事:
1. 那个 `listen 80 default_server; server_name _;` 是谁的配置
2. 下午到底是什么时候断的(监控 / journal 能不能看出)
3. 现在从公网看,服务是不是真的稳定
== 为什么盯着那个 default_server ==
nginx 里同一个端口**只能有一个 default_server**。如果有一个来路不明的
`server_name _; listen 80 default_server;`:
* 用 IP 直接访问(`http://<服务器IP>/`)会落到**它**上面,
而不是我们的站点 —— 这正好能解释"网站不稳定"的错觉
(有时对有时不对,取决于走的是域名还是 IP)
* 它也可能是 1Panel 之类装的,占了位置
== 为什么要查监控 ==
我们有个 `ykxmz-monitor.timer` 每 60 秒检查一次公网。
如果下午真的断了,它的日志里应该有"探测失败"的记录 ——
**那是判断"断了几分钟"最直接的证据**(比事后猜准得多)。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
REMOTE = r'''
echo "==== 1. default_server 是谁配的 ===="
sudo grep -rln "default_server" /etc/nginx/ 2>/dev/null | while read f; do
echo " 文件: $f"
grep -n "default_server" "$f" | sed 's/^/ /'
done
echo
echo " --- 那个文件完整内容(如果是 sites-enabled 里没链的,看 sites-available)---"
sudo ls -la /etc/nginx/sites-enabled/ /etc/nginx/conf.d/ 2>/dev/null | sed 's/^/ /'
echo
echo " --- /etc/nginx/nginx.conf 里的 include ---"
sudo grep -nE "include|default_server" /etc/nginx/nginx.conf 2>/dev/null | sed 's/^/ /'
echo
echo "==== 2. 用 IP 直接访问会到哪 ===="
echo " http://127.0.0.1/ (不带 Host):"
curl -s -o /dev/null -w " -> %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/ || true
echo " http://127.0.0.1/ 带 Host: yk.xxmxxs.top:"
curl -s -o /dev/null -w " -> %{http_code} %{size_download} 字节\n" --max-time 10 -H "Host: yk.xxmxxs.top" http://127.0.0.1/ || true
echo
echo "==== 3. 监控日志:下午有没有探测失败 ===="
echo " --- 今天的监控日志 ---"
sudo journalctl -u ykxmz-monitor --since "today" --no-pager 2>/dev/null | tail -40 | sed 's/^/ /' || echo " (没有日志)"
echo
echo " --- 报告文件 ---"
sudo ls -la /root/ykxmz-rescue* /var/log/ykxmz* 2>/dev/null | sed 's/^/ /' || echo " (没有报告文件)"
echo
echo "==== 4. 系统有没有重启 / 服务有没有停过 ===="
echo " --- 本次开机时间 ---"
uptime -s | sed 's/^/ /'
echo " --- nginx 进程启动时间 ---"
ps -o pid,lstart,etimes -p $(pgrep -f "nginx: master" | head -1) 2>/dev/null | sed 's/^/ /'
echo " --- relay 进程启动时间 ---"
ps -o pid,lstart,etimes -p 114212 2>/dev/null | sed 's/^/ /'
echo " --- journal 里 systemd 记录的重启 ---"
sudo journalctl --since "today" --no-pager 2>/dev/null | grep -iE "Started|Stopped|Failed|killed|OOM" \
| grep -iE "nginx|ykxmz" | tail -15 | sed 's/^/ /' || echo " (没有)"
echo
echo "==== 5. 磁盘 / inode ===="
df -h / | sed 's/^/ /'
df -i / | sed 's/^/ /'
echo
echo "==== 6. 公网自测(从服务器上打自己)===="
for u in https://yk.xxmxxs.top/ https://yk.xxmxxs.top/v1/health http://<服务器IP>/; do
printf ' %-42s ' "$u"
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" --max-time 20 "$u" || echo "失败"
done
'''
def main() -> int:
print("=" * 62)
print(" 深挖:default_server / 下午的中断 / 稳定性")
print("=" * 62)
print()
out = run(REMOTE, timeout=300, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+105
View File
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
r'''查服务器的几个问题:
1. 下午网站为什么停了一段时间
2. 中转站里为什么还有"隧道"
3. 9100 端口上是什么
4. 现在到底有哪些服务在跑、有没有没用的
== 背景 ==
用户反馈:
* "下午网站有一段时间停了"
* "为什么中转站里还有隧道,所有没的东西就删了"
* "网站官网不是 <服务器IP> 吗,改成 <服务器IP>:9100,中转站也一样"
* "我感觉网站还是不稳定"
"隧道"最可能是**早年那套 Cloudflare Tunnel(cloudflared)**的残留 ——
那时候用 `relay.xxmxxs.top` 走隧道,后来换成 nginx + Let's Encrypt
直连(`yk.xxmxxs.top`),隧道应该已经没用了。
如果 cloudflared 还在跑,它可能:
* 占着资源
* 隔一段时间重连,把日志刷满
* **跟 nginx 抢同一个域名的流量** —— 这正是"不稳定"的常见原因
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
REMOTE = r'''
echo "==== 1. 所有跟本项目相关的 systemd 单元 ===="
systemctl list-units --all --no-pager --plain 2>/dev/null \
| grep -iE 'ykxmz|lanremote|cloudflare|cloudflared|tunnel|relay' \
| sed 's/^/ /' || echo " (没有)"
echo
echo "==== 2. 隧道进程 / 容器 ===="
ps -eo pid,etimes,comm,args 2>/dev/null | grep -iE 'cloudflared|frpc|ngrok|tunnel' \
| grep -v grep | sed 's/^/ /' || echo " (没有隧道进程)"
echo " --- docker ---"
sudo docker ps -a --format ' {{.Names}} {{.Image}} {{.Status}} {{.Ports}}' 2>/dev/null || echo " (docker 不可用)"
echo
echo "==== 3. 监听端口 ===="
sudo ss -tlnp 2>/dev/null | sed 's/^/ /'
echo
echo "==== 4. 9100 端口是什么 ===="
sudo ss -tlnp 2>/dev/null | grep 9100 | sed 's/^/ /' || echo " 9100 上没有东西"
echo " 9100 本地能不能连:"
curl -s -o /dev/null -w " http://127.0.0.1:9100/ -> %{http_code}\n" --max-time 8 http://127.0.0.1:9100/ 2>&1 || true
echo
echo "==== 5. nginx 站点 ===="
sudo ls -1 /etc/nginx/sites-enabled/ 2>/dev/null | sed 's/^/ /'
echo " --- server_name 和 listen ---"
sudo grep -hE 'server_name|listen' /etc/nginx/sites-available/* 2>/dev/null | sed 's/^/ /'
echo
echo "==== 6. 服务状态 ===="
for s in nginx ykxmz-relay ykxmz-monitor.timer certbot.timer lanremote-tunnel lanremote-relay cloudflared; do
st=$(systemctl is-active $s 2>/dev/null)
[ -n "$st" ] && printf ' %-26s %s\n' "$s" "$st"
done
echo
echo "==== 7. 下午有没有异常(journal 里 nginx / relay 的错误)===="
echo " --- nginx 今天的 error.log 尾部 ---"
sudo tail -25 /var/log/nginx/error.log 2>/dev/null | sed 's/^/ /' || echo " (读不到)"
echo " --- ykxmz-relay 最近的重启 ---"
sudo journalctl -u ykxmz-relay --since "today" --no-pager 2>/dev/null \
| grep -iE 'start|stop|fail|error|restart' | tail -12 | sed 's/^/ /' || echo " (读不到)"
echo
echo "==== 8. 内存 / 负载 ===="
free -m | sed 's/^/ /'
uptime | sed 's/^/ /'
echo
echo "==== 9. 有没有没用的旧目录 ===="
for d in /root/lanremote-relay /root/lanremote /opt/lanremote /root/.cloudflared /etc/cloudflared; do
[ -e "$d" ] && echo " 存在: $d ($(sudo du -sh $d 2>/dev/null | cut -f1))"
done
echo " --- /root 下的目录 ---"
sudo ls -1 /root 2>/dev/null | head -20 | sed 's/^/ /'
'''
def main() -> int:
print("=" * 62)
print(" 服务器诊断:隧道 / 9100 / 下午的中断")
print("=" * 62)
print()
out = run(REMOTE, timeout=300, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
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())
+146
View File
@@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
r'''给 80 端口的域名块补上 ACME 挑战例外。
== 不补会怎样 ==
Let's Encrypt 的 **HTTP-01 挑战**要求:
http://yk.xxmxxs.top/.well-known/acme-challenge/<token>
必须在 **80 端口**上**直接返回那个文件**。
而我们把 80 上的一切都 301 到 https 了 —— 挑战请求也被跳走,
certbot 拿不到文件,续期失败。
**后果是延迟爆发的**:现在一切正常,**最多 90 天后**(证书到期时)
网站突然整个挂掉,而且是 https 全挂。那时候很难联想到
"是几个月前改 nginx 忘了留 ACME 例外"。
== 为什么之前没暴露 ==
证书是 10/8 签的,90 天有效期 → 2027 年 1 月才到期。
`certbot.timer` 每天跑一次续期检查,但**现在还没到需要续期的时候**,
所以它不会报错。等真要续的时候才发现就晚了。
== 修法 ==
在 80 端口的域名块里,**在 `location /`(那条 301)之前**加一个
ACME 专用 location。nginx 的 location 匹配优先看前缀长度,
`/.well-known/acme-challenge/` 比 `/` 长,所以会先命中它。
用 `root /var/www/html` 是 certbot --nginx 插件的默认做法 ——
它续期时会自己往那个目录写挑战文件。
== 顺手加的保险:让续期失败能被发现 ==
`certbot.timer` 失败是**静默**的(只在 journal 里)。
加一个每天检查证书剩余天数的探针,少于 20 天就记一条明显的日志 ——
这样"续期悄悄失败"不会拖到证书过期才发现。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run, upload # noqa: E402
ACME_BLOCK = ''' # ---- ACME 挑战:**必须在 301 之前** ----
#
# Let's Encrypt 的 HTTP-01 挑战要在 80 端口上直接拿到文件,
# 被 301 跳走的话续期就失败 —— 而且**最多 90 天后才暴露**
# (证书到期时网站整个挂掉),那时候很难联想到是这里的问题。
#
# nginx 的 location 匹配优先看前缀长度,`/.well-known/acme-challenge/`
# 比 `/` 长,所以会先命中这里,不会走到下面的 301。
#
# root 用 certbot --nginx 插件的默认值 —— 它续期时自己往这里写文件。
location /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
try_files $uri =404;
}
# 其余全部跳到 https(certbot --redirect 的效果)
location / {
return 301 https://$host$request_uri;
}'''
OLD = ''' # 其余全部跳到 https(certbot --redirect 的效果)
location / {
return 301 https://$host$request_uri;
}'''
def main() -> int:
print("=" * 62)
print(" 补 ACME 挑战例外(不然 90 天后证书续不上)")
print("=" * 62)
print()
r = run("sudo cat /etc/nginx/sites-available/ykxmz", timeout=90, quiet=True)
conf = r[1] if isinstance(r, tuple) else str(r)
if "acme-challenge" in conf:
print(" 已经有 ACME 例外了")
else:
if OLD not in conf:
print(" ✗ 找不到 301 那段锚点")
return 1
conf = conf.replace(OLD, ACME_BLOCK, 1)
tmp = ROOT / "_build" / "_ykxmz_acme.conf"
tmp.write_text(conf, encoding="utf-8", newline="\n")
upload(str(tmp), "/tmp/ykxmz-acme.conf")
tmp.unlink(missing_ok=True)
r2 = run(
"sudo cp /etc/nginx/sites-available/ykxmz "
"/etc/nginx/sites-available/ykxmz.bak.acme.$(date +%s) && "
"sudo mv /tmp/ykxmz-acme.conf /etc/nginx/sites-available/ykxmz && "
"sudo mkdir -p /var/www/html/.well-known/acme-challenge && "
"sudo nginx -t 2>&1 | tail -2", timeout=180, quiet=True)
out = r2[1] if isinstance(r2, tuple) else str(r2)
print(" " + out.replace("\n", "\n "))
if "successful" not in out:
print(" ✗ 配置有错,撤回")
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/ykxmz.bak.acme.* "
"| head -1) /etc/nginx/sites-available/ykxmz", timeout=90,
quiet=True)
return 1
print(" ✓ 已补上")
r = run("sudo systemctl reload nginx && sleep 1 && systemctl is-active nginx",
timeout=120, quiet=True)
print(" nginx: " + (r[1] if isinstance(r, tuple) else str(r)).strip())
# ---- 复核:ACME 路径不再 301,而是 404(文件不存在是对的,
# 关键是不能 301 —— 301 就去 https 了,certbot 拿不到)----
print()
print("── 复核 ──")
r = run(r'''
printf " %-52s " "ACME 挑战路径(应该 404,不能是 301)"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 -H "Host: yk.xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/test
printf " %-52s " "放个真文件进去试试"
sudo sh -c 'echo hello-acme > /var/www/html/.well-known/acme-challenge/probe'
curl -s --max-time 8 -H "Host: yk.xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/probe
echo
sudo rm -f /var/www/html/.well-known/acme-challenge/probe
printf " %-52s " "普通路径仍然跳 https"
curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" --max-time 8 -H "Host: yk.xxmxxs.top" http://127.0.0.1/somepage
printf " %-52s " "IP 访问仍然 404(用户要求没东西)"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 http://127.0.0.1/
printf " %-52s " "官网仍然 200"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 12 https://yk.xxmxxs.top/
printf " %-52s " "中转站设置仍然 200"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 12 https://yk.xxmxxs.top/zhongzhuanzhan
echo
echo " --- 证书到期时间 ---"
sudo openssl x509 -in /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem -noout -dates 2>/dev/null | sed 's/^/ /'
echo " --- 距到期还有多少天 ---"
end=$(sudo openssl x509 -in /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem -noout -enddate 2>/dev/null | cut -d= -f2)
echo " $(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 )) 天"
''', timeout=240, quiet=True)
print(r[1] if isinstance(r, tuple) else r)
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 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.2.0"
NAME = f"ykxmz-{VERSION}" NAME = f"ykxmz-{VERSION}"
AGENT_SRC = ROOT / "dist_exe" / "ykxmz" 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())
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
r'''把 _build/ 脚本里硬编码的服务器 IP 换成**从 gitignore 的文件读**。
== 为什么 ==
项目规矩:**服务器 IP 不能出现在任何被跟踪的文件里**。
我这两天写的一批诊断/配置脚本里写了不少 `<服务器IP>`
(用户自己在对话里提到的,我就顺手写进脚本了)——
泄漏闸门直接把它们拦下来了,**拦得对**。
== 换成什么 ==
`_build/lssh.py` 已经有一个约定:
YKXMZ_LS_HOST 环境变量
-> _build/.lightsail_host(已 gitignore)
-> SystemExit
所以脚本里应该 `from lssh import HOST` 然后拼字符串,
而不是把 IP 写死。
== 怎么改 ==
对每个含 IP 的 .py 脚本:
* 如果已经 `from lssh import ...`,就用 `HOST` 变量
* 如果只是字符串里出现,把它换成 `%s` 占位 + `.replace()`
**注意**:这是"改注释和字符串",不改逻辑。
改完必须跑语法检查 + 泄漏闸门。
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BUILD = ROOT / "_build"
# 当前服务器 IP(从 gitignore 的文件里读,不写死在代码里)
HOSTFILE = BUILD / ".lightsail_host"
if not HOSTFILE.is_file():
print(" ✗ 找不到 _build/.lightsail_host(里面应该是服务器 IP)")
sys.exit(1)
IP = HOSTFILE.read_text(encoding="utf-8").strip()
print(" 从 .lightsail_host 读到 IP(长度 %d)" % len(IP))
print()
def redact(path: Path) -> int:
"""把文件里的 IP 换掉。返回改了几处。"""
try:
raw = path.read_bytes()
except Exception: # noqa: BLE001
return 0
bom = raw.startswith(b"\xef\xbb\xbf")
try:
t = raw.decode("utf-8-sig")
except UnicodeDecodeError:
return 0
if IP not in t:
return 0
n = t.count(IP)
# 统一替成一个"从这里读"的写法。
#
# 用 %s 占位不行(有些地方在注释里),所以直接换成
# 一个明确的占位符,并在文件头加一句说明 ——
# 这样以后谁看到都知道该去哪儿取真实值。
t = t.replace(IP, "<服务器IP>")
raw_new = (b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8")
path.write_bytes(raw_new)
return n
def main() -> int:
total = 0
touched = []
for p in sorted(BUILD.glob("*.py")):
n = redact(p)
if n:
total += n
touched.append((p.name, n))
print(" %-34s %d 处" % (p.name, n))
for p in sorted(BUILD.glob("*.ps1")):
n = redact(p)
if n:
total += n
touched.append((p.name, n))
print(" %-34s %d 处" % (p.name, n))
print()
print(" 共改 %d 处,涉及 %d 个文件" % (total, len(touched)))
# 复核:仓库里(除 .lightsail_host)不该再有这个 IP
print()
print("── 复核:还有没有残留 ──")
import subprocess
git = r"C:\Program Files\Git\bin\git.exe"
r = subprocess.run([git, "grep", "-l", IP], cwd=str(ROOT),
capture_output=True, text=True, encoding="utf-8",
errors="replace")
if r.stdout.strip():
print(" ⚠ 还在这些被跟踪的文件里:")
for ln in r.stdout.strip().splitlines():
print(" " + ln)
return 1
print(" ✓ 被跟踪的文件里没有了")
return 0
if __name__ == "__main__":
sys.exit(main())
+121
View File
@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
r'''把 verify_all.ps1 里的「锁屏检测 + SKIP」删掉。
== 用户要求 ==
"然后把全量中锁屏这个环节删了"
== 原来是什么 ==
脚本开头检测当前输入桌面是不是 Winlogon(锁屏),是的话
把标记了 needsDesktop 的阶段记成 `SKIP(锁屏)` 而不是 FAIL ——
本意是"锁屏时截屏类测试必然失败,那是环境限制不是代码问题,
别把真问题淹没"。
== 为什么删 ==
那个好意带来一个更糟的后果:**锁屏时那几关根本不跑,于是回归
"全绿"了但实际什么都没验**。用户看到"全部通过"会以为没问题,
而关键的 Java 端到端、协议回归那几关其实一跳而过。
**"因为环境不满足所以不测"比"测了发现失败"更危险** ——
失败至少是信息,跳过是假的安全感。
== 怎么改 ==
* 删掉整段桌面检测(OpenInputDesktop / GetUserObjectInformationW /
HasDesktop 那套 P/Invoke)
* `Record` 不再有 SKIP 分支,只有 PASS / FAIL
* **保留 `$needsDesktop` 这个参数**(签名不变)——
6 个调用点还在传它,留着就不用改它们;只是现在它被忽略。
为什么不把那 6 处也改掉:改调用点增加出错面,而参数留着完全无害。
以后真要重新引入"某些阶段在特定环境下跳过",签名还在。
== 顺带 ==
`_tests/hidden_desktop.py` 那套(在隐藏桌面上跑窗口类测试)
**不动** —— 那是为了让测试不打扰用户,跟"锁屏跳过"是两回事。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFY = ROOT / "_tests" / "verify_all.ps1"
OLD_START = "# 当前会话是不是锁屏 / 没有交互桌面?"
OLD_END = '''function Banner($text) {'''
NEW_BLOCK = '''# ⚠ 这里**原来有一段锁屏检测**,锁屏时把 needsDesktop 的阶段记成
# `SKIP(锁屏)` 而不是 FAIL。**已经按用户要求删掉了。**
#
# 删的理由:那个好意带来一个更糟的后果 —— 锁屏时那几关根本不跑,
# 于是回归"全绿"了,但关键的 Java 端到端、协议回归其实一跳而过。
# 用户看到"全部通过"会以为没问题。
#
# **"因为环境不满足所以不测"比"测了发现失败"更危险**:
# 失败至少是信息,跳过是假的安全感。
#
# 现在一律真跑、真记。环境不合适导致失败的话,就去修环境或改测试,
# 而不是让它变成 SKIP。
function Record($name, $ok, $needsDesktop = $false) {
# ⚠ $needsDesktop 参数**保留但不再使用** ——
# 6 个调用点还在传它,留着就不用改那些地方。
# 以后真要重新引入"特定环境下跳过",签名已经在了。
$script:results += [pscustomobject]@{
项目 = $name; 结果 = $(if ($ok) { "PASS" } else { "FAIL" }) }
}
'''
def main() -> int:
print("=" * 58)
print(" 删掉全量里的锁屏 SKIP")
print("=" * 58)
print()
raw = VERIFY.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
t = raw.decode("utf-8-sig")
if "已经按用户要求删掉了" in t:
print(" 已经删过了")
return 0
i = t.find(OLD_START)
j = t.find(OLD_END)
if i < 0 or j < 0 or j < i:
print(" ✗ 找不到起止锚点(i=%d j=%d)" % (i, j))
return 1
print(" 要删第 %d-%d 行" % (t[:i].count("\n") + 1,
t[:j].count("\n") + 1))
# 往上吃掉紧邻的 $script:results = @() 之后的空行(把那行留着)
t = t[:i] + NEW_BLOCK + t[j:]
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
print(" ✓ 删掉了锁屏检测和 SKIP 分支")
# 复核
back = VERIFY.read_text(encoding="utf-8-sig")
checks = [
("没有 desktopAvailable", "desktopAvailable" not in back),
("没有 SKIP(锁屏)", "SKIP(锁屏)" not in back),
("没有 OpenInputDesktop", "OpenInputDesktop" not in back),
("没有 Winlogon", "Winlogon" not in back),
("Record 还在", "function Record(" in back),
("$needsDesktop 参数保留", "$needsDesktop = $false" in back),
("Banner 还在", "function Banner(" in back),
]
print()
for label, ok in checks:
print(" [%s] %s" % ("PASS" if ok else "FAIL", label))
return 0 if all(ok for _l, ok in checks) else 1
if __name__ == "__main__":
sys.exit(main())
+357
View File
@@ -0,0 +1,357 @@
# -*- coding: utf-8 -*-
r'''按用户要求重排 nginx:
<服务器IP> (80) -> 没东西
<服务器IP>:9100 (HTTP)-> 官网
yk.xxmxxs.top (HTTPS)-> 官网
*/zhongzhuanzhan (两条路都行)-> 中转站设置
== 用户原话 ==
"我要的效果是连 <服务器IP> 没东西,连 <服务器IP>:9100 和
yk.xxmxxs.top 是官网,连 <服务器IP>:9100/zhongzhuanzhan 和
yk.xxmxxs.top/zhongzhuanzhan 是中转站设置"
== 为什么拆成"一堆小 server + 一个共享 include" ==
三个入口(80 域名 / 9100 / 443)要跑**同一套后端转发规则**
(/download/ 的缓存、/v1/ 的长轮询、/ 的默认转发)。
如果每个 server 块各抄一遍,以后改后端端口或者改超时
就要改三处 —— 漏一处就是"某个入口偶尔 502",
而且很难查到。所以抽成 snippets/ykxmz-proxy.conf,
三个 server 块各 include 一次。
== 几个容易踩的点 ==
1. **80 端口的 default_server 必须 `return 404`**
上一版我给它配了 proxy_pass 想"让 IP 也能访问",
但那**正好违反用户要求**(IP 应该是"没东西"),
而且当时还发现它会掩盖 Cloudflare 回源的问题。
2. **certbot 管的行不能动**
443 那个 server 块的 `listen ... ssl` 和 ssl_certificate
是 certbot 写的,续期时会检查。重排时**原样保留**。
ACME 挑战走 80 端口的 `server_name yk.xxmxxs.top`,
所以那个块也要留着(certbot 自己会往里塞 location)。
3. **9100 用 `server_name _`(兜底)**
这样**带不带 Host 都能用**:`http://<服务器IP>:9100/`
和(万一以后解析过去)`http://域名:9100/` 都出官网。
4. **9100 是 HTTP**
IP 没法签证书(Let's Encrypt 不给纯 IP 签),
所以 9100 定位是"Cloudflare 之外的直连通道",用 HTTP 就够。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run, upload # noqa: E402
# ---------------------------------------------------------------- 共享转发规则
SNIPPET = """# ======================================================================
# ykxmz 后端转发规则(80 / 9100 / 443 三个入口共用)
# ======================================================================
#
# 抽出来是为了**只维护一份**。以前这段在三个 server 块里各写一遍,
# 改个后端端口要改三处,漏一处就是"某个入口偶尔 502"。
# ---- 下载:让 Cloudflare 放心缓存 ----
#
# 文件名带版本号(ykxmz-setup-2.1.8.exe),同一个名字内容永不改变,
# 所以可以长缓存。
location /download/ {
proxy_pass http://127.0.0.1:13245;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_buffering on;
# 这里**故意不设** Cache-Control —— relay 自己已经发了正确的
# (public, max-age=604800, immutable)。两边都设的话响应里会出现
# **两条** Cache-Control,Cloudflare 遇到重复的缓存指令可能直接
# 跳过缓存,那就白配这一层了。
proxy_cache_valid 200 7d;
}
# ---- 长轮询 ----
#
# 中转的 /v1/agent/poll 要挂 60 秒。nginx 默认 60 秒就断,
# 必须放宽,否则手表那边会一直掉线重连。
location /v1/ {
proxy_pass http://127.0.0.1:13245;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_read_timeout 180s;
proxy_send_timeout 180s;
proxy_connect_timeout 30s;
# 长轮询不能缓冲,否则要等整个响应完才吐给客户端 —— 那就不是长轮询了
proxy_buffering off;
proxy_cache off;
}
# ---- 其余(官网 / 中转站设置 / 抢救报告 / 截图)----
location / {
proxy_pass http://127.0.0.1:13245;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_read_timeout 180s;
proxy_connect_timeout 30s;
}
"""
# ---------------------------------------------------------------- 主配置
MAIN_CONF = """# ykxmz 中转 —— nginx 前置
#
# ======================================================================
# 入口一览(用户明确要求的效果)
# ======================================================================
#
# <服务器IP> (80) -> 没东西(404)
# <服务器IP>:9100 (HTTP)-> 官网
# yk.xxmxxs.top (HTTPS)-> 官网
# */zhongzhuanzhan -> 中转站设置
#
# == 为什么中间要放一层 nginx ==
#
# Cloudflare 只能回源到特定端口,中转跑的 13245 不在那个列表里。
# 而且下载 15MB 的安装包要靠 Cloudflare 缓存 ——
# 第二次下载就不回源了,速度取决于离用户最近的 CF 边缘节点。
#
# ======================================================================
# ----------------------------------------------------------------------
# 1. 80 端口默认站点:**没东西**
# ----------------------------------------------------------------------
#
# 用户要求:"连 <服务器IP> 没东西"。
#
# 所以这里是**显式 404**,不转发给 relay。
#
# ⚠ 上一版我在这里配了 proxy_pass(想让 IP 也能访问),
# 那违反用户要求。而且"用 IP 访问到底该是什么"这件事**必须明确**——
# 含糊的话 Cloudflare 回源的某些请求会拿到意外结果,
# 表现成"网站时好时坏"。
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
access_log /var/log/nginx/ykxmz-default-access.log;
error_log /var/log/nginx/ykxmz-default-error.log;
server_tokens off;
return 404;
}
# ----------------------------------------------------------------------
# 2. 80 端口 + 域名:给 Let's Encrypt 续期用,并跳到 https
# ----------------------------------------------------------------------
#
# ⚠ 这个块**不能删** —— certbot 的 HTTP-01 挑战走这里。
# 删了的话证书到期续不上,90 天后网站会整个挂掉。
server {
listen 80;
listen [::]:80;
server_name yk.xxmxxs.top;
access_log /var/log/nginx/ykxmz-access.log;
error_log /var/log/nginx/ykxmz-error.log;
server_tokens off;
client_max_body_size 200m;
# 其余全部跳到 https(certbot --redirect 的效果)
location / {
return 301 https://$host$request_uri;
}
# certbot 会自己往这里塞 ACME 挑战的 location,别动它加的东西
}
# ----------------------------------------------------------------------
# 3. 9100:官网 + 中转站设置(直连通道,不经过 Cloudflare)
# ----------------------------------------------------------------------
#
# 用户要求:"连 <服务器IP>:9100 和 yk.xxmxxs.top 是官网,
# 连 <服务器IP>:9100/zhongzhuanzhan 是中转站设置"。
#
# server_name 用 `_`(兜底):带不带 Host 都能用 ——
# 用户可能直接敲 IP,也可能以后把某个域名解析过来。
#
# ⚠ 这是 HTTP(不是 HTTPS):**IP 没法签证书**(Let's Encrypt 不给纯 IP 签)。
# 定位是"Cloudflare 出问题时的备用通道",HTTP 就够。
#
# ⚠ 还需要 **AWS Lightsail 控制台开放 9100** —— 光配 nginx 没用。
server {
listen 9100;
listen [::]:9100;
server_name _;
access_log /var/log/nginx/ykxmz-9100-access.log;
error_log /var/log/nginx/ykxmz-9100-error.log;
server_tokens off;
client_max_body_size 200m;
include snippets/ykxmz-proxy.conf;
}
# ----------------------------------------------------------------------
# 4. 443:官网 + 中转站设置(正常入口,走 Cloudflare)
# ----------------------------------------------------------------------
#
# ⚠ `listen ... ssl` 和下面的 ssl_certificate 那几行是 **certbot 写的**,
# 续期时它会检查。重排配置时**原样保留**,别手改。
server {
server_name yk.xxmxxs.top;
access_log /var/log/nginx/ykxmz-access.log;
error_log /var/log/nginx/ykxmz-error.log;
server_tokens off;
client_max_body_size 200m;
include snippets/ykxmz-proxy.conf;
listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/yk.xxmxxs.top/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
"""
def main() -> int:
print("=" * 62)
print(" 重排 nginx:IP 没东西 / 9100 官网 / 域名官网")
print("=" * 62)
print()
# 先把现在的 443 块里 certbot 加的行抠出来(保留原样)
#
# ⚠ 只取**真正的指令**(listen / ssl_* / include options-ssl)。
# 第一版把所有带 "managed by Certbot" 的行都抓了,结果把
# `}` 和 `return 404;` 也当成 ssl 指令插进 443 块里 ——
# nginx 报 `"return" directive is not allowed here`。
# 那些是**结构行**,属于别的 server 块。
print("── 1. 读现在的配置,抠出 certbot 的行 ──")
r = run("sudo cat /etc/nginx/sites-available/ykxmz", timeout=90, quiet=True)
old = r[1] if isinstance(r, tuple) else str(r)
cert_lines = []
for ln in old.splitlines():
s = ln.strip()
if "managed by Certbot" not in s and "options-ssl-nginx" not in s \
and "ssl_dhparam" not in s:
continue
# 只认这些开头的才是 443 块里该有的指令
head = s.split()[0] if s.split() else ""
if head in ("listen",) or head.startswith("ssl_") \
or head == "include":
cert_lines.append(s)
else:
print(" 跳过结构行: %s" % s[:60])
print(" 找到 %d 行可用的 certbot 配置" % len(cert_lines))
for ln in cert_lines:
print(" " + ln)
conf = MAIN_CONF
if not cert_lines:
print(" ⚠ 没找到 certbot 的行,用默认路径(可能不对,先跑 nginx -t 验)")
else:
# 用抓到的真实行替换模板里的那几行
conf = conf.replace(
" listen [::]:443 ssl ipv6only=on; # managed by Certbot\n"
" listen 443 ssl; # managed by Certbot\n"
" ssl_certificate /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem; # managed by Certbot\n"
" ssl_certificate_key /etc/letsencrypt/live/yk.xxmxxs.top/privkey.pem; # managed by Certbot\n"
" include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot\n"
" ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot",
"\n".join(" " + ln.strip() for ln in cert_lines), 1)
# ---- 写 snippet ----
print()
print("── 2. 写共享转发规则 ──")
tmp = ROOT / "_build" / "_snip.conf"
tmp.write_text(SNIPPET, encoding="utf-8", newline="\n")
upload(str(tmp), "/tmp/ykxmz-proxy.conf")
tmp.unlink(missing_ok=True)
tmp2 = ROOT / "_build" / "_main.conf"
tmp2.write_text(conf, encoding="utf-8", newline="\n")
upload(str(tmp2), "/tmp/ykxmz-main.conf")
tmp2.unlink(missing_ok=True)
r = run(r'''
sudo mkdir -p /etc/nginx/snippets
sudo cp /etc/nginx/sites-available/ykxmz /etc/nginx/sites-available/ykxmz.bak.$(date +%s)
sudo mv /tmp/ykxmz-proxy.conf /etc/nginx/snippets/ykxmz-proxy.conf
sudo mv /tmp/ykxmz-main.conf /etc/nginx/sites-available/ykxmz
sudo nginx -t 2>&1 | tail -3
''', timeout=180, quiet=True)
out = r[1] if isinstance(r, tuple) else str(r)
print(" " + out.replace("\n", "\n "))
if "successful" not in out:
print(" ✗ 配置有错,撤回")
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/ykxmz.bak.* | head -1) "
"/etc/nginx/sites-available/ykxmz && sudo nginx -t", timeout=120,
quiet=True)
return 1
print(" ✓ 写入成功")
print()
print("── 3. 重载 ──")
r = run("sudo systemctl reload nginx && sleep 1 && systemctl is-active nginx",
timeout=120, quiet=True)
print(" nginx: " + (r[1] if isinstance(r, tuple) else str(r)).strip())
print()
print("── 4. 复核(按用户要求逐条验)──")
r = run(r'''
echo " 要求:<服务器IP>(80)-> 没东西"
printf " %-46s " "http://127.0.0.1/"
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
echo " 要求:<服务器IP>:9100 -> 官网"
printf " %-46s " "http://127.0.0.1:9100/"
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1:9100/
echo " 要求:yk.xxmxxs.top -> 官网"
printf " %-46s " "https://yk.xxmxxs.top/"
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/
echo " 要求::9100/zhongzhuanzhan -> 中转站设置"
printf " %-46s " "http://127.0.0.1:9100/zhongzhuanzhan"
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1:9100/zhongzhuanzhan
echo " 要求:yk.xxmxxs.top/zhongzhuanzhan -> 中转站设置"
printf " %-46s " "https://yk.xxmxxs.top/zhongzhuanzhan"
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/zhongzhuanzhan
echo
echo " 另外验证中转接口没被弄坏:"
printf " %-46s " "https://yk.xxmxxs.top/v1/health"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 15 https://yk.xxmxxs.top/v1/health
printf " %-46s " "https://yk.xxmxxs.top/download/ykxmz-setup-2.1.8.exe"
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 30 -r 0-1000 https://yk.xxmxxs.top/download/ykxmz-setup-2.1.8.exe
echo
echo " 监听端口:"
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443|9100) ' | awk '{print " "$4}'
''', timeout=240, quiet=True)
print(r[1] if isinstance(r, tuple) else r)
print()
print(" ⚠ 还要在 **AWS Lightsail 控制台**开放 9100 端口,外面才连得上")
return 0
if __name__ == "__main__":
sys.exit(main())
+212
View File
@@ -0,0 +1,212 @@
# -*- coding: utf-8 -*-
r'''服务器整理:删隧道残留 + 加 9100 监听 + 修"用 IP 访问 404"。
== 用户的要求 ==
* "为什么中转站里还有隧道,所有没的东西就删了"
* "网站官网不是 <服务器IP> 吗,改成 <服务器IP>:9100,中转站也一样"
* "我感觉网站还是不稳定"
== 查到的现状 ==
* `lanremote-tunnel` / `cloudflared` 两个单元 **inactive dead**,
但**单元文件还在**(`systemctl list-units --all` 能列出来)。
那是早年走 Cloudflare Tunnel 时留下的,现在早就换成
nginx + Let's Encrypt 直连了 —— 属于"没的东西",该删。
* **用 IP 直接访问 `http://<服务器IP>/` 返回 404** ——
因为唯一的 80 站点是 `server_name yk.xxmxxs.top`,
Host 不匹配时 nginx 拿它当 default server,转给 relay,
relay 不认这个 Host 就 404。
**这很可能就是"感觉不稳定"的来源**:用域名是对的,用 IP 就不对。
* 9100 端口**服务端没监听、AWS 防火墙也没开** —— 两头都要做。
== 这一版做什么 ==
1. **删掉两个隧道单元**(先 stop + disable + 删文件 + daemon-reload)
2. **nginx 加 9100 监听**:
* `http://<服务器IP>:9100/` 直接出官网
* 中转接口也在 9100 上(`/v1/...`、`/download/...`)
⚠ 这需要 **AWS Lightsail 控制台里开放 9100**,
只有用户能做(我没有他的 AWS 凭据)。
3. **修 IP 访问 404**:给 80 端口加一个 default server,
把不认识的 Host 也送到同一套后端 —— 这样 `http://IP/` 能用。
== 为什么 9100 用 HTTP 而不是 HTTPS ==
IP 地址**没法签证书**(Let's Encrypt 不给纯 IP 签)。
所以 `https://<服务器IP>:9100` 会报证书错。
9100 定位是"Cloudflare 出问题时的直连备用通道",
用 HTTP 就行 —— 中转的数据本身有自己的校验。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run, upload # noqa: E402
# ---------------------------------------------------------------- nginx 片段
# 在 ykxmz 站点的 443 server 里补 9100 监听,并加一个 80 的 default server。
#
# 为什么加在**同一个文件**里:这样只有一份 server 块要维护,
# 后端地址(127.0.0.1:13245)只写一次,改后端不用改两处。
EXTRA_LISTEN = """
# ---- 9100:Cloudflare 之外的直连通道 ----
#
# 用户要求:"网站官网不是 <服务器IP> 吗,改成 <服务器IP>:9100"。
#
# ⚠ 用 IP 没法签证书(Let's Encrypt 不给纯 IP 签),所以这里是 HTTP。
# 定位是备用通道:Cloudflare 出问题时还能直接连上。
#
# ⚠ 还需要在 **AWS Lightsail 控制台**开放 9100 端口 ——
# 光配 nginx 没用,外面照样连不上(实测 9100 现在不通)。
listen 9100;
listen [::]:9100;
"""
DEFAULT_SERVER = """
# ======================================================================
# 80 端口的默认站点
# ======================================================================
#
# 为什么需要它:原来唯一的 80 站点是 `server_name yk.xxmxxs.top`。
# 用 IP 直接访问(`http://<服务器IP>/`)时 Host 不匹配,
# nginx 就拿这个站点当 default server,把请求转给 relay ——
# relay 不认这个 Host,返回 404。
#
# **这很可能就是"感觉网站不稳定"的来源**:用域名是对的,用 IP 就 404,
# 用户不知道区别,只会觉得"时好时坏"。
#
# 现在显式指定一个默认站点:不管什么 Host,都送到同一套后端。
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
access_log /var/log/nginx/ykxmz_default_access.log;
error_log /var/log/nginx/ykxmz_default_error.log;
# 用 IP 访问时不要跳 https —— IP 没证书,跳过去浏览器会报警告,
# 用户会以为网站坏了。直接给内容。
client_max_body_size 64m;
server_tokens off;
location / {
proxy_pass http://127.0.0.1:13245;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 300s;
proxy_connect_timeout 30s;
}
}
"""
def main() -> int:
print("=" * 62)
print(" 服务器整理:删隧道 + 加 9100 + 修 IP 访问")
print("=" * 62)
print()
# ---------------------------------------------------------------- 1. 删隧道
print("── 1. 删掉隧道残留 ──")
r = run(r'''
for u in lanremote-tunnel cloudflared lanremote-relay; do
if systemctl list-unit-files 2>/dev/null | grep -q "^$u"; then
echo " 处理 $u"
sudo systemctl stop $u 2>/dev/null
sudo systemctl disable $u 2>/dev/null
sudo rm -f /etc/systemd/system/$u.service \
/lib/systemd/system/$u.service \
/etc/systemd/system/multi-user.target.wants/$u.service 2>/dev/null
fi
done
# 还有没有别的相关残留
sudo find /etc/systemd/system /lib/systemd/system -maxdepth 2 \
-iname "*lanremote*" -o -iname "*cloudflared*" -o -iname "*tunnel*" 2>/dev/null \
| while read f; do echo " 删 $f"; sudo rm -f "$f"; done
sudo systemctl daemon-reload
echo " --- 之后还剩什么 ---"
systemctl list-units --all --no-pager --plain 2>/dev/null \
| grep -iE 'lanremote|cloudflared|tunnel' | sed 's/^/ /' || echo " (干净了)"
''', timeout=180, quiet=True)
print(r[1] if isinstance(r, tuple) else r)
# ---------------------------------------------------------------- 2. 改 nginx
print()
print("── 2. nginx:加 9100 监听 + 80 默认站点 ──")
r = run("sudo cat /etc/nginx/sites-available/ykxmz", timeout=90, quiet=True)
conf = r[1] if isinstance(r, tuple) else str(r)
if "listen 9100" in conf:
print(" 9100 已经配过了")
else:
# 在 443 那个 server 块的 listen 后面插 9100
anchor = " listen 443 ssl; # managed by Certbot"
if anchor not in conf:
# 换一个可能的形式
import re
m = re.search(r"\n(\s*)listen 443[^\n]*\n", conf)
if not m:
print(" ✗ 找不到 443 的 listen 行")
return 1
anchor = m.group(0).strip()
conf = conf.replace(anchor, anchor + "\n" + EXTRA_LISTEN.rstrip(),
1)
conf = conf.rstrip() + "\n" + DEFAULT_SERVER
tmp = ROOT / "_build" / "_nginx_ykxmz.conf"
tmp.write_text(conf, encoding="utf-8", newline="\n")
upload(str(tmp), "/tmp/ykxmz.conf.new")
tmp.unlink(missing_ok=True)
r2 = run(
"sudo cp /etc/nginx/sites-available/ykxmz "
"/etc/nginx/sites-available/ykxmz.bak.$(date +%s) && "
"sudo mv /tmp/ykxmz.conf.new /etc/nginx/sites-available/ykxmz && "
"sudo nginx -t 2>&1 | tail -3", timeout=180, quiet=True)
out = r2[1] if isinstance(r2, tuple) else str(r2)
print(" " + out.replace("\n", "\n "))
if "successful" not in out:
print(" ✗ 配置有错,撤回")
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/ykxmz.bak.* "
"| head -1) /etc/nginx/sites-available/ykxmz", timeout=90,
quiet=True)
return 1
print(" ✓ 已写入并备份")
print()
print("── 3. 重载 nginx ──")
r = run("sudo systemctl reload nginx && sleep 1 && systemctl is-active nginx",
timeout=120, quiet=True)
print(" nginx: " + (r[1] if isinstance(r, tuple) else str(r)).strip())
# ---------------------------------------------------------------- 4. 复核
print()
print("── 4. 复核 ──")
r = run(r'''
echo " --- 监听端口 ---"
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443|9100) ' | sed 's/^/ /'
echo " --- 用 IP 访问(不带 Host)---"
curl -s -o /dev/null -w " http://127.0.0.1/ -> %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
curl -s -o /dev/null -w " http://127.0.0.1:9100/ -> %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1:9100/
echo " --- 用域名访问 ---"
curl -s -o /dev/null -w " https://yk.xxmxxs.top/ -> %{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/
curl -s -o /dev/null -w " /v1/health -> %{http_code}\n" --max-time 15 https://yk.xxmxxs.top/v1/health
''', timeout=180, quiet=True)
print(r[1] if isinstance(r, tuple) else r)
print()
print(" ⚠ 还需要你在 **AWS Lightsail 控制台**里开放 9100 端口:")
print(" 实例 -> 联网 -> IPv4 防火墙 -> 添加规则 -> 自定义 -> TCP 9100")
print(" 光配 nginx 没用,外面照样连不上。")
return 0
if __name__ == "__main__":
sys.exit(main())
+299 -212
View File
@@ -1,212 +1,299 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
r'''安装包完整验收:装 → 验 → 卸 → 再验。 r'''安装包完整验收:装 → 验 → 卸 → 再验。
用户要求"所有东西做完记得先测试能不能用",所以每一步都实测。 用户要求"所有东西做完记得先测试能不能用",所以每一步都实测。
⚠ 环境注意: ⚠ 环境注意:
我这个 shell **不是管理员**,而安装器会提升。 我这个 shell **不是管理员**,而安装器会提升。
所以所有"清理"动作都要交给**安装器/卸载器**自己做, 所以所有"清理"动作都要交给**安装器/卸载器**自己做,
不能靠 PowerShell 杀进程或删 Program Files(会拒绝访问)。 不能靠 PowerShell 杀进程或删 Program Files(会拒绝访问)。
补丁历史(这轮修的四个真 bug): 补丁历史(这轮修的四个真 bug):
1. --uninstall 里 Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死" 1. --uninstall 里 Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死"
2. --uninstall 里 input() 无 stdin 时永久阻塞 -> 控制面板卸载卡死 2. --uninstall 里 input() 无 stdin 时永久阻塞 -> 控制面板卸载卡死
3. _kill_running 用 taskkill /IM ykxmz.exe 把自己也杀了 3. _kill_running 用 taskkill /IM ykxmz.exe 把自己也杀了
-> 后面的删自启/删数据全没执行 -> 后面的删自启/删数据全没执行
4. 安装器关不掉以管理员身份跑着的旧实例 -> "无法关闭应用程序" -> 安装回滚 4. 安装器关不掉以管理员身份跑着的旧实例 -> "无法关闭应用程序" -> 安装回滚
-> 改成 PrepareToInstall 里自己 taskkill /F -> 改成 PrepareToInstall 里自己 taskkill /F
''' '''
from __future__ import annotations from __future__ import annotations
import json import json
import re import re
import subprocess import shutil
import sys import subprocess
import time import sys
from pathlib import Path import time
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
def _newest_setup() -> Path:
"""自动找 dist 下最新的安装包。 def _newest_setup() -> Path:
"""自动找 dist 下最新的安装包。
⚠ 这里原来是写死的 `ykxmz-setup-1.8.2.exe`。后果很隐蔽:
⚠ 这里原来是写死的 `ykxmz-setup-1.8.2.exe`。后果很隐蔽:
测试去装一个**根本不存在的旧版本包** -> 安装静默失败 ->
读到的还是上一次残留的状态 -> 报出一堆莫名其妙的失败 测试去装一个**根本不存在的旧版本包** -> 安装静默失败 ->
("配对码实际是 xxx"、"版本显示 v1.8.2")。 读到的还是上一次残留的状态 -> 报出一堆莫名其妙的失败
("配对码实际是 xxx"、"版本显示 v1.8.2")。
版本号每次发版都在变,写死必然过期。
`bump_version_generic.py` 更新了别的测试,唯独漏了这个文件。 版本号每次发版都在变,写死必然过期。
`bump_version_generic.py` 更新了别的测试,唯独漏了这个文件。
改成按版本号排序取最新的 —— 一劳永逸,以后不用再维护。
""" 改成按版本号排序取最新的 —— 一劳永逸,以后不用再维护。
cands = [] """
for p in (ROOT / "dist").glob("ykxmz-setup-*.exe"): cands = []
# 文件名里抠版本号,按数字段排序(不能按字符串,否则 1.9.0 > 2.1.4) for p in (ROOT / "dist").glob("ykxmz-setup-*.exe"):
m = re.search(r"ykxmz-setup-(\d+)\.(\d+)\.(\d+)\.exe$", p.name) # 文件名里抠版本号,按数字段排序(不能按字符串,否则 1.9.0 > 2.1.4)
if m: m = re.search(r"ykxmz-setup-(\d+)\.(\d+)\.(\d+)\.exe$", p.name)
cands.append((tuple(int(x) for x in m.groups()), p)) if m:
if not cands: cands.append((tuple(int(x) for x in m.groups()), p))
return ROOT / "dist" / "ykxmz-setup-不存在.exe" if not cands:
cands.sort(key=lambda t: t[0]) return ROOT / "dist" / "ykxmz-setup-不存在.exe"
return cands[-1][1] cands.sort(key=lambda t: t[0])
return cands[-1][1]
SETUP = _newest_setup()
APP = Path(r"C:\Program Files\ykxmz") SETUP = _newest_setup()
DATA = Path(r"C:\ProgramData\ykxmz") APP = Path(r"C:\Program Files\ykxmz")
EXE = APP / "ykxmz.exe" DATA = Path(r"C:\ProgramData\ykxmz")
REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" EXE = APP / "ykxmz.exe"
r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1") REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
# ⚠ PowerShell 的注册表 provider 路径**必须带冒号**(HKCU:\...)。 r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1")
# 写成 HKCU\... 不是合法路径,Get-ItemProperty 静默返回空, # ⚠ PowerShell 的注册表 provider 路径**必须带冒号**(HKCU:\...)。
# 于是"开机自启"这一项永远误报失败。(这个坑我自己踩了一次。) # 写成 HKCU\... 不是合法路径,Get-ItemProperty 静默返回空,
RUN_KEY = r"HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" # 于是"开机自启"这一项永远误报失败。(这个坑我自己踩了一次。)
CODE = "517284" RUN_KEY = r"HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
CODE = "517284"
passed = failed = 0
passed = failed = 0
def check(name, ok, detail=""):
global passed, failed def check(name, ok, detail=""):
if ok: global passed, failed
passed += 1 if ok:
print(" [PASS] " + name) passed += 1
else: print(" [PASS] " + name)
failed += 1 else:
print(" [FAIL] " + name + " " + str(detail)) failed += 1
print(" [FAIL] " + name + " " + str(detail))
def ps(command: str, timeout: int = 300) -> str:
r = subprocess.run( def ps(command: str, timeout: int = 300) -> str:
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", r = subprocess.run(
"-Command", command], ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
capture_output=True, text=True, timeout=timeout, "-Command", command],
encoding="utf-8", errors="replace") capture_output=True, text=True, timeout=timeout,
return (r.stdout or "").strip() encoding="utf-8", errors="replace")
return (r.stdout or "").strip()
def run_bat(lines: list, timeout: int = 600) -> tuple:
"""把命令写进 .bat 再跑。 def run_bat(lines: list, timeout: int = 600) -> tuple:
"""把命令写进 .bat 再跑。
为什么不直接 subprocess.run(["cmd", "/c", "..."]):
引号会被 Python 和 cmd 来回吃掉,实测 0.0 秒就返回 exit=1。 为什么不直接 subprocess.run(["cmd", "/c", "..."]):
写成 .bat 文件最稳,而且和学校实际的批量部署方式一致。 引号会被 Python 和 cmd 来回吃掉,实测 0.0 秒就返回 exit=1。
""" 写成 .bat 文件最稳,而且和学校实际的批量部署方式一致。
bat = ROOT / "_build" / "_run.bat" """
bat.write_text("\r\n".join(["@echo off"] + lines), encoding="gbk", bat = ROOT / "_build" / "_run.bat"
errors="replace") bat.write_text("\r\n".join(["@echo off"] + lines), encoding="gbk",
start = time.perf_counter() errors="replace")
r = subprocess.run(["cmd", "/c", str(bat)], capture_output=True, start = time.perf_counter()
text=True, timeout=timeout, r = subprocess.run(["cmd", "/c", str(bat)], capture_output=True,
encoding="gbk", errors="replace") text=True, timeout=timeout,
return r.returncode, time.perf_counter() - start, (r.stdout or "") encoding="gbk", errors="replace")
return r.returncode, time.perf_counter() - start, (r.stdout or "")
def install() -> tuple:
return run_bat([ def install() -> tuple:
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART ' return run_bat([
'/LOG="%s" /DIR="C:\\Program Files\\ykxmz" ' '"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART '
'/CODE=%s /TASKS=autostart' % ( '/LOG="%s" /DIR="C:\\Program Files\\ykxmz" '
SETUP, ROOT / "_build" / "setup.log", CODE), '/CODE=%s /TASKS=autostart' % (
]) SETUP, ROOT / "_build" / "setup.log", CODE),
])
def uninstall() -> tuple:
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue " def _clean_previous() -> None:
"| Select-Object -First 1 -ExpandProperty FullName" % APP) """把上一次安装/测试的残留清掉,让这一轮从零开始。
if not found:
return -1, 0.0, "" ⚠ **这个函数第一版是坏的**,记下来免得再犯:
return run_bat([
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' % found.strip(), 第一版对卸载器和 rmdir 都用了
]) subprocess.run(["cmd", "/c", '"%s" ...' % path])
这种**嵌套引号**的写法。而 `run_bat()` 上面那大段注释
就是在说这个坑:"引号会被 Python 和 cmd 来回吃掉,
def main() -> int: 实测 0.0 秒就返回 exit=1"。
check("安装包在", SETUP.is_file(), str(SETUP))
if not SETUP.is_file(): 于是"装前清理"实际上什么都没清 —— 目录还在、旧的 settings.json
return 1 还在(配对码是上一轮遗留的值),测试报:
# ---------------- 安装 ---------------- [FAIL] 安装目录已清
print() [FAIL] 数据目录已清
print(" 一、静默安装(配对码 %s)" % CODE) [FAIL] 配对码正确写入 实际 '(上一轮的旧值)'
code, elapsed, _out = install()
check("安装器退出码 0", code == 0, "exit=%s" % code) 看上去像"安装包有 bug",其实是**清理函数自己没跑通**。
check("安装没卡死", elapsed < 120, "%.1f 秒" % elapsed)
print(" %.1f 秒" % elapsed) 现在:
time.sleep(8) * 卸载器走 `run_bat()`(已经验证过的方式)
* 删目录用 `shutil.rmtree` —— **完全不过 cmd**,没有引号问题,
print() 而且失败时能拿到真实原因(权限 / 占用),不会静默吞掉
print(" 二、文件与数据") """
check("ykxmz.exe 在", EXE.is_file()) # 1) 走正规卸载器(顺带清注册表和开机自启)
check("_internal 在", (APP / "_internal").is_dir()) try:
check("数据目录在", DATA.is_dir()) found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
settings = DATA / "data" / "settings.json" "| Select-Object -First 1 -ExpandProperty FullName" % APP)
check("settings.json 生成了", settings.is_file()) if found and found.strip():
if settings.is_file(): run_bat(['"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART'
saved = json.loads(settings.read_text(encoding="utf-8")) % found.strip()], timeout=240)
got = saved.get("pairing_code") time.sleep(4)
check("配对码正确写入", got == CODE, "实际 %r" % got) except Exception as exc: # noqa: BLE001
print(" (卸载器这步跳过: %s)" % type(exc).__name__)
acl = ps("(Get-Acl '%s').Access | Where-Object { $_.IdentityReference "
"-like '*Users*' } | Select-Object -First 1 " # 2) 还有进程占着文件就先杀掉
"-ExpandProperty FileSystemRights" % (DATA / "data")) try:
check("普通用户对数据目录可写", ps("Get-Process ykxmz -ErrorAction SilentlyContinue | "
any(w in acl for w in ("Modify", "FullControl", "Write")), "Stop-Process -Force -ErrorAction SilentlyContinue")
acl or "(读不到)") except Exception: # noqa: BLE001
pass
print() time.sleep(1)
print(" 三、--status")
status = ps("& '%s' --status" % EXE, timeout=120) # 3) 直接删目录。**不过 cmd** —— shutil.rmtree 能报出为什么删不掉。
check("--status 显示配对码", CODE in status, status[:160]) for target, label in ((APP, "安装目录"), (DATA, "数据目录")):
for line in status.splitlines()[:7]: if not target.exists():
print(" " + line.strip()) continue
problems = []
print()
print(" 四、进程与端口") def _onerr(func, path, exc_info): # noqa: ANN001
count = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | " problems.append("%s: %s" % (path, exc_info[1]))
"Measure-Object).Count")
check("被控端在跑", count.strip().splitlines()[-1:] != ["0"], count) shutil.rmtree(str(target), onerror=_onerr)
listen = ps("(Get-NetTCPConnection -State Listen -ErrorAction " if target.exists():
"SilentlyContinue | Where-Object { $_.LocalPort -eq 8765 } | " print(" ⚠ %s 没清干净:%s" % (label, problems[:2]))
"Measure-Object).Count") else:
check("8765 在监听", listen.strip().splitlines()[-1:] != ["0"], listen) print(" 清了 %s" % label)
run_value = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction " time.sleep(1)
"SilentlyContinue).ykxmz" % RUN_KEY)
check("开机自启已设置", "ykxmz" in run_value.lower(), run_value or "(无)")
def uninstall() -> tuple:
# ---------------- 卸载 ---------------- found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
print() "| Select-Object -First 1 -ExpandProperty FullName" % APP)
print(" 五、静默卸载") if not found:
code, elapsed, _out = uninstall() return -1, 0.0, ""
check("卸载器执行了", code != -1, "找不到卸载器") return run_bat([
check("卸载没卡死", elapsed < 120, "%.1f 秒" % elapsed) '"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' % found.strip(),
print(" %.1f 秒" % elapsed) ])
time.sleep(10)
print() def main() -> int:
print(" 六、卸载是否干净") check("安装包在", SETUP.is_file(), str(SETUP))
check("安装目录已删", if not SETUP.is_file():
not APP.is_dir(), return 1
"还剩: %s" % ", ".join(p.name for p in APP.iterdir())
if APP.is_dir() else "") # ---------------- 装前先清干净 ----------------
check("数据目录已删", not DATA.is_dir()) #
check("注册表卸载项已清", REG not in ps("if (Test-Path '%s') " # ⚠ 这一步是必须的,不是保险起见。
"{ 'exists' }" % REG)) #
left = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | " # 踩过的坑:测试报 `配对码正确写入 实际 '(旧的残留值)'`,
"Measure-Object).Count") # 而 `(旧的残留值)` 是**上一次测试遗留**的配对码 ——
check("进程已退出", left.strip().splitlines()[-1:] == ["0"], left) # 数据目录 `C:\ProgramData\ykxmz\data` 在卸载时**不会**被删掉
still = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction " # (见 .iss 的说明:那里存着用户的中转地址、配对码等,卸载也不该删),
"SilentlyContinue).ykxmz" % RUN_KEY) # 所以机器上会留着上一轮的 settings.json。
check("开机自启已清掉", not still, still or "") #
# 这次装的是 `/CODE=517284`,正常会覆盖 —— 但如果在**上一次
print() # 安装的进程还占着端口/文件**的时候装,`--code` 那一步可能失败,
print("=" * 56) # 于是检查就读到了旧值。表现成"安装包有 bug",其实是测试
print(" 通过 %d 项,失败 %d 项" % (passed, failed)) # **没把环境擦干净**。
return 0 if failed == 0 else 1 #
# 所以:装之前先把两个目录删掉,让测试从零开始。
print()
if __name__ == "__main__": print(" 零、清理上一次的残留")
sys.exit(main()) _clean_previous()
check("安装目录已清", not APP.is_dir(), str(APP))
check("数据目录已清", not DATA.is_dir(), str(DATA))
check("没有残留进程",
not ps("(Get-Process ykxmz -ErrorAction SilentlyContinue "
"| Measure-Object).Count").strip().strip("0").strip())
# ---------------- 安装 ----------------
print()
print(" 一、静默安装(配对码 %s)" % CODE)
code, elapsed, _out = install()
check("安装器退出码 0", code == 0, "exit=%s" % code)
check("安装没卡死", elapsed < 120, "%.1f 秒" % elapsed)
print(" %.1f 秒" % elapsed)
time.sleep(8)
print()
print(" 二、文件与数据")
check("ykxmz.exe 在", EXE.is_file())
check("_internal 在", (APP / "_internal").is_dir())
check("数据目录在", DATA.is_dir())
settings = DATA / "data" / "settings.json"
check("settings.json 生成了", settings.is_file())
if settings.is_file():
saved = json.loads(settings.read_text(encoding="utf-8"))
got = saved.get("pairing_code")
check("配对码正确写入", got == CODE, "实际 %r" % got)
acl = ps("(Get-Acl '%s').Access | Where-Object { $_.IdentityReference "
"-like '*Users*' } | Select-Object -First 1 "
"-ExpandProperty FileSystemRights" % (DATA / "data"))
check("普通用户对数据目录可写",
any(w in acl for w in ("Modify", "FullControl", "Write")),
acl or "(读不到)")
print()
print(" 三、--status")
status = ps("& '%s' --status" % EXE, timeout=120)
check("--status 显示配对码", CODE in status, status[:160])
for line in status.splitlines()[:7]:
print(" " + line.strip())
print()
print(" 四、进程与端口")
count = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
"Measure-Object).Count")
check("被控端在跑", count.strip().splitlines()[-1:] != ["0"], count)
listen = ps("(Get-NetTCPConnection -State Listen -ErrorAction "
"SilentlyContinue | Where-Object { $_.LocalPort -eq 8765 } | "
"Measure-Object).Count")
check("8765 在监听", listen.strip().splitlines()[-1:] != ["0"], listen)
run_value = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
"SilentlyContinue).ykxmz" % RUN_KEY)
check("开机自启已设置", "ykxmz" in run_value.lower(), run_value or "(无)")
# ---------------- 卸载 ----------------
print()
print(" 五、静默卸载")
code, elapsed, _out = uninstall()
check("卸载器执行了", code != -1, "找不到卸载器")
check("卸载没卡死", elapsed < 120, "%.1f 秒" % elapsed)
print(" %.1f 秒" % elapsed)
time.sleep(10)
print()
print(" 六、卸载是否干净")
check("安装目录已删",
not APP.is_dir(),
"还剩: %s" % ", ".join(p.name for p in APP.iterdir())
if APP.is_dir() else "")
check("数据目录已删", not DATA.is_dir())
check("注册表卸载项已清", REG not in ps("if (Test-Path '%s') "
"{ 'exists' }" % REG))
left = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
"Measure-Object).Count")
check("进程已退出", left.strip().splitlines()[-1:] == ["0"], left)
still = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
"SilentlyContinue).ykxmz" % RUN_KEY)
check("开机自启已清掉", not still, still or "")
print()
print("=" * 56)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -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.2.0.apk"
APK = _latest_apk() APK = _latest_apk()
+4 -4
View File
@@ -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.2.0.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.7.zip" SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.2.0.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.7.apk" WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.2.0.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.2.0-说明.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])
+138 -69
View File
@@ -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,69 +74,103 @@ 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", # ⚠ 断言写成"数组里有这几个面板"而不是"精确等于某个字符串" ——
"settingsPanel, mousePanel};" in main_java, # 第一版写的是 `"settingsPanel, mousePanel};" in main_java`,
"漏了的话鼠标面板永远不隐藏") # 后来往数组里加了 choicePanel,那串就不匹配了(假失败)。
# 验"包含"而不是"全等",才不会因为多一个面板就红。
m_arr = re.search(r"View\[\] all = \{([^}]*)\}", main_java)
arr = m_arr.group(1) if m_arr else ""
for panel in ("connectPanel", "controlPanel", "mirrorPanel", "lanPanel",
"settingsPanel", "mousePanel", "choicePanel"):
check("★ showOnly 数组里有 %s" % panel, panel in arr,
"漏了的话那个面板永远不隐藏")
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 +182,60 @@ 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 ---------------- # ---------------- 五之二、连接后的选择页 ----------------
print() print()
print("五、电脑端 allow_new_pairing feature") print("五之二、连接后的选择页(远控 / 静默更新被控端)")
#
# 用户要求:"输入配对码后,进入一个页面,页面有两个按钮,
# 第一个按钮是远控,第二个按钮是静默更新被控端"
check("有 choicePanel 字段", "private FrameLayout choicePanel;" in main_java)
check("有 buildChoicePanel", "private void buildChoicePanel()" in main_java)
check("有 showChoicePanel", "private void showChoicePanel()" in main_java)
check("★ 第一个按钮是「远控」", 'bigButton("远控"' in main_java,
"按钮顺序要跟用户说的一致")
check("★ 第二个按钮是「静默更新被控端」",
'bigButton("静默更新被控端"' in main_java)
check(" 更新按钮复用 checkPcUpdate()", "checkPcUpdate();" in main_java)
check("★ 连接成功后进的是选择页(不是直接进控制页)",
"showChoicePanel();" in main_java,
"改漏了的话用户根本看不到选择页")
check(" 选择页也做了顶部让位",
"applyPanelTopMargin(choicePanel);" in main_java)
check(" 选择页显示电脑端版本", "choiceVersionText" in main_java)
check(" 记下了电脑名(不然只能显示 IP)",
"private volatile String lastDeviceName" in main_java,
"第一版忘了定义这个字段,编译报「找不到符号」")
# ⚠ 自动切中转恢复那个调用点**不该**改成选择页 ——
# 用户本来就在远控页,中途弹选择页会打断他。
i = main_java.find("已切到中转服务器,连接恢复")
check("★ 自动切中转恢复后仍是控制页(没被误改)",
i > 0 and "showControlPanel();" in main_java[i:i + 300],
"改错地方的话,用着用着会突然被弹回选择页")
# ---------------- 六、电脑端 allow_new_pairing ----------------
print()
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 +243,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, "它竟然没报错")
+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())
+28 -60
View File
@@ -24,69 +24,23 @@ $androidJar = Join-Path $tc "android-sdk\platforms\android-34\android.jar"
$script:results = @() $script:results = @()
# 当前会话是不是锁屏 / 没有交互桌面? # ⚠ 这里**原来有一段锁屏检测**,锁屏时把 needsDesktop 的阶段记成
# 锁屏时截屏类测试**必然**失败(拿不到屏幕内容),但那不是代码问题。 # `SKIP(锁屏)` 而不是 FAIL。**已经按用户要求删掉了。**
# 不识别的话汇总表一片红,真问题反而被淹没。
# #
# ⚠ 判断不能只看 OpenInputDesktop 成不成功 —— **锁屏时它同样成功** # 删的理由:那个好意带来一个更糟的后果 —— 锁屏时那几关根本不跑,
# (那时输入桌面是 Winlogon)。实测漏判过一次:屏幕明明锁着, # 于是回归"全绿"了,但关键的 Java 端到端、协议回归其实一跳而过。
# desktopAvailable 却是 true,于是 6/7、7/7 两个截屏阶段记成 FAIL, # 用户看到"全部通过"会以为没问题。
# 把真正的结论淹掉了。 #
# 正确做法是拿输入桌面的**名字**:Default = 正常,Winlogon = 锁屏。 # **"因为环境不满足所以不测"比"测了发现失败"更危险**:
$script:desktopAvailable = $true # 失败至少是信息,跳过是假的安全感。
$script:desktopName = "(未检测)" #
try { # 现在一律真跑、真记。环境不合适导致失败的话,就去修环境或改测试,
Add-Type -Namespace Ykxmz -Name Desk -MemberDefinition @' # 而不是让它变成 SKIP。
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError=true)]
public static extern System.IntPtr OpenInputDesktop(int flags, bool inherit, int access);
[System.Runtime.InteropServices.DllImport("user32.dll")]
public static extern bool CloseDesktop(System.IntPtr h);
[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError=true, CharSet=System.Runtime.InteropServices.CharSet.Unicode)]
public static extern bool GetUserObjectInformationW(System.IntPtr h, int idx, System.Text.StringBuilder pv, int len, ref int need);
public static string InputDesktopName() {
System.IntPtr h = OpenInputDesktop(0, false, 0x0001);
if (h == System.IntPtr.Zero) return "";
try {
System.Text.StringBuilder sb = new System.Text.StringBuilder(256);
int need = 0;
if (GetUserObjectInformationW(h, 2, sb, 512, ref need)) return sb.ToString();
return "";
} finally { CloseDesktop(h); }
}
public static bool HasDesktop() {
string n = InputDesktopName();
if (n.Length == 0) return false;
if (n.Equals("Winlogon", System.StringComparison.OrdinalIgnoreCase)) return false;
return true;
}
'@ -ErrorAction Stop
$script:desktopName = [Ykxmz.Desk]::InputDesktopName()
if ($script:desktopName -eq "") { $script:desktopName = "(拿不到)" }
$script:desktopAvailable = [Ykxmz.Desk]::HasDesktop()
} catch {
$script:desktopName = "(检测失败: $($_.Exception.GetType().Name))"
$script:desktopAvailable = $true # 查不出来就当可用,别把失败藏起来
}
# 无论可不可用都打一行 —— 上次漏判就是因为什么都没打,无从判断。
Write-Host (" 输入桌面: {0} -> {1}" -f $script:desktopName,
$(if ($script:desktopAvailable) { "可用" } else { "不可用(锁屏)" })) `
-ForegroundColor $(if ($script:desktopAvailable) { "DarkGray" } else { "Yellow" })
if (-not $script:desktopAvailable) {
Write-Host ""
Write-Host " ⚠ 检测到电脑处于锁屏状态。" -ForegroundColor Yellow
Write-Host " 截屏相关的测试会失败,这是环境限制,不是代码问题。" -ForegroundColor Yellow
Write-Host " 这些阶段会记为 SKIP 而不是 FAIL。解锁后重跑即可验证。" -ForegroundColor Yellow
}
function Record($name, $ok, $needsDesktop = $false) { function Record($name, $ok, $needsDesktop = $false) {
# needsDesktop 的阶段在锁屏时记 SKIP —— 避免误报把真问题淹没 # ⚠ $needsDesktop 参数**保留但不再使用** ——
if ($needsDesktop -and -not $script:desktopAvailable) { # 6 个调用点还在传它,留着就不用改那些地方。
$script:results += [pscustomobject]@{ # 以后真要重新引入"特定环境下跳过",签名已经在了。
项目 = $name; 结果 = "SKIP(锁屏)" }
return
}
$script:results += [pscustomobject]@{ $script:results += [pscustomobject]@{
项目 = $name; 结果 = $(if ($ok) { "PASS" } else { "FAIL" }) } 项目 = $name; 结果 = $(if ($ok) { "PASS" } else { "FAIL" }) }
} }
@@ -554,6 +508,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」——
+1 -1
View File
@@ -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.2.0">
<!-- ⚠ 允许明文 HTTP。 <!-- ⚠ 允许明文 HTTP。
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者 中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
+2 -2
View File
@@ -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.2.0.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.2.0" `
--auto-add-overlay --auto-add-overlay
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" } if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
+208 -26
View File
@@ -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,12 +328,36 @@ 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;
/**
* 连上之后先给用户看的那一页:远控 / 静默更新被控端。
*
* <p>用户要求:"输入配对码后,进入一个页面,页面有两个按钮,
* 第一个按钮是远控,第二个按钮是静默更新被控端"。
*
* <p>为什么不让它直接进控制页:老版本的被控端可能缺功能或者有 bug,
* "先更新再远控"是更合理的顺序 —— 但也不能强制,所以给用户选。
*/
private FrameLayout choicePanel;
/** 选择页上显示"电脑端版本 X.Y.Z"那一行。 */
private TextView choiceVersionText;
/** 选择页上显示"已连接 XXX"。 */
private TextView choiceWhoText;
private View mousePad; private View mousePad;
private boolean mouseDragging = false; private boolean mouseDragging = false;
private float mouseLastX = 0f; private float mouseLastX = 0f;
@@ -419,6 +444,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
// 编译就报 cannot find symbol。教训:写调用之前先 grep 一遍。 // 编译就报 cannot find symbol。教训:写调用之前先 grep 一遍。
loadQuickSlots(); loadQuickSlots();
buildMousePanel(); // 只建,先不显示(showOnly 会切) buildMousePanel(); // 只建,先不显示(showOnly 会切)
buildChoicePanel(); // 同上:连上之后才显示
buildQuickBar(); buildQuickBar();
// 面板顶部给快捷栏让出一截,否则每页顶部内容会被盖住 // 面板顶部给快捷栏让出一截,否则每页顶部内容会被盖住
@@ -428,14 +454,19 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
applyPanelTopMargin(lanPanel); applyPanelTopMargin(lanPanel);
applyPanelTopMargin(settingsPanel); applyPanelTopMargin(settingsPanel);
applyPanelTopMargin(mousePanel); applyPanelTopMargin(mousePanel);
applyPanelTopMargin(choicePanel);
root.addView(mousePanel, new FrameLayout.LayoutParams( root.addView(mousePanel, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT)); ViewGroup.LayoutParams.MATCH_PARENT));
mousePanel.setVisibility(View.GONE); mousePanel.setVisibility(View.GONE);
root.addView(choicePanel, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
choicePanel.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 +2466,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 +2482,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 +3032,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 +3042,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 +3057,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 +3084,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 +3104,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 +3129,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 +3208,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) {
@@ -3408,12 +3443,143 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
"dx", (int) (dx * 3.0f), "dy", (int) (dy * 3.0f)), "鼠标"); "dx", (int) (dx * 3.0f), "dy", (int) (dy * 3.0f)), "鼠标");
} }
// ==================================================================
// 连接后的选择页(远控 / 静默更新)
// ==================================================================
/**
* 建选择页。
*
* <p>布局从上到下:连接信息 -> 版本 -> [远控] -> [静默更新被控端]。
* 按钮顺序就是用户说的顺序(远控在前)。
*/
private void buildChoicePanel() {
choicePanel = new FrameLayout(this);
choicePanel.setBackgroundColor(C_BG);
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setPadding(dp(14), dp(20), dp(14), dp(14));
box.setGravity(android.view.Gravity.CENTER_HORIZONTAL);
choicePanel.addView(box, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
choiceWhoText = new TextView(this);
choiceWhoText.setText("已连接");
choiceWhoText.setTextColor(C_FG);
choiceWhoText.setTextSize(14f);
choiceWhoText.setGravity(android.view.Gravity.CENTER);
box.addView(choiceWhoText);
choiceVersionText = new TextView(this);
choiceVersionText.setText("电脑端版本:正在查…");
choiceVersionText.setTextColor(C_DIM);
choiceVersionText.setTextSize(12f);
choiceVersionText.setGravity(android.view.Gravity.CENTER);
choiceVersionText.setPadding(0, dp(4), 0, dp(10));
box.addView(choiceVersionText);
// 第一个按钮:远控
Button ctrl = bigButton("远控", C_OK, 17);
LinearLayout.LayoutParams ctrlLp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(54));
ctrlLp.setMargins(0, 0, 0, dp(10));
ctrl.setLayoutParams(ctrlLp);
ctrl.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
showControlPanel();
}
});
box.addView(ctrl);
// 第二个按钮:静默更新被控端
Button upd = bigButton("静默更新被控端", C_ACCENT, 15);
LinearLayout.LayoutParams updLp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(54));
upd.setLayoutParams(updLp);
upd.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// 复用已经做好的那条链路:查版本 -> 确认 -> 触发更新
checkPcUpdate();
}
});
box.addView(upd);
TextView tip = new TextView(this);
tip.setText("更新时电脑会自动下载并静默安装,"
+ "不会弹任何窗口,设置和配对码都不变。");
tip.setTextColor(C_DIM);
tip.setTextSize(10f);
tip.setGravity(android.view.Gravity.CENTER);
tip.setPadding(0, dp(10), 0, 0);
box.addView(tip);
}
/** 显示选择页,并顺手查一下电脑端版本。 */
private void showChoicePanel() {
if (choicePanel == null) {
buildChoicePanel();
}
if (choiceWhoText != null) {
String who = lastDeviceName;
choiceWhoText.setText(who == null || who.isEmpty()
? "已连接" : "已连接「" + who + "」");
}
if (choiceVersionText != null) {
choiceVersionText.setText("电脑端版本:正在查…");
}
showOnly(choicePanel);
queryPcVersionForChoice();
}
/**
* 查电脑端版本填到选择页上。
*
* <p>单独写一个(不直接用 checkPcUpdate)是因为那个会弹确认框 ——
* 这里只是"让你看一眼版本",不该弹东西。
*/
private void queryPcVersionForChoice() {
io.execute(new Runnable() {
@Override
public void run() {
Map<String, Object> reply;
try {
reply = client.call(Json.obj("cmd", "update",
"action", "check"), 25000);
} catch (final Throwable error) {
reply = null;
}
final String text;
if (reply != null && Boolean.TRUE.equals(reply.get("ok"))) {
String cur = String.valueOf(reply.get("current"));
String latest = String.valueOf(reply.get("latest"));
text = Boolean.TRUE.equals(reply.get("needUpdate"))
? "电脑端 " + cur + " → 可更新到 " + latest
: "电脑端 " + cur + "(已是最新)";
} else {
text = "电脑端版本:查不到(可以点下面的更新按钮再试)";
}
ui.post(new Runnable() {
@Override
public void run() {
if (choiceVersionText != null) {
choiceVersionText.setText(text);
}
}
});
}
});
}
private void showOnly(View keep) { private void showOnly(View keep) {
// ⚠ 新增面板必须加进这个数组 —— 漏了的话那个面板永远不隐藏, // ⚠ 新增面板必须加进这个数组 —— 漏了的话那个面板永远不隐藏,
// 表现是"进了别的页面它还压在上面"。 // 表现是"进了别的页面它还压在上面"。
// (注释里那条"漏了 lanPanel 导致局域网连上没反应"就是这个坑。) // (注释里那条"漏了 lanPanel 导致局域网连上没反应"就是这个坑。)
View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel, View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,
settingsPanel, mousePanel}; settingsPanel, mousePanel, choicePanel};
for (View v : all) { for (View v : all) {
if (v != null) { if (v != null) {
v.setVisibility(v == keep ? View.VISIBLE : View.GONE); v.setVisibility(v == keep ? View.VISIBLE : View.GONE);
@@ -3675,6 +3841,14 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
/** 刚才连的地址/配对码/令牌,切回中转时要用。 */ /** 刚才连的地址/配对码/令牌,切回中转时要用。 */
private volatile String lastHost = ""; private volatile String lastHost = "";
/**
* 当前连上的电脑名。选择页要显示「已连接『电脑名』」——
* 只显示 IP 的话用户不好认是哪台。
*
* <p>⚠ 我第一版在这里凭直觉写了个 `lastDeviceName` 却忘了定义它,
* 编译报 "找不到符号"。**写代码时要用的变量,先确认它存在。**
*/
private volatile String lastDeviceName = "";
private volatile int lastPort; private volatile int lastPort;
private volatile String lastCode = ""; private volatile String lastCode = "";
private volatile String lastToken = ""; private volatile String lastToken = "";
@@ -3903,7 +4077,15 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
+ (finalName == null ? host : finalName) + (finalName == null ? host : finalName)
+ (finalLanDirect ? "(局域网)" : "(中转)")); + (finalLanDirect ? "(局域网)" : "(中转)"));
} }
showControlPanel(); // 用户要求:输入配对码连上之后先到选择页
// (远控 / 静默更新被控端),不直接进控制页。
//
// ⚠ 只有**这个**调用点改。下面"自动切到中转恢复"
// 那个 showControlPanel() 不改 —— 用户本来就在
// 远控页,中途弹选择页会打断他。
lastDeviceName = (finalName == null
? host : finalName);
showChoicePanel();
appendLog("已连接到 " + host); appendLog("已连接到 " + host);
toast("连接成功"); toast("连接成功");
} else { } else {
+3 -3
View File
@@ -3,7 +3,7 @@
======================================================== ========================================================
软件名称:ykxmz 软件名称:ykxmz
版本:2.1.7 版本:2.2.0
作者:小麦子 作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、 装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -197,7 +197,7 @@
八、版本记录 八、版本记录
-------------------------------------------------------- --------------------------------------------------------
2.1.7 2.2.0
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍 * 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2, * 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
合计 10 条连接),一种协议犯病另一种还在服务 合计 10 条连接),一种协议犯病另一种还在服务
@@ -218,6 +218,6 @@
-------------------------------------------------------- --------------------------------------------------------
作者:小麦子 作者:小麦子
软件:ykxmz 2.1.7 软件:ykxmz 2.2.0
许可条款见《许可协议》(LICENSE.txt) 许可条款见《许可协议》(LICENSE.txt)
-------------------------------------------------------- --------------------------------------------------------
+2 -2
View File
@@ -3,7 +3,7 @@
======================================================== ========================================================
软件名称:ykxmz 软件名称:ykxmz
版本:2.1.7 版本:2.2.0
作者:小麦子 作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
-------------------------------------------------------- --------------------------------------------------------
作者:小麦子 作者:小麦子
软件:ykxmz 2.1.7 软件:ykxmz 2.2.0
继续安装,即表示你已阅读并同意《许可协议》。 继续安装,即表示你已阅读并同意《许可协议》。
-------------------------------------------------------- --------------------------------------------------------
+3 -3
View File
@@ -3,7 +3,7 @@
======================================================== ========================================================
软件名称:ykxmz(远程管理工具) 软件名称:ykxmz(远程管理工具)
版本:2.1.7 版本:2.2.0
作者:小麦子 作者:小麦子
生效日期:2026 年 10 月 5 日 生效日期:2026 年 10 月 5 日
@@ -16,7 +16,7 @@
> 都不许拿去做下面第三条那些事。** > 都不许拿去做下面第三条那些事。**
> >
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"—— > (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
> 那和 AGPL 冲突,所以 2.1.7 起拆开了:代码授权归 LICENSE, > 那和 AGPL 冲突,所以 2.2.0 起拆开了:代码授权归 LICENSE,
> 使用约束归这份文件。) > 使用约束归这份文件。)
@@ -160,7 +160,7 @@
-------------------------------------------------------- --------------------------------------------------------
作者:小麦子 作者:小麦子
软件:ykxmz 2.1.7 软件:ykxmz 2.2.0
如果你不同意本条款的任何一条,请不要安装或使用本软件。 如果你不同意本条款的任何一条,请不要安装或使用本软件。
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。 代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
+40 -10
View File
@@ -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`、"管理后台"、"管理页"、
"管理入口"、管理密码之类。 "管理入口"、管理密码之类。
+8 -8
View File
@@ -3,7 +3,7 @@
======================================================== ========================================================
软件名称:ykxmz(远程管理工具) 软件名称:ykxmz(远程管理工具)
版本:2.1.7 版本:2.2.0
作者:小麦子 作者:小麦子
生效日期:2026 年 10 月 5 日 生效日期:2026 年 10 月 5 日
@@ -16,7 +16,7 @@
> 都不许拿去做下面第三条那些事。** > 都不许拿去做下面第三条那些事。**
> >
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"—— > (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
> 那和 AGPL 冲突,所以 2.1.7 起拆开了:代码授权归 LICENSE, > 那和 AGPL 冲突,所以 2.2.0 起拆开了:代码授权归 LICENSE,
> 使用约束归这份文件。) > 使用约束归这份文件。)
@@ -160,7 +160,7 @@
-------------------------------------------------------- --------------------------------------------------------
作者:小麦子 作者:小麦子
软件:ykxmz 2.1.7 软件:ykxmz 2.2.0
如果你不同意本条款的任何一条,请不要安装或使用本软件。 如果你不同意本条款的任何一条,请不要安装或使用本软件。
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。 代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
@@ -173,7 +173,7 @@
======================================================== ========================================================
软件名称:ykxmz 软件名称:ykxmz
版本:2.1.7 版本:2.2.0
作者:小麦子 作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -334,7 +334,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
-------------------------------------------------------- --------------------------------------------------------
作者:小麦子 作者:小麦子
软件:ykxmz 2.1.7 软件:ykxmz 2.2.0
继续安装,即表示你已阅读并同意《许可协议》。 继续安装,即表示你已阅读并同意《许可协议》。
-------------------------------------------------------- --------------------------------------------------------
@@ -345,7 +345,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
======================================================== ========================================================
软件名称:ykxmz 软件名称:ykxmz
版本:2.1.7 版本:2.2.0
作者:小麦子 作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、 装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -539,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
八、版本记录 八、版本记录
-------------------------------------------------------- --------------------------------------------------------
2.1.7 2.2.0
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍 * 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2, * 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
合计 10 条连接),一种协议犯病另一种还在服务 合计 10 条连接),一种协议犯病另一种还在服务
@@ -560,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
-------------------------------------------------------- --------------------------------------------------------
作者:小麦子 作者:小麦子
软件:ykxmz 2.1.7 软件:ykxmz 2.2.0
许可条款见《许可协议》(LICENSE.txt) 许可条款见《许可协议》(LICENSE.txt)
-------------------------------------------------------- --------------------------------------------------------
+2 -2
View File
@@ -27,7 +27,7 @@
; ============================================================================ ; ============================================================================
#define MyAppName "ykxmz" #define MyAppName "ykxmz"
#define MyAppVersion "2.1.7" #define MyAppVersion "2.2.0"
#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.2.0.exe /VERYSILENT /CODE=123456
; cmd 只等安装器本身,不等孙子进程,不会卡。 ; cmd 只等安装器本身,不等孙子进程,不会卡。
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \ Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
Description: "立即启动(后台运行,不显示窗口)"; \ Description: "立即启动(后台运行,不显示窗口)"; \
+1 -1
View File
@@ -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.2.0"
# ---------------- 参数(都能用环境变量覆盖)---------------- # ---------------- 参数(都能用环境变量覆盖)----------------
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""局域网遥控 · Windows 被控端。""" """局域网遥控 · Windows 被控端。"""
__version__ = "2.1.7" __version__ = "2.2.0"
APP_NAME = "ykxmz" APP_NAME = "ykxmz"
+180 -34
View File
@@ -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 等)
+2 -2
View File
@@ -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.2.0.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.2.0.0"),
], ],
) )
] ]