mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 19:30:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d368979aa | ||
|
|
48bc392466 | ||
|
|
fd8be87209 |
@@ -45,9 +45,9 @@
|
||||
|
||||
| 装在哪 | 是什么 | 文件 |
|
||||
|---|---|---|
|
||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.7.exe` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.7.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.7.apk` |
|
||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.2.0.exe` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.2.0.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-2.2.0.apk` |
|
||||
|
||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 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`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||
|
||||
### 第二步:手表上装 App
|
||||
|
||||
把 `ykxmz-watch-2.1.7.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
把 `ykxmz-watch-2.2.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
|
||||
打开 App,首页有四个入口:
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||
|
||||
把 `ykxmz-server-2.1.7.zip` 解压到那台机器上,然后一条命令:
|
||||
把 `ykxmz-server-2.2.0.zip` 解压到那台机器上,然后一条命令:
|
||||
|
||||
```bash
|
||||
bash deploy.sh
|
||||
@@ -456,7 +456,7 @@ windows\build_exe.ps1
|
||||
```powershell
|
||||
android\build_apk.ps1
|
||||
# 流程:aapt2 → javac → d8 → zipalign → apksigner
|
||||
# 产物:dist\ykxmz-watch-2.1.7.apk
|
||||
# 产物:dist\ykxmz-watch-2.2.0.apk
|
||||
```
|
||||
|
||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||
|
||||
@@ -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
|
||||
|
||||
- **手表端顶部快捷栏** —— 任何页面顶部都有一条栏,最多 4 个按钮:
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把「语音播报降级链」加进 verify_all.ps1。
|
||||
|
||||
== 为什么用文件而不是 python -c ==
|
||||
|
||||
要写进 .ps1 的内容里有 `$stageOut`。用 `python -c "..."` 穿过
|
||||
PowerShell 时会被先解析一遍,`$stageOut` 变成 `\` —— 上次 3a12
|
||||
就是这么坏的。
|
||||
|
||||
凡是内容里带 `$` 的,一律写成 .py 文件再跑。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||
|
||||
ANCHOR = "# ---------------------------------------------------------------- 3a5"
|
||||
|
||||
STAGE = '''# ---------------------------------------------------------------- 3a14
|
||||
Banner "3a14/9 语音播报降级链(修 Win7 上用不了)"
|
||||
# 用户反馈:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"。
|
||||
#
|
||||
# 原来只有一条路(powershell + System.Speech),而 Win7 上
|
||||
# PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后必须保证:
|
||||
# * cscript/VBScript 排最前(从 Win98 就有,SAPI5 是系统组件,不用装东西)
|
||||
# * 前一条失败要接着试下一条,不是试一条就放弃
|
||||
# * 全失败要**报错**,不能像原来那样 Popen 不等待、明明没出声却说"已播报"
|
||||
# * VBScript 用 ANSI 写(UTF-8 的话 cscript 读出来中文是乱码)
|
||||
$stageOut = & python (Join-Path $root "_tests\\test_tts.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "语音播报降级" ($LASTEXITCODE -eq 0)
|
||||
|
||||
'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = VERIFY.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "test_tts.py" in t:
|
||||
print(" 已经加过了")
|
||||
return 0
|
||||
if ANCHOR not in t:
|
||||
print(" ✗ 找不到锚点")
|
||||
return 1
|
||||
|
||||
t = t.replace(ANCHOR, STAGE + ANCHOR, 1)
|
||||
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了 3a14")
|
||||
|
||||
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||
for token in ('$stageOut = & python (Join-Path $root "_tests\\test_tts.py")',
|
||||
'Record "语音播报降级"'):
|
||||
ok = token in back
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:56]))
|
||||
if not ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,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())
|
||||
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''清掉 actions.py 里重复的 list_voices 和残留的旧 say 前半段。
|
||||
|
||||
== 怎么弄出来的 ==
|
||||
|
||||
fix_tts_fallback.py 里我写了两处替换,但 `NEW` 那个大块**自己也带了一份
|
||||
list_voices**:
|
||||
|
||||
t.replace(OLD_VOICES, NEW_VOICES) # 这是第一份 list_voices
|
||||
t.replace(OLD_SAY, NEW) # NEW 里又有一份 list_voices
|
||||
|
||||
于是 pyflakes 报:
|
||||
|
||||
redefinition of unused 'list_voices' from line 540
|
||||
redefinition of unused 'say' from line 575
|
||||
local variable 'chosen' is assigned to but never used
|
||||
|
||||
第三条是**旧 say 的前半段残留** —— `chosen` / `speed` 算完就不用了,
|
||||
真正干活的在后面那份里。
|
||||
|
||||
== 这一版怎么改 ==
|
||||
|
||||
把**后面**那份 list_voices(`NEW` 带进来的)删掉,保留前面 NEW_VOICES 那份;
|
||||
再把旧的 say 前半段(算 chosen/speed 那几行)删掉 —— 那些在新 say 里
|
||||
已经算过一遍了。
|
||||
|
||||
**先全部定位好再动手**,避免又改一半。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ACTIONS = ROOT / "windows" / "lanremote" / "actions.py"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 清掉重复定义")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ACTIONS.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
lines = t.split("\n")
|
||||
|
||||
# ---- 1. 找所有 list_voices / say 定义 ----
|
||||
defs = {}
|
||||
for i, ln in enumerate(lines):
|
||||
m = re.match(r" def (list_voices|say|_tts_commands|_write_vbs|"
|
||||
r"_powershell_path)\(self", ln)
|
||||
if m:
|
||||
defs.setdefault(m.group(1), []).append(i)
|
||||
|
||||
print(" 定义位置:")
|
||||
for name, spots in sorted(defs.items()):
|
||||
print(" %-18s %s" % (name, [s + 1 for s in spots]))
|
||||
|
||||
# ---- 2. 删掉重复的 list_voices(保留第一个)----
|
||||
dup_lv = defs.get("list_voices", [])
|
||||
if len(dup_lv) != 2:
|
||||
print(" ⚠ list_voices 有 %d 个定义,期望 2 个" % len(dup_lv))
|
||||
else:
|
||||
start = dup_lv[1]
|
||||
# 找到下一个同级 def
|
||||
end = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
if re.match(r" def \w+\(", lines[j]):
|
||||
end = j
|
||||
break
|
||||
# 往上把紧邻的注释也吃掉(那是我写的那段说明)
|
||||
s = start
|
||||
while s > 0 and (lines[s - 1].strip().startswith("#")
|
||||
or lines[s - 1].strip() == ""):
|
||||
s -= 1
|
||||
if lines[s].strip() == "" and s + 1 < start:
|
||||
s += 1
|
||||
break
|
||||
print(" 删除重复的 list_voices:第 %d-%d 行" % (start + 1, end))
|
||||
del lines[start:end]
|
||||
|
||||
# ---- 3. 删掉旧 say 的前半段(算 chosen/speed 那段)----
|
||||
t2 = "\n".join(lines)
|
||||
old_head = ''' chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip()
|
||||
try:
|
||||
speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
speed = 0
|
||||
speed = max(-10, min(10, speed))
|
||||
|
||||
'''
|
||||
n = t2.count(old_head)
|
||||
print(" 旧 say 前半段残留:%d 处" % n)
|
||||
if n:
|
||||
# 只删最后一处之前的那一坨(如果出现两次,说明新 say 里也有一份,
|
||||
# 那份是**要保留的**)—— 所以删第一处。
|
||||
t2 = t2.replace(old_head, "", 1)
|
||||
|
||||
ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t2.encode("utf-8"))
|
||||
print(" ✓ 清完了")
|
||||
|
||||
# ---- 4. 复核 ----
|
||||
back = ACTIONS.read_text(encoding="utf-8-sig")
|
||||
lv = len(re.findall(r"\n def list_voices\(self", back))
|
||||
sy = len(re.findall(r"\n def say\(self", back))
|
||||
tc = len(re.findall(r"\n def _tts_commands\(self", back))
|
||||
print()
|
||||
print(" 复核:list_voices=%d(应 1) say=%d(应 1) _tts_commands=%d(应 1)"
|
||||
% (lv, sy, tc))
|
||||
return 0 if (lv == 1 and sy == 1 and tc == 1) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,250 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''诊断:这台机器上哪几种语音播报方案能用。
|
||||
|
||||
== 为什么写这个 ==
|
||||
|
||||
用户反馈:**Win7 上语音播报不能用**。
|
||||
|
||||
现在的实现只有一条路:
|
||||
powershell -NoProfile -WindowStyle Hidden -Command
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
|
||||
|
||||
这在 Win7 上有几个可能致命的点:
|
||||
|
||||
1. **`powershell` 可能不在 PATH 里**
|
||||
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
|
||||
正常装的机器 PATH 里有,但被精简过、或者从服务/计划任务里拉起时
|
||||
可能没有 —— 而**我们是用 CreateProcess 按名字找的**,找不到就静默失败。
|
||||
|
||||
2. **Win7 默认是 PowerShell 2.0,它用 ANSI 代码页解析 `-Command`**
|
||||
PS 3.0+ 才改成按 UTF-16 处理命令行。所以中文经 `-Command` 传进去
|
||||
在 PS 2.0 上会乱码 —— 表现是"念出来是乱码"或"什么都不念"。
|
||||
|
||||
3. **`System.Speech` 需要 .NET Framework 3.0+**
|
||||
而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有,
|
||||
根本不需要 .NET。绕开 .NET 更稳。
|
||||
|
||||
== 所以这里试 5 条路 ==
|
||||
|
||||
A. powershell(按 PATH 找)+ System.Speech <- 现在的实现
|
||||
B. powershell(按**绝对路径**找)+ System.Speech <- 修"不在 PATH"
|
||||
C. powershell(绝对路径)+ SAPI.SpVoice COM <- 绕开 .NET
|
||||
D. **cscript + VBScript + SAPI.SpVoice** <- 完全不依赖 PowerShell
|
||||
E. wscript + VBScript + SAPI.SpVoice <- 同上,另一个宿主
|
||||
|
||||
D/E 最值得期待:**VBScript 从 Windows 98 就有,SAPI5 是系统组件**,
|
||||
两个都不需要装任何东西,也不受 PowerShell 版本影响。
|
||||
|
||||
== 怎么跑 ==
|
||||
|
||||
python _build\diag_tts.py
|
||||
|
||||
它会一条条试并打印结果。**在有问题的机器上跑最有价值** ——
|
||||
比如那台 Win7。把输出发回来就知道该换哪条路。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
|
||||
# 让它念一句中文。中文最能暴露编码问题 ——
|
||||
# 纯英文在 PS 2.0 上也可能碰巧是对的(ASCII 不受代码页影响)。
|
||||
TEXT = "语音播报测试,一二三"
|
||||
|
||||
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
FOUND: list = []
|
||||
FAILED: list = []
|
||||
|
||||
|
||||
def report(name: str, ok: bool, detail: str = "") -> None:
|
||||
mark = "可用 " if ok else "不可用"
|
||||
print(" [%s] %s%s" % (mark, name, (" —— " + detail) if detail else ""))
|
||||
(FOUND if ok else FAILED).append(name)
|
||||
|
||||
|
||||
def run(cmd: list, timeout: int = 30) -> tuple:
|
||||
"""跑一条命令,返回 (退出码, 输出)。不让它弹窗。"""
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
||||
creationflags=NO_WINDOW,
|
||||
encoding="mbcs", errors="replace")
|
||||
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||
return p.returncode, out
|
||||
except FileNotFoundError:
|
||||
return -1, "文件找不到"
|
||||
except subprocess.TimeoutExpired:
|
||||
return -2, "超时"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return -3, "%s: %s" % (type(exc).__name__, exc)
|
||||
|
||||
|
||||
def find_powershell() -> str:
|
||||
"""先按 PATH 找,再按绝对路径找。"""
|
||||
p = shutil.which("powershell")
|
||||
if p:
|
||||
return p
|
||||
cand = Path(os.environ.get("SystemRoot", r"C:\Windows")) / \
|
||||
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||
return str(cand) if cand.is_file() else ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 语音播报方案诊断")
|
||||
print("=" * 62)
|
||||
print()
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
wv = sys.getwindowsversion()
|
||||
print(" 系统: Windows %d.%d (build %d)"
|
||||
% (wv.major, wv.minor, wv.build))
|
||||
except Exception: # noqa: BLE001
|
||||
print(" 系统: Windows(版本号取不到)")
|
||||
else:
|
||||
print(" 系统: 非 Windows")
|
||||
print(" Python: %s (%d 位)" % (sys.version.split()[0],
|
||||
64 if sys.maxsize > 2 ** 32 else 32))
|
||||
print(" 测试文本: %r" % TEXT)
|
||||
print()
|
||||
|
||||
if not IS_WINDOWS:
|
||||
print(" 非 Windows,没有 TTS 方案可试。")
|
||||
return 0
|
||||
|
||||
# ---------------- 0. 环境事实 ----------------
|
||||
print("── 0. 环境 ──")
|
||||
ps_path = find_powershell()
|
||||
ps_in_path = shutil.which("powershell")
|
||||
print(" powershell 在 PATH 里: %s" % (ps_in_path or "**没有**"))
|
||||
print(" powershell 绝对路径: %s" % (ps_path or "**找不到**"))
|
||||
if ps_path:
|
||||
code, out = run([ps_path, "-NoProfile", "-Command",
|
||||
"$PSVersionTable.PSVersion.Major"])
|
||||
print(" PowerShell 主版本: %s" % (out.strip() or "查不到"))
|
||||
print(" cscript: %s" % (shutil.which("cscript") or "**没有**"))
|
||||
print(" wscript: %s" % (shutil.which("wscript") or "**没有**"))
|
||||
print(" SystemRoot: %s" % os.environ.get("SystemRoot", "?"))
|
||||
print()
|
||||
|
||||
# ---------------- A. 现有实现 ----------------
|
||||
print("── A. powershell(按 PATH)+ System.Speech ← 现在产品用的 ──")
|
||||
if not ps_in_path:
|
||||
report("A", False, "powershell 不在 PATH 里 —— **这就是失败原因**")
|
||||
else:
|
||||
script = ("Add-Type -AssemblyName System.Speech;"
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
||||
"$s.Speak('%s')" % TEXT.replace("'", "''"))
|
||||
code, out = run(["powershell", "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script])
|
||||
report("A", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- B. 绝对路径 + System.Speech ----------------
|
||||
print("── B. powershell(绝对路径)+ System.Speech ──")
|
||||
if not ps_path:
|
||||
report("B", False, "找不到 powershell.exe")
|
||||
else:
|
||||
script = ("Add-Type -AssemblyName System.Speech;"
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
||||
"$s.Speak('%s')" % TEXT.replace("'", "''"))
|
||||
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script])
|
||||
report("B", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- C. SAPI.SpVoice COM(绕开 .NET)----------------
|
||||
print("── C. powershell(绝对路径)+ SAPI.SpVoice COM ──")
|
||||
if not ps_path:
|
||||
report("C", False, "找不到 powershell.exe")
|
||||
else:
|
||||
script = ("$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.Speak('%s')" % TEXT.replace("'", "''"))
|
||||
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script])
|
||||
report("C", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- D. cscript + VBScript + SAPI ----------------
|
||||
#
|
||||
# 最值得期待的一条:VBScript 从 Windows 98 就有,
|
||||
# SAPI5 是系统组件,**两个都不需要装任何东西**,
|
||||
# 也完全不受 PowerShell 版本 / PATH / .NET 的影响。
|
||||
print("── D. cscript + VBScript + SAPI.SpVoice(不依赖 PowerShell)──")
|
||||
vbs = (
|
||||
'Dim v\r\n'
|
||||
'Set v = CreateObject("SAPI.SpVoice")\r\n'
|
||||
'v.Speak "%s"\r\n'
|
||||
) % TEXT.replace('"', '""')
|
||||
# ⚠ VBScript 文件要 ANSI(=系统代码页) —— 写成 UTF-8 的话
|
||||
# 中文在 cscript 里会变乱码。这里用 mbcs 编码写。
|
||||
tmp = Path(tempfile.gettempdir()) / "ykxmz_tts_test.vbs"
|
||||
try:
|
||||
tmp.write_bytes(vbs.encode("mbcs", errors="replace"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report("D", False, "写 vbs 失败: %s" % exc)
|
||||
else:
|
||||
code, out = run(["cscript", "//nologo", str(tmp)])
|
||||
report("D", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- E. wscript(不弹黑框)----------------
|
||||
print("── E. wscript + VBScript + SAPI.SpVoice ──")
|
||||
if tmp.is_file():
|
||||
code, out = run(["wscript", "//nologo", str(tmp)])
|
||||
report("E", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
else:
|
||||
report("E", False, "没有 vbs 文件")
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- 音色列表(两种方式对比)----------------
|
||||
print()
|
||||
print("── 可用音色 ──")
|
||||
if ps_path:
|
||||
code, out = run([
|
||||
ps_path, "-NoProfile", "-Command",
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }"])
|
||||
print(" SAPI5(COM):")
|
||||
for ln in (out or "").splitlines():
|
||||
if ln.strip():
|
||||
print(" " + ln.strip())
|
||||
if not out.strip():
|
||||
print(" (列不出来)")
|
||||
code2, out2 = run([
|
||||
ps_path, "-NoProfile", "-Command",
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"])
|
||||
print(" System.Speech:")
|
||||
for ln in (out2 or "").splitlines():
|
||||
if ln.strip():
|
||||
print(" " + ln.strip())
|
||||
if not out2.strip():
|
||||
print(" (列不出来 —— .NET 那套可能有问题)")
|
||||
|
||||
# ---------------- 结论 ----------------
|
||||
print()
|
||||
print("=" * 62)
|
||||
print(" 可用 %d 条 / 不可用 %d 条" % (len(FOUND), len(FAILED)))
|
||||
if FOUND:
|
||||
print(" 可用方案: %s" % ", ".join(FOUND))
|
||||
print()
|
||||
print(" → 产品应该按**这个顺序**降级尝试:D(cscript) -> C(SAPI COM)")
|
||||
print(" -> B(绝对路径) -> A(PATH),全失败才报错。")
|
||||
else:
|
||||
print(" ⚠ 一条都不通。可能是:没装任何语音、系统被精简过、")
|
||||
print(" 或者 SAPI 组件损坏。")
|
||||
print("=" * 62)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,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())
|
||||
@@ -0,0 +1,384 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把语音播报改成**多条方案降级**,修 Win7 上用不了的问题。
|
||||
|
||||
== 用户反馈 ==
|
||||
|
||||
"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"
|
||||
|
||||
== 原来只有一条路,而且有几个 Win7 特有的坑 ==
|
||||
|
||||
powershell -NoProfile -WindowStyle Hidden -Command
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
|
||||
|
||||
坑一:**`powershell` 只按名字找**
|
||||
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
|
||||
精简版系统、或者从服务/计划任务拉起时 PATH 里可能没有 ——
|
||||
找不到就**静默失败**(Popen 不报错,因为进程起来了,只是在里面失败)。
|
||||
|
||||
坑二:**Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`**
|
||||
PS 3.0 起才按 UTF-16 处理。所以中文经 `-Command` 传进去在 PS 2.0 上
|
||||
很容易乱码 —— 表现是"念出来是乱码"或者干脆不念。
|
||||
|
||||
坑三:**`System.Speech` 需要 .NET Framework 3.0+**
|
||||
而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有,
|
||||
**根本不需要 .NET**。绕开 .NET 更稳。
|
||||
|
||||
== 现在的做法:降级链 ==
|
||||
|
||||
按"依赖越少越优先"排:
|
||||
|
||||
1. **cscript + VBScript + SAPI.SpVoice**
|
||||
VBScript 从 Windows 98 就有,SAPI5 是系统组件 ——
|
||||
两个都不需要装东西,也不受 PowerShell 版本 / PATH / .NET 影响。
|
||||
**这是最可能在 Win7 上通的一条。**
|
||||
|
||||
2. **wscript + VBScript + SAPI.SpVoice**
|
||||
同上,换宿主(cscript 万一被安全软件拦了)。
|
||||
|
||||
3. **powershell(绝对路径)+ SAPI.SpVoice COM**
|
||||
绕开 .NET,但仍要 PowerShell。
|
||||
|
||||
4. **powershell(绝对路径)+ System.Speech**
|
||||
原来的做法,只是改用绝对路径。
|
||||
|
||||
5. **powershell(按 PATH)+ System.Speech**
|
||||
最原始的做法,留作最后兜底。
|
||||
|
||||
**一条通了就记住它**(模块级缓存),后面都直接走那条 ——
|
||||
不然每播一次都要把上面几条全试一遍,慢且难看。
|
||||
|
||||
== 顺手改进 ==
|
||||
|
||||
* `say()` 原来用 `Popen` 不等待 —— **失败了也返回"已播报"**。
|
||||
现在**等它结束并看退出码**,真失败会明确报错,不再骗人。
|
||||
代价是播报变成了同步的(要等念完)—— 但"报成功其实没出声"
|
||||
比"慢一点"糟糕得多。
|
||||
* VBScript 文件用 **mbcs(ANSI)** 编码写。写成 UTF-8 的话
|
||||
cscript 读出来是乱码 —— 这是 VBScript 的老规矩。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ACTIONS = ROOT / "windows" / "lanremote" / "actions.py"
|
||||
|
||||
# ---------------------------------------------------------------- 要替换的旧代码
|
||||
OLD_VOICES = ''' def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
script = (
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
except Exception:
|
||||
return []
|
||||
return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]'''
|
||||
|
||||
OLD_SAY = ''' safe = text.replace("'", "''")
|
||||
parts = [
|
||||
"Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;",
|
||||
]
|
||||
if chosen:
|
||||
parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} "
|
||||
"catch { };")
|
||||
if speed:
|
||||
parts.append(f"$s.Rate = {speed};")
|
||||
parts.append(f"$s.Speak('{safe}')")
|
||||
script = "".join(parts)
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["powershell", "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script],
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
||||
except Exception as exc:
|
||||
raise ActionError(f"TTS 启动失败: {exc}") from exc
|
||||
return f"已播报({chosen or '默认音色'})"'''
|
||||
|
||||
NEW = ''' # ------------------------------------------------------------------
|
||||
# 语音播报(系统自带 TTS)
|
||||
# ------------------------------------------------------------------
|
||||
#
|
||||
# ⚠ 这里有一套**降级链**,不是随便写的。原因见文件末尾的说明,
|
||||
# 简单说:Win7 上的 PowerShell 2.0 + 中文 + PATH 三样都可能出问题,
|
||||
# 而 cscript + SAPI5 是最老最稳的组合(从 XP 就有)。
|
||||
#
|
||||
# 顺序:cscript -> wscript -> ps(绝对路径)+SAPI COM
|
||||
# -> ps(绝对路径)+System.Speech -> ps(PATH)+System.Speech
|
||||
|
||||
def _powershell_path(self) -> str:
|
||||
"""找 powershell.exe。**先按绝对路径**,PATH 只作兜底。
|
||||
|
||||
为什么:Win7 上 PATH 里不一定有,而 CreateProcess 按名字找不到时
|
||||
是**静默失败**的(进程根本没起来,但 Popen 不报错)。
|
||||
"""
|
||||
import shutil as _sh
|
||||
cand = Path(os.environ.get("SystemRoot", r"C:\\Windows")) / \\
|
||||
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||
if os.name == "nt" and cand.is_file():
|
||||
return str(cand)
|
||||
return _sh.which("powershell") or ""
|
||||
|
||||
def _write_vbs(self, text: str, voice: str, rate: int) -> Path:
|
||||
"""生成一个播报用的 .vbs。
|
||||
|
||||
⚠ 必须用 **mbcs(系统 ANSI 代码页)** 写 ——
|
||||
cscript 读 .vbs 是按 ANSI 读的,写成 UTF-8 中文就是乱码。
|
||||
"""
|
||||
import tempfile
|
||||
lines = ['Dim v', 'Set v = CreateObject("SAPI.SpVoice")']
|
||||
if voice:
|
||||
# 按描述匹配音色。找不到就 silently 用默认 ——
|
||||
# 音色选不上不该让整条播报失败。
|
||||
lines.append('Dim i')
|
||||
lines.append('For i = 0 To v.GetVoices().Count - 1')
|
||||
lines.append(' If InStr(v.GetVoices().Item(i).GetDescription(), "%s") > 0 Then'
|
||||
% voice.replace('"', '""'))
|
||||
lines.append(' Set v.Voice = v.GetVoices().Item(i)')
|
||||
lines.append(' End If')
|
||||
lines.append('Next')
|
||||
if rate:
|
||||
# SAPI 的 Rate 范围是 -10..10,跟我们的设置一致
|
||||
lines.append("v.Rate = %d" % max(-10, min(10, rate)))
|
||||
# 长文本 VBScript 单行有长度限制,所以**分句拼接**
|
||||
chunk = text.replace('"', '""').replace("\\r", " ").replace("\\n", " ")
|
||||
while chunk:
|
||||
piece, chunk = chunk[:400], chunk[400:]
|
||||
lines.append('v.Speak "%s"' % piece)
|
||||
path = Path(tempfile.gettempdir()) / "ykxmz_say.vbs"
|
||||
path.write_bytes(("\\r\\n".join(lines) + "\\r\\n").encode("mbcs",
|
||||
errors="replace"))
|
||||
return path
|
||||
|
||||
def _tts_commands(self, text: str, voice: str, rate: int) -> list:
|
||||
"""按"依赖越少越优先"排出所有可行方案,返回 [(名字, 命令行)]。"""
|
||||
cmds = []
|
||||
vbs = None
|
||||
try:
|
||||
vbs = self._write_vbs(text, voice, rate)
|
||||
except Exception: # noqa: BLE001
|
||||
vbs = None
|
||||
if vbs is not None:
|
||||
# 1 / 2:完全不依赖 PowerShell 和 .NET
|
||||
cmds.append(("cscript", ["cscript", "//nologo", str(vbs)]))
|
||||
cmds.append(("wscript", ["wscript", "//nologo", str(vbs)]))
|
||||
|
||||
ps = self._powershell_path()
|
||||
esc = text.replace("'", "''")
|
||||
if ps:
|
||||
# 3:绕开 .NET,只用系统原生 SAPI5
|
||||
sapi = ["$v = New-Object -ComObject SAPI.SpVoice;"]
|
||||
if voice:
|
||||
sapi.append("foreach ($o in $v.GetVoices()) { "
|
||||
"if ($o.GetDescription() -like '*%s*') { "
|
||||
"$v.Voice = $o } };" % voice.replace("'", "''"))
|
||||
if rate:
|
||||
sapi.append("$v.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sapi.append("$v.Speak('%s')" % esc)
|
||||
cmds.append(("ps-sapi", [ps, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sapi)]))
|
||||
|
||||
# 4:原来的做法,但用绝对路径
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-speech", [ps, "-NoProfile", "-WindowStyle",
|
||||
"Hidden", "-Command", "".join(sp)]))
|
||||
|
||||
# 5:最原始的(PATH + System.Speech),兜底
|
||||
import shutil as _sh
|
||||
if _sh.which("powershell"):
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-path", ["powershell", "-NoProfile",
|
||||
"-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sp)]))
|
||||
return cmds
|
||||
|
||||
def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。
|
||||
|
||||
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没有 .NET 3.0 的话
|
||||
System.Speech 那条路是列不出来的,而 SAPI5 一定有。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
out = []
|
||||
ps = self._powershell_path()
|
||||
if ps:
|
||||
script = ("$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
if not out:
|
||||
# 退到 System.Speech 再列一次
|
||||
if ps:
|
||||
script = ("Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis"
|
||||
".SpeechSynthesizer).GetInstalledVoices()"
|
||||
" | ForEach-Object { $_.VoiceInfo.Name }")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
return out
|
||||
|
||||
def say(self, text: str, voice: str = "", rate: int = 0) -> str:
|
||||
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。
|
||||
|
||||
⚠ 会**等播报结束**并看退出码,失败就抛错。
|
||||
原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。
|
||||
代价是同步等待(念多久等多久),但"报成功其实没出声"
|
||||
比"慢一点"糟糕得多。
|
||||
"""
|
||||
if not text.strip():
|
||||
raise ActionError("播报内容为空")
|
||||
if not IS_WINDOWS:
|
||||
raise ActionError("语音播报目前仅支持 Windows")
|
||||
|
||||
chosen = (voice or "").strip() or str(
|
||||
self.store.get("tts_voice") or "").strip()
|
||||
try:
|
||||
speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
speed = 0
|
||||
speed = max(-10, min(10, speed))
|
||||
|
||||
cmds = self._tts_commands(text, chosen, speed)
|
||||
if not cmds:
|
||||
raise ActionError("这台电脑上找不到可用的语音播报方式"
|
||||
"(没有 cscript 也没有 powershell?)")
|
||||
|
||||
# 上次成功的那条优先 —— 不然每播一次都要把几条全试一遍
|
||||
cached = globals().get("_TTS_LAST_OK")
|
||||
if cached:
|
||||
cmds.sort(key=lambda c: 0 if c[0] == cached else 1)
|
||||
|
||||
tried = []
|
||||
for name, cmd in cmds:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, timeout=180,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
except FileNotFoundError:
|
||||
tried.append("%s(没有这个程序)" % name)
|
||||
continue
|
||||
except subprocess.TimeoutExpired:
|
||||
tried.append("%s(超时)" % name)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
tried.append("%s(%s)" % (name, type(exc).__name__))
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
globals()["_TTS_LAST_OK"] = name
|
||||
return "已播报(%s,用 %s)" % (chosen or "默认音色", name)
|
||||
err = ((proc.stderr or "") + (proc.stdout or "")).strip()
|
||||
tried.append("%s(退出码%d %s)" % (name, proc.returncode, err[:40]))
|
||||
|
||||
raise ActionError("语音播报失败,试过:" + ";".join(tried))'''
|
||||
|
||||
NEW_VOICES = ''' def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。
|
||||
|
||||
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话
|
||||
System.Speech 那条路列不出来,而 SAPI5 一定有。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
ps = self._powershell_path()
|
||||
if not ps:
|
||||
return []
|
||||
out = []
|
||||
for script in (
|
||||
# 优先 SAPI5 COM
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }",
|
||||
# 退到 System.Speech
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }",
|
||||
):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
if out:
|
||||
return out
|
||||
return []'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 语音播报:改成多条方案降级")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ACTIONS.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "_tts_commands" in t:
|
||||
print(" 已经改过了")
|
||||
return 0
|
||||
|
||||
missing = [k for k, v in {"list_voices": OLD_VOICES, "say": OLD_SAY}.items()
|
||||
if v not in t]
|
||||
if missing:
|
||||
print(" ✗ 找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点就位")
|
||||
|
||||
t = t.replace(OLD_VOICES, NEW_VOICES, 1)
|
||||
t = t.replace(OLD_SAY, NEW, 1)
|
||||
|
||||
ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ list_voices 改用 SAPI5 COM(+ System.Speech 兜底)")
|
||||
print(" ✓ say 改成降级链 + 等待退出码 + 记住可用的那条")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -17,7 +17,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
VERSION = "2.1.7"
|
||||
VERSION = "2.2.0"
|
||||
NAME = f"ykxmz-{VERSION}"
|
||||
|
||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''快捷栏:按钮做大 + **4 格全部可自定义**。
|
||||
|
||||
== 用户这次的反馈(原话)==
|
||||
|
||||
"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点,
|
||||
然后呢,这个每每个四格算换一下吧,第一个第二个第三个第四个都能换东西"
|
||||
|
||||
== 两个改动 ==
|
||||
|
||||
1. **按钮做大**:30dp -> 46dp,字号 11 -> 14。
|
||||
30dp 在手表上确实太小了 —— 表盘本来就小,手指按上去一半在栏外。
|
||||
46dp 接近 Android 建议的最小可点区域(48dp)。
|
||||
|
||||
⚠ 栏高和面板 topMargin 必须**用同一个常量**。分开写的话
|
||||
改一个忘一个,表现是"内容要么被栏盖住,要么上面空一条"。
|
||||
|
||||
2. **第 4 格也变成可自定义**。
|
||||
|
||||
用户最早说"其中有一个按钮不能改,功能就是一键返回桌面",
|
||||
现在改主意了:"第一个第二个第三个第四个都能换东西"。
|
||||
|
||||
所以「显示桌面」从"固定的那一格"变成**普通可选动作**,
|
||||
而且默认就放在第 4 格 —— 这样默认行为跟以前一样
|
||||
(最后一个还是显示桌面),但用户想换也能换。
|
||||
|
||||
实现上就是把 QUICK_CUSTOM_SLOTS 从 3 改成 4,
|
||||
再把 {"desktop","显示桌面"} 加进 QUICK_ACTIONS,
|
||||
然后去掉所有"第 4 格特殊"的分支。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java"
|
||||
|
||||
PAIRS = [
|
||||
# ---- 1. 格子数 3 -> 4 ----
|
||||
(' private static final int QUICK_CUSTOM_SLOTS = 3; // 固定那个不算在内',
|
||||
''' /** 快捷栏一共几格。用户最初要求"最多 4 个",
|
||||
* 后来又改成"第一二三四格都能换东西",所以 4 格都可自定义。 */
|
||||
private static final int QUICK_SLOTS = 4;
|
||||
/** 快捷栏高度(dp)。46 接近 Android 建议的最小可点区域 48。
|
||||
* ⚠ 面板的 topMargin 也用这个值 —— 分两处写的话改一个忘一个,
|
||||
* 表现是"内容被栏盖住"或"上面空一条"。 */
|
||||
private static final int QUICK_BAR_DP = 46;'''),
|
||||
|
||||
# ---- 2. 加 desktop 到可选动作 ----
|
||||
(''' {"image", "投图到电脑"},
|
||||
};''',
|
||||
''' {"image", "投图到电脑"},
|
||||
// 用户最早说这一格"不能改",后来又改成四格都能换 ——
|
||||
// 所以它现在是**普通可选动作**,只是默认放在第 4 格。
|
||||
{"desktop", "显示桌面"},
|
||||
};'''),
|
||||
|
||||
# ---- 3. 默认值 3 -> 4 格 ----
|
||||
(''' private String[] quickSlots = new String[]{"file", "mirror", "sfx"};''',
|
||||
''' private String[] quickSlots =
|
||||
new String[]{"file", "mirror", "sfx", "desktop"};'''),
|
||||
|
||||
# ---- 4. buildQuickBar:格数、高度、字号 ----
|
||||
(''' for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(11f);''',
|
||||
''' for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(14f); // 11 太小,用户说"按不到"'''),
|
||||
|
||||
(''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30));''',
|
||||
''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP));'''),
|
||||
|
||||
# ---- 5. refreshQuickBar:不再有"固定那一格" ----
|
||||
(''' boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = (i < QUICK_CUSTOM_SLOTS) ? quickSlots[i] : "desktop";''',
|
||||
''' boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = quickSlots[i];'''),
|
||||
|
||||
# ---- 6. onQuickClick:不再有"固定那一格" ----
|
||||
(''' /** 点第 slot 个按钮。0..2 是自定义,3 是那个固定的显示桌面。 */
|
||||
private void onQuickClick(int slot) {
|
||||
String id = (slot < QUICK_CUSTOM_SLOTS) ? quickSlots[slot] : "desktop";
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
runQuickAction(id);
|
||||
}''',
|
||||
''' /** 点第 slot 个按钮。四格完全平等,都从 quickSlots 里取。 */
|
||||
private void onQuickClick(int slot) {
|
||||
if (slot < 0 || slot >= quickSlots.length) {
|
||||
return;
|
||||
}
|
||||
String id = quickSlots[slot];
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
runQuickAction(id);
|
||||
}'''),
|
||||
|
||||
# ---- 7. loadQuickSlots:4 格 + 允许 desktop ----
|
||||
(''' String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx"}
|
||||
: raw.split(",", -1);''',
|
||||
''' String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx", "desktop"}
|
||||
: raw.split(",", -1);'''),
|
||||
|
||||
(''' String[] out = new String[QUICK_CUSTOM_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_CUSTOM_SLOTS) {
|
||||
break;
|
||||
}
|
||||
if (id != null && !id.isEmpty() && !"desktop".equals(id)
|
||||
&& isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx"};
|
||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS; i++) {''',
|
||||
''' String[] out = new String[QUICK_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_SLOTS) {
|
||||
break;
|
||||
}
|
||||
// ⚠ 这里**不再排除 desktop** —— 用户改成四格都能换了
|
||||
if (id != null && !id.isEmpty() && isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx", "desktop"};
|
||||
for (int i = 0; i < QUICK_SLOTS; i++) {'''),
|
||||
|
||||
# ---- 8. applyPanelTopMargin:用同一个常量 ----
|
||||
(''' flp.topMargin = dp(30); // 跟 quickBar 的高度一致''',
|
||||
''' // 用同一个常量 —— 栏高改了这里自动跟着改
|
||||
flp.topMargin = dp(QUICK_BAR_DP);'''),
|
||||
|
||||
# ---- 9. onCreate 里 addView 的高度 ----
|
||||
(''' root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30)));''',
|
||||
''' root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP)));'''),
|
||||
|
||||
# ---- 10. 设置页:4 格 + 去掉"固定改不了"的说法 ----
|
||||
(''' settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。"
|
||||
+ "最后一个「显示桌面」是固定的、改不了;"
|
||||
+ "前三个点下面自己选。(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {''',
|
||||
''' settingsExtraBox.addView(hintText("顶部那条栏,4 个格子"
|
||||
+ "**每一个都能换**,点下面自己选。"
|
||||
+ "(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_SLOTS; slot++) {'''),
|
||||
|
||||
(''' settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx"};''',
|
||||
''' settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效 / 显示桌面)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx", "desktop"};'''),
|
||||
|
||||
# ---- 11. 设置页标题也说明 4 格都能换 ----
|
||||
(''' private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");''',
|
||||
''' private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");'''),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 快捷栏:按钮做大 + 4 格全部可自定义")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "QUICK_SLOTS = 4" in t:
|
||||
print(" 已经改过了")
|
||||
return 0
|
||||
|
||||
# 先全部校验,避免改一半
|
||||
missing = []
|
||||
for i, (old, _new) in enumerate(PAIRS, 1):
|
||||
if old not in t:
|
||||
missing.append((i, old.strip().splitlines()[0][:66]))
|
||||
if missing:
|
||||
print(" ✗ 这些锚点找不到:")
|
||||
for i, s in missing:
|
||||
print(" #%d %s" % (i, s))
|
||||
return 1
|
||||
print(" ✓ %d 个锚点全部就位" % len(PAIRS))
|
||||
|
||||
for i, (old, new) in enumerate(PAIRS, 1):
|
||||
t = t.replace(old, new, 1)
|
||||
|
||||
# 收尾:把残留的 QUICK_CUSTOM_SLOTS 全换掉(应该已经没有了)
|
||||
left = t.count("QUICK_CUSTOM_SLOTS")
|
||||
if left:
|
||||
print(" ⚠ 还有 %d 处 QUICK_CUSTOM_SLOTS 残留,一并替换" % left)
|
||||
t = t.replace("QUICK_CUSTOM_SLOTS", "QUICK_SLOTS")
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 改完了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -1,212 +1,299 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''安装包完整验收:装 → 验 → 卸 → 再验。
|
||||
|
||||
用户要求"所有东西做完记得先测试能不能用",所以每一步都实测。
|
||||
|
||||
⚠ 环境注意:
|
||||
我这个 shell **不是管理员**,而安装器会提升。
|
||||
所以所有"清理"动作都要交给**安装器/卸载器**自己做,
|
||||
不能靠 PowerShell 杀进程或删 Program Files(会拒绝访问)。
|
||||
|
||||
补丁历史(这轮修的四个真 bug):
|
||||
1. --uninstall 里 Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死"
|
||||
2. --uninstall 里 input() 无 stdin 时永久阻塞 -> 控制面板卸载卡死
|
||||
3. _kill_running 用 taskkill /IM ykxmz.exe 把自己也杀了
|
||||
-> 后面的删自启/删数据全没执行
|
||||
4. 安装器关不掉以管理员身份跑着的旧实例 -> "无法关闭应用程序" -> 安装回滚
|
||||
-> 改成 PrepareToInstall 里自己 taskkill /F
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
|
||||
|
||||
def _newest_setup() -> Path:
|
||||
"""自动找 dist 下最新的安装包。
|
||||
|
||||
⚠ 这里原来是写死的 `ykxmz-setup-1.8.2.exe`。后果很隐蔽:
|
||||
|
||||
测试去装一个**根本不存在的旧版本包** -> 安装静默失败 ->
|
||||
读到的还是上一次残留的状态 -> 报出一堆莫名其妙的失败
|
||||
("配对码实际是 xxx"、"版本显示 v1.8.2")。
|
||||
|
||||
版本号每次发版都在变,写死必然过期。
|
||||
`bump_version_generic.py` 更新了别的测试,唯独漏了这个文件。
|
||||
|
||||
改成按版本号排序取最新的 —— 一劳永逸,以后不用再维护。
|
||||
"""
|
||||
cands = []
|
||||
for p in (ROOT / "dist").glob("ykxmz-setup-*.exe"):
|
||||
# 文件名里抠版本号,按数字段排序(不能按字符串,否则 1.9.0 > 2.1.4)
|
||||
m = re.search(r"ykxmz-setup-(\d+)\.(\d+)\.(\d+)\.exe$", p.name)
|
||||
if m:
|
||||
cands.append((tuple(int(x) for x in m.groups()), p))
|
||||
if not cands:
|
||||
return ROOT / "dist" / "ykxmz-setup-不存在.exe"
|
||||
cands.sort(key=lambda t: t[0])
|
||||
return cands[-1][1]
|
||||
|
||||
|
||||
SETUP = _newest_setup()
|
||||
APP = Path(r"C:\Program Files\ykxmz")
|
||||
DATA = Path(r"C:\ProgramData\ykxmz")
|
||||
EXE = APP / "ykxmz.exe"
|
||||
REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||
r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1")
|
||||
# ⚠ PowerShell 的注册表 provider 路径**必须带冒号**(HKCU:\...)。
|
||||
# 写成 HKCU\... 不是合法路径,Get-ItemProperty 静默返回空,
|
||||
# 于是"开机自启"这一项永远误报失败。(这个坑我自己踩了一次。)
|
||||
RUN_KEY = r"HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
CODE = "517284"
|
||||
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
global passed, failed
|
||||
if ok:
|
||||
passed += 1
|
||||
print(" [PASS] " + name)
|
||||
else:
|
||||
failed += 1
|
||||
print(" [FAIL] " + name + " " + str(detail))
|
||||
|
||||
|
||||
def ps(command: str, timeout: int = 300) -> str:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-Command", command],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
encoding="utf-8", errors="replace")
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
|
||||
def run_bat(lines: list, timeout: int = 600) -> tuple:
|
||||
"""把命令写进 .bat 再跑。
|
||||
|
||||
为什么不直接 subprocess.run(["cmd", "/c", "..."]):
|
||||
引号会被 Python 和 cmd 来回吃掉,实测 0.0 秒就返回 exit=1。
|
||||
写成 .bat 文件最稳,而且和学校实际的批量部署方式一致。
|
||||
"""
|
||||
bat = ROOT / "_build" / "_run.bat"
|
||||
bat.write_text("\r\n".join(["@echo off"] + lines), encoding="gbk",
|
||||
errors="replace")
|
||||
start = time.perf_counter()
|
||||
r = subprocess.run(["cmd", "/c", str(bat)], capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
encoding="gbk", errors="replace")
|
||||
return r.returncode, time.perf_counter() - start, (r.stdout or "")
|
||||
|
||||
|
||||
def install() -> tuple:
|
||||
return run_bat([
|
||||
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART '
|
||||
'/LOG="%s" /DIR="C:\\Program Files\\ykxmz" '
|
||||
'/CODE=%s /TASKS=autostart' % (
|
||||
SETUP, ROOT / "_build" / "setup.log", CODE),
|
||||
])
|
||||
|
||||
|
||||
def uninstall() -> tuple:
|
||||
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
||||
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
||||
if not found:
|
||||
return -1, 0.0, ""
|
||||
return run_bat([
|
||||
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' % found.strip(),
|
||||
])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
check("安装包在", SETUP.is_file(), str(SETUP))
|
||||
if not SETUP.is_file():
|
||||
return 1
|
||||
|
||||
# ---------------- 安装 ----------------
|
||||
print()
|
||||
print(" 一、静默安装(配对码 %s)" % CODE)
|
||||
code, elapsed, _out = install()
|
||||
check("安装器退出码 0", code == 0, "exit=%s" % code)
|
||||
check("安装没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||
print(" %.1f 秒" % elapsed)
|
||||
time.sleep(8)
|
||||
|
||||
print()
|
||||
print(" 二、文件与数据")
|
||||
check("ykxmz.exe 在", EXE.is_file())
|
||||
check("_internal 在", (APP / "_internal").is_dir())
|
||||
check("数据目录在", DATA.is_dir())
|
||||
settings = DATA / "data" / "settings.json"
|
||||
check("settings.json 生成了", settings.is_file())
|
||||
if settings.is_file():
|
||||
saved = json.loads(settings.read_text(encoding="utf-8"))
|
||||
got = saved.get("pairing_code")
|
||||
check("配对码正确写入", got == CODE, "实际 %r" % got)
|
||||
|
||||
acl = ps("(Get-Acl '%s').Access | Where-Object { $_.IdentityReference "
|
||||
"-like '*Users*' } | Select-Object -First 1 "
|
||||
"-ExpandProperty FileSystemRights" % (DATA / "data"))
|
||||
check("普通用户对数据目录可写",
|
||||
any(w in acl for w in ("Modify", "FullControl", "Write")),
|
||||
acl or "(读不到)")
|
||||
|
||||
print()
|
||||
print(" 三、--status")
|
||||
status = ps("& '%s' --status" % EXE, timeout=120)
|
||||
check("--status 显示配对码", CODE in status, status[:160])
|
||||
for line in status.splitlines()[:7]:
|
||||
print(" " + line.strip())
|
||||
|
||||
print()
|
||||
print(" 四、进程与端口")
|
||||
count = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Measure-Object).Count")
|
||||
check("被控端在跑", count.strip().splitlines()[-1:] != ["0"], count)
|
||||
listen = ps("(Get-NetTCPConnection -State Listen -ErrorAction "
|
||||
"SilentlyContinue | Where-Object { $_.LocalPort -eq 8765 } | "
|
||||
"Measure-Object).Count")
|
||||
check("8765 在监听", listen.strip().splitlines()[-1:] != ["0"], listen)
|
||||
run_value = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||
check("开机自启已设置", "ykxmz" in run_value.lower(), run_value or "(无)")
|
||||
|
||||
# ---------------- 卸载 ----------------
|
||||
print()
|
||||
print(" 五、静默卸载")
|
||||
code, elapsed, _out = uninstall()
|
||||
check("卸载器执行了", code != -1, "找不到卸载器")
|
||||
check("卸载没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||
print(" %.1f 秒" % elapsed)
|
||||
time.sleep(10)
|
||||
|
||||
print()
|
||||
print(" 六、卸载是否干净")
|
||||
check("安装目录已删",
|
||||
not APP.is_dir(),
|
||||
"还剩: %s" % ", ".join(p.name for p in APP.iterdir())
|
||||
if APP.is_dir() else "")
|
||||
check("数据目录已删", not DATA.is_dir())
|
||||
check("注册表卸载项已清", REG not in ps("if (Test-Path '%s') "
|
||||
"{ 'exists' }" % REG))
|
||||
left = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Measure-Object).Count")
|
||||
check("进程已退出", left.strip().splitlines()[-1:] == ["0"], left)
|
||||
still = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||
check("开机自启已清掉", not still, still or "")
|
||||
|
||||
print()
|
||||
print("=" * 56)
|
||||
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''安装包完整验收:装 → 验 → 卸 → 再验。
|
||||
|
||||
用户要求"所有东西做完记得先测试能不能用",所以每一步都实测。
|
||||
|
||||
⚠ 环境注意:
|
||||
我这个 shell **不是管理员**,而安装器会提升。
|
||||
所以所有"清理"动作都要交给**安装器/卸载器**自己做,
|
||||
不能靠 PowerShell 杀进程或删 Program Files(会拒绝访问)。
|
||||
|
||||
补丁历史(这轮修的四个真 bug):
|
||||
1. --uninstall 里 Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死"
|
||||
2. --uninstall 里 input() 无 stdin 时永久阻塞 -> 控制面板卸载卡死
|
||||
3. _kill_running 用 taskkill /IM ykxmz.exe 把自己也杀了
|
||||
-> 后面的删自启/删数据全没执行
|
||||
4. 安装器关不掉以管理员身份跑着的旧实例 -> "无法关闭应用程序" -> 安装回滚
|
||||
-> 改成 PrepareToInstall 里自己 taskkill /F
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
|
||||
|
||||
def _newest_setup() -> Path:
|
||||
"""自动找 dist 下最新的安装包。
|
||||
|
||||
⚠ 这里原来是写死的 `ykxmz-setup-1.8.2.exe`。后果很隐蔽:
|
||||
|
||||
测试去装一个**根本不存在的旧版本包** -> 安装静默失败 ->
|
||||
读到的还是上一次残留的状态 -> 报出一堆莫名其妙的失败
|
||||
("配对码实际是 xxx"、"版本显示 v1.8.2")。
|
||||
|
||||
版本号每次发版都在变,写死必然过期。
|
||||
`bump_version_generic.py` 更新了别的测试,唯独漏了这个文件。
|
||||
|
||||
改成按版本号排序取最新的 —— 一劳永逸,以后不用再维护。
|
||||
"""
|
||||
cands = []
|
||||
for p in (ROOT / "dist").glob("ykxmz-setup-*.exe"):
|
||||
# 文件名里抠版本号,按数字段排序(不能按字符串,否则 1.9.0 > 2.1.4)
|
||||
m = re.search(r"ykxmz-setup-(\d+)\.(\d+)\.(\d+)\.exe$", p.name)
|
||||
if m:
|
||||
cands.append((tuple(int(x) for x in m.groups()), p))
|
||||
if not cands:
|
||||
return ROOT / "dist" / "ykxmz-setup-不存在.exe"
|
||||
cands.sort(key=lambda t: t[0])
|
||||
return cands[-1][1]
|
||||
|
||||
|
||||
SETUP = _newest_setup()
|
||||
APP = Path(r"C:\Program Files\ykxmz")
|
||||
DATA = Path(r"C:\ProgramData\ykxmz")
|
||||
EXE = APP / "ykxmz.exe"
|
||||
REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||
r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1")
|
||||
# ⚠ PowerShell 的注册表 provider 路径**必须带冒号**(HKCU:\...)。
|
||||
# 写成 HKCU\... 不是合法路径,Get-ItemProperty 静默返回空,
|
||||
# 于是"开机自启"这一项永远误报失败。(这个坑我自己踩了一次。)
|
||||
RUN_KEY = r"HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
CODE = "517284"
|
||||
|
||||
passed = failed = 0
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
global passed, failed
|
||||
if ok:
|
||||
passed += 1
|
||||
print(" [PASS] " + name)
|
||||
else:
|
||||
failed += 1
|
||||
print(" [FAIL] " + name + " " + str(detail))
|
||||
|
||||
|
||||
def ps(command: str, timeout: int = 300) -> str:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-Command", command],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
encoding="utf-8", errors="replace")
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
|
||||
def run_bat(lines: list, timeout: int = 600) -> tuple:
|
||||
"""把命令写进 .bat 再跑。
|
||||
|
||||
为什么不直接 subprocess.run(["cmd", "/c", "..."]):
|
||||
引号会被 Python 和 cmd 来回吃掉,实测 0.0 秒就返回 exit=1。
|
||||
写成 .bat 文件最稳,而且和学校实际的批量部署方式一致。
|
||||
"""
|
||||
bat = ROOT / "_build" / "_run.bat"
|
||||
bat.write_text("\r\n".join(["@echo off"] + lines), encoding="gbk",
|
||||
errors="replace")
|
||||
start = time.perf_counter()
|
||||
r = subprocess.run(["cmd", "/c", str(bat)], capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
encoding="gbk", errors="replace")
|
||||
return r.returncode, time.perf_counter() - start, (r.stdout or "")
|
||||
|
||||
|
||||
def install() -> tuple:
|
||||
return run_bat([
|
||||
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART '
|
||||
'/LOG="%s" /DIR="C:\\Program Files\\ykxmz" '
|
||||
'/CODE=%s /TASKS=autostart' % (
|
||||
SETUP, ROOT / "_build" / "setup.log", CODE),
|
||||
])
|
||||
|
||||
|
||||
def _clean_previous() -> None:
|
||||
"""把上一次安装/测试的残留清掉,让这一轮从零开始。
|
||||
|
||||
⚠ **这个函数第一版是坏的**,记下来免得再犯:
|
||||
|
||||
第一版对卸载器和 rmdir 都用了
|
||||
subprocess.run(["cmd", "/c", '"%s" ...' % path])
|
||||
这种**嵌套引号**的写法。而 `run_bat()` 上面那大段注释
|
||||
就是在说这个坑:"引号会被 Python 和 cmd 来回吃掉,
|
||||
实测 0.0 秒就返回 exit=1"。
|
||||
|
||||
于是"装前清理"实际上什么都没清 —— 目录还在、旧的 settings.json
|
||||
还在(配对码是上一轮遗留的值),测试报:
|
||||
|
||||
[FAIL] 安装目录已清
|
||||
[FAIL] 数据目录已清
|
||||
[FAIL] 配对码正确写入 实际 '(上一轮的旧值)'
|
||||
|
||||
看上去像"安装包有 bug",其实是**清理函数自己没跑通**。
|
||||
|
||||
现在:
|
||||
* 卸载器走 `run_bat()`(已经验证过的方式)
|
||||
* 删目录用 `shutil.rmtree` —— **完全不过 cmd**,没有引号问题,
|
||||
而且失败时能拿到真实原因(权限 / 占用),不会静默吞掉
|
||||
"""
|
||||
# 1) 走正规卸载器(顺带清注册表和开机自启)
|
||||
try:
|
||||
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
||||
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
||||
if found and found.strip():
|
||||
run_bat(['"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART'
|
||||
% found.strip()], timeout=240)
|
||||
time.sleep(4)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(" (卸载器这步跳过: %s)" % type(exc).__name__)
|
||||
|
||||
# 2) 还有进程占着文件就先杀掉
|
||||
try:
|
||||
ps("Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Stop-Process -Force -ErrorAction SilentlyContinue")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
# 3) 直接删目录。**不过 cmd** —— shutil.rmtree 能报出为什么删不掉。
|
||||
for target, label in ((APP, "安装目录"), (DATA, "数据目录")):
|
||||
if not target.exists():
|
||||
continue
|
||||
problems = []
|
||||
|
||||
def _onerr(func, path, exc_info): # noqa: ANN001
|
||||
problems.append("%s: %s" % (path, exc_info[1]))
|
||||
|
||||
shutil.rmtree(str(target), onerror=_onerr)
|
||||
if target.exists():
|
||||
print(" ⚠ %s 没清干净:%s" % (label, problems[:2]))
|
||||
else:
|
||||
print(" 清了 %s" % label)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def uninstall() -> tuple:
|
||||
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
||||
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
||||
if not found:
|
||||
return -1, 0.0, ""
|
||||
return run_bat([
|
||||
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' % found.strip(),
|
||||
])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
check("安装包在", SETUP.is_file(), str(SETUP))
|
||||
if not SETUP.is_file():
|
||||
return 1
|
||||
|
||||
# ---------------- 装前先清干净 ----------------
|
||||
#
|
||||
# ⚠ 这一步是必须的,不是保险起见。
|
||||
#
|
||||
# 踩过的坑:测试报 `配对码正确写入 实际 '(旧的残留值)'`,
|
||||
# 而 `(旧的残留值)` 是**上一次测试遗留**的配对码 ——
|
||||
# 数据目录 `C:\ProgramData\ykxmz\data` 在卸载时**不会**被删掉
|
||||
# (见 .iss 的说明:那里存着用户的中转地址、配对码等,卸载也不该删),
|
||||
# 所以机器上会留着上一轮的 settings.json。
|
||||
#
|
||||
# 这次装的是 `/CODE=517284`,正常会覆盖 —— 但如果在**上一次
|
||||
# 安装的进程还占着端口/文件**的时候装,`--code` 那一步可能失败,
|
||||
# 于是检查就读到了旧值。表现成"安装包有 bug",其实是测试
|
||||
# **没把环境擦干净**。
|
||||
#
|
||||
# 所以:装之前先把两个目录删掉,让测试从零开始。
|
||||
print()
|
||||
print(" 零、清理上一次的残留")
|
||||
_clean_previous()
|
||||
check("安装目录已清", not APP.is_dir(), str(APP))
|
||||
check("数据目录已清", not DATA.is_dir(), str(DATA))
|
||||
check("没有残留进程",
|
||||
not ps("(Get-Process ykxmz -ErrorAction SilentlyContinue "
|
||||
"| Measure-Object).Count").strip().strip("0").strip())
|
||||
|
||||
# ---------------- 安装 ----------------
|
||||
print()
|
||||
print(" 一、静默安装(配对码 %s)" % CODE)
|
||||
code, elapsed, _out = install()
|
||||
check("安装器退出码 0", code == 0, "exit=%s" % code)
|
||||
check("安装没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||
print(" %.1f 秒" % elapsed)
|
||||
time.sleep(8)
|
||||
|
||||
print()
|
||||
print(" 二、文件与数据")
|
||||
check("ykxmz.exe 在", EXE.is_file())
|
||||
check("_internal 在", (APP / "_internal").is_dir())
|
||||
check("数据目录在", DATA.is_dir())
|
||||
settings = DATA / "data" / "settings.json"
|
||||
check("settings.json 生成了", settings.is_file())
|
||||
if settings.is_file():
|
||||
saved = json.loads(settings.read_text(encoding="utf-8"))
|
||||
got = saved.get("pairing_code")
|
||||
check("配对码正确写入", got == CODE, "实际 %r" % got)
|
||||
|
||||
acl = ps("(Get-Acl '%s').Access | Where-Object { $_.IdentityReference "
|
||||
"-like '*Users*' } | Select-Object -First 1 "
|
||||
"-ExpandProperty FileSystemRights" % (DATA / "data"))
|
||||
check("普通用户对数据目录可写",
|
||||
any(w in acl for w in ("Modify", "FullControl", "Write")),
|
||||
acl or "(读不到)")
|
||||
|
||||
print()
|
||||
print(" 三、--status")
|
||||
status = ps("& '%s' --status" % EXE, timeout=120)
|
||||
check("--status 显示配对码", CODE in status, status[:160])
|
||||
for line in status.splitlines()[:7]:
|
||||
print(" " + line.strip())
|
||||
|
||||
print()
|
||||
print(" 四、进程与端口")
|
||||
count = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Measure-Object).Count")
|
||||
check("被控端在跑", count.strip().splitlines()[-1:] != ["0"], count)
|
||||
listen = ps("(Get-NetTCPConnection -State Listen -ErrorAction "
|
||||
"SilentlyContinue | Where-Object { $_.LocalPort -eq 8765 } | "
|
||||
"Measure-Object).Count")
|
||||
check("8765 在监听", listen.strip().splitlines()[-1:] != ["0"], listen)
|
||||
run_value = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||
check("开机自启已设置", "ykxmz" in run_value.lower(), run_value or "(无)")
|
||||
|
||||
# ---------------- 卸载 ----------------
|
||||
print()
|
||||
print(" 五、静默卸载")
|
||||
code, elapsed, _out = uninstall()
|
||||
check("卸载器执行了", code != -1, "找不到卸载器")
|
||||
check("卸载没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||
print(" %.1f 秒" % elapsed)
|
||||
time.sleep(10)
|
||||
|
||||
print()
|
||||
print(" 六、卸载是否干净")
|
||||
check("安装目录已删",
|
||||
not APP.is_dir(),
|
||||
"还剩: %s" % ", ".join(p.name for p in APP.iterdir())
|
||||
if APP.is_dir() else "")
|
||||
check("数据目录已删", not DATA.is_dir())
|
||||
check("注册表卸载项已清", REG not in ps("if (Test-Path '%s') "
|
||||
"{ 'exists' }" % REG))
|
||||
left = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Measure-Object).Count")
|
||||
check("进程已退出", left.strip().splitlines()[-1:] == ["0"], left)
|
||||
still = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||
check("开机自启已清掉", not still, still or "")
|
||||
|
||||
print()
|
||||
print("=" * 56)
|
||||
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -23,7 +23,7 @@ def _latest_apk() -> Path:
|
||||
(ROOT / "dist").glob("*watch*.apk"),
|
||||
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return candidates[0] if candidates else \
|
||||
ROOT / "dist" / "ykxmz-watch-2.1.7.apk"
|
||||
ROOT / "dist" / "ykxmz-watch-2.2.0.apk"
|
||||
|
||||
|
||||
APK = _latest_apk()
|
||||
|
||||
@@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
# 被控端 -> 一个 zip
|
||||
# 服务器端 -> 一个 zip
|
||||
# 控制端 -> 直接给 APK(不打 zip)
|
||||
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.7.zip"
|
||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.7.zip"
|
||||
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.7.apk"
|
||||
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.2.0.zip"
|
||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.2.0.zip"
|
||||
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.2.0.apk"
|
||||
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
||||
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
||||
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
||||
@@ -89,7 +89,7 @@ def main() -> int:
|
||||
for label, path in (("被控端", agent_dir / "README-agent.txt"),
|
||||
("服务器端", relay_dir / "README-server.txt"),
|
||||
("控制端(旁边那份)",
|
||||
ROOT / "dist" / "ykxmz-watch-2.1.7-说明.txt")):
|
||||
ROOT / "dist" / "ykxmz-watch-2.2.0-说明.txt")):
|
||||
raw = path.read_bytes() if path.is_file() else b""
|
||||
check(f"{label}说明有 UTF-8 BOM",
|
||||
raw[:3] == b"\xef\xbb\xbf", raw[:3])
|
||||
|
||||
+138
-69
@@ -3,31 +3,38 @@ r'''验「手表快捷栏」和「允许新设备连接」。
|
||||
|
||||
== 验什么 ==
|
||||
|
||||
一、手表端源码
|
||||
* 快捷栏建出来了,4 个格子(3 自定义 + 1 固定显示桌面)
|
||||
* 固定那个**不给改**(用户明确要求"其中有一个按钮不能改")
|
||||
* 挂进了 onCreate(光有方法不 addView 是白搭 —— 项目里踩过)
|
||||
* 加进了 showOnly 的数组(漏了的话它会永远压在最上面)
|
||||
* 7 个动作都在
|
||||
* 设置页里有入口(没入口用户改不了)
|
||||
一、快捷栏的形状
|
||||
* 一共 4 格,**每格都能自定义**
|
||||
* 「显示桌面」是普通可选动作(默认放第 4 格,但能换掉)
|
||||
* 8 个动作都在
|
||||
* 挂进了 onCreate、加进了 showOnly 的数组
|
||||
* 设置页里有入口
|
||||
* 鼠标触摸板在
|
||||
|
||||
二、电脑端 allow_new_pairing 这个 feature
|
||||
* 能查、能关、能开
|
||||
* 存储里真的变了
|
||||
* 只影响新配对,不踢现有会话(注释里写了理由)
|
||||
二、按钮大小
|
||||
用户反馈原话:"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点"
|
||||
|
||||
== 为什么专门验"固定那个不给改" ==
|
||||
* 栏高做成常量(>= 44dp),而且**栏高 / 面板 margin / addView
|
||||
三处用同一个常量** —— 分三处写的话改一个忘一个,
|
||||
表现是"内容被盖住"或"上面空一条"
|
||||
|
||||
用户原话:"最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。
|
||||
三、电脑端 allow_new_pairing 这个 feature
|
||||
|
||||
如果哪天有人"顺手"把它也做成可配置的,用户点了半天发现
|
||||
"显示桌面"没了 —— 而这种回归不会报错,只会让人困惑。
|
||||
所以这里断言:第 4 格永远是 desktop,且 QUICK_ACTIONS 里**没有**它。
|
||||
== 这条断言改过一次,值得记下来 ==
|
||||
|
||||
用户最早说:"最多可以加 4 个,其中有一个按钮不能改,
|
||||
功能就是一键返回桌面"。
|
||||
第一版就按"第 4 格写死 desktop"实现,也按那个断言测。
|
||||
|
||||
后来用户看到实物改了主意:"第一个第二个第三个第四个都能换东西"。
|
||||
|
||||
所以现在验的是**相反**的事。教训:
|
||||
**别把"用户当时的一句话"当成永久约束焊进结构里**;
|
||||
真是约束的话,注释里要写清是什么时候的要求,
|
||||
下次他改主意时才知道该动哪儿。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -67,69 +74,103 @@ def main() -> int:
|
||||
]:
|
||||
check(label, token in main_java)
|
||||
|
||||
# 4 个格子
|
||||
check("格子数 = 3 自定义 + 1 固定",
|
||||
"QUICK_CUSTOM_SLOTS + 1" in main_java
|
||||
and "QUICK_CUSTOM_SLOTS = 3" in main_java)
|
||||
|
||||
# 固定那个
|
||||
m = re.search(r'String id = \(slot < QUICK_CUSTOM_SLOTS\) \? quickSlots\[slot\]'
|
||||
r' : "desktop"', main_java)
|
||||
check("★ 第 4 格固定是 desktop(不给改)", m is not None,
|
||||
"第 4 格不是写死的 desktop —— 用户要求它不能改")
|
||||
check("★ QUICK_ACTIONS 里没有 desktop(否则它会出现在可选列表里)",
|
||||
not re.search(r'\{"desktop"', main_java),
|
||||
"desktop 出现在可选动作里了,用户就能把它改掉")
|
||||
# 4 格,全部可自定义。这条断言改过一次,见文件头。
|
||||
check("★ 一共 4 格", "QUICK_SLOTS = 4" in main_java, "用户要求 4 格")
|
||||
check("★ 旧的 3+1 分法已经不在了",
|
||||
"QUICK_CUSTOM_SLOTS" not in main_java, "还有残留说明没改干净")
|
||||
check("★ 没有把 desktop 写死在某格",
|
||||
': "desktop";' not in main_java,
|
||||
"还有地方把 desktop 写死成某一格")
|
||||
check("★ desktop 在可选动作里(能换到任意一格)",
|
||||
bool(re.search(r'\{"desktop",\s*"显示桌面"\}', main_java)),
|
||||
"不在 QUICK_ACTIONS 里的话用户没法把它放到别的格")
|
||||
check("四格平等:都从 quickSlots 取",
|
||||
"String id = quickSlots[i];" in main_java
|
||||
and "String id = quickSlots[slot];" in main_java,
|
||||
"还有特殊分支")
|
||||
check("默认值就是那 4 个",
|
||||
'new String[]{"file", "mirror", "sfx", "desktop"}' in main_java)
|
||||
check("desktop 走的是 show_desktop 命令",
|
||||
'"cmd", "show_desktop"' in main_java,
|
||||
"固定按钮应该让电脑 Win+D")
|
||||
'"cmd", "show_desktop"' in main_java)
|
||||
|
||||
# 7 个动作
|
||||
print()
|
||||
print(" 7 个可选动作:")
|
||||
print(" 8 个可选动作:")
|
||||
for aid, label in [("file", "传文件"), ("mirror", "屏幕镜像"),
|
||||
("mouse", "鼠标控制"), ("sfx", "音效"),
|
||||
("power", "关机"), ("tts", "语音播报"),
|
||||
("image", "投图到电脑")]:
|
||||
ok = ('{"%s", "%s"}' % (aid, label)) in main_java
|
||||
check(" %s(%s)" % (aid, label), ok)
|
||||
("image", "投图到电脑"), ("desktop", "显示桌面")]:
|
||||
check(" %s(%s)" % (aid, label),
|
||||
('{"%s", "%s"}' % (aid, label)) in main_java)
|
||||
|
||||
# 挂接
|
||||
# ---------------- 二、按钮大小 ----------------
|
||||
print()
|
||||
print("二、挂接(漏了就白写)")
|
||||
oncreate = main_java[main_java.find("protected void onCreate"):
|
||||
main_java.find("protected void onCreate") + 6000]
|
||||
check("★ onCreate 里调了 buildQuickBar()",
|
||||
"buildQuickBar();" in oncreate,
|
||||
"没挂上去的话栏根本不显示")
|
||||
check("★ onCreate 里调了 loadQuickSlots()",
|
||||
"loadQuickSlots();" in oncreate)
|
||||
check("★ 快捷栏**最后** addView(保证在最上层)",
|
||||
oncreate.find("root.addView(quickBar") > oncreate.find("root.addView(mousePanel"),
|
||||
print("二、按钮大小(用户反馈「太小了、按不到」)")
|
||||
m = re.search(r"QUICK_BAR_DP = (\d+)", main_java)
|
||||
check("★ 栏高做成常量 QUICK_BAR_DP", m is not None,
|
||||
"没有常量的话栏高和面板 margin 会各写各的")
|
||||
h = int(m.group(1)) if m else 0
|
||||
check("★ 栏高 >= 44dp(接近 Android 建议的可点区域 48)", h >= 44,
|
||||
"现在是 %d" % h)
|
||||
check("★ 面板 topMargin 用同一个常量",
|
||||
"flp.topMargin = dp(QUICK_BAR_DP);" in main_java,
|
||||
"分两处写的话改一个忘一个")
|
||||
check("★ addView 也用同一个常量",
|
||||
"MATCH_PARENT, dp(QUICK_BAR_DP)));" in main_java)
|
||||
check("★ 按钮字号 >= 13(原来是 11)",
|
||||
"b.setTextSize(14f);" in main_java)
|
||||
# ⚠ 只查**代码**,不查注释。
|
||||
# 第一版这里是 `"dp(30)" not in main_java`,结果被
|
||||
# applyPanelTopMargin 的 javadoc 里那句旧说明误报了 ——
|
||||
# 注释里提一下旧值是很正常的事("以前是 30dp"),
|
||||
# 不该因为它变红。
|
||||
code_lines = [ln for ln in main_java.splitlines()
|
||||
if not ln.strip().startswith(("*", "//", "/*"))]
|
||||
hard = [ln.strip() for ln in code_lines if "dp(30)" in ln]
|
||||
check("没有残留的 dp(30)(旧栏高)", not hard,
|
||||
"还有代码用旧的 30dp:%s" % hard)
|
||||
|
||||
# ---------------- 三、挂接 ----------------
|
||||
print()
|
||||
print("三、挂接(漏了就白写)")
|
||||
i0 = main_java.find("protected void onCreate")
|
||||
oncreate = main_java[i0:i0 + 7000]
|
||||
check("★ onCreate 里调了 buildQuickBar()", "buildQuickBar();" in oncreate,
|
||||
"没挂上去栏根本不显示")
|
||||
check("★ onCreate 里调了 loadQuickSlots()", "loadQuickSlots();" in oncreate)
|
||||
check("★ 快捷栏最后 addView(保证在最上层)",
|
||||
oncreate.find("root.addView(quickBar")
|
||||
> oncreate.find("root.addView(mousePanel"),
|
||||
"加在面板之前会被面板整个盖住")
|
||||
check("★ showOnly 的数组里有 mousePanel",
|
||||
"settingsPanel, mousePanel};" in main_java,
|
||||
"漏了的话鼠标面板永远不隐藏")
|
||||
# ⚠ 断言写成"数组里有这几个面板"而不是"精确等于某个字符串" ——
|
||||
# 第一版写的是 `"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(面板给栏让位)",
|
||||
"private void applyPanelTopMargin" in main_java)
|
||||
|
||||
# 设置页入口
|
||||
# ---------------- 四、设置页 ----------------
|
||||
print()
|
||||
print("三、设置页入口(没入口用户改不了)")
|
||||
check("有 fillSettingsExtra", "private void fillSettingsExtra()" in main_java)
|
||||
print("四、设置页入口(没入口用户改不了)")
|
||||
check("有 fillSettingsExtra",
|
||||
"private void fillSettingsExtra()" in main_java)
|
||||
check("有 pickQuickSlot(选动作)",
|
||||
"private void pickQuickSlot(final int slot)" in main_java)
|
||||
check("设置页里加了「快捷栏」小节",
|
||||
'sectionTitle("快捷栏")' in main_java)
|
||||
check("设置页里加了「允许新设备连接」小节",
|
||||
'sectionTitle("允许新设备连接")' in main_java)
|
||||
check("界面上说明了第 4 格改不了",
|
||||
"固定的、改不了" in main_java,
|
||||
"不说明的话用户会一直找怎么改第四个")
|
||||
check("界面上说明了四格都能换", "每一个都能换" in main_java,
|
||||
"不说明的话用户不知道第四格也能改")
|
||||
|
||||
# 鼠标触摸板
|
||||
# ---------------- 五、鼠标触摸板 ----------------
|
||||
print()
|
||||
print("四、鼠标触摸板")
|
||||
print("五、鼠标触摸板")
|
||||
check("有 buildMousePanel", "private void buildMousePanel()" in main_java)
|
||||
check("用 GestureDetector(单击/双击/拖动)",
|
||||
"GestureDetector" in main_java
|
||||
@@ -141,31 +182,60 @@ def main() -> int:
|
||||
check("滚轮上/下", 'sendMouse("scroll", "", 3)' in main_java
|
||||
and 'sendMouse("scroll", "", -3)' in main_java)
|
||||
check("拖动 -> moveby", '"moveby"' in main_java)
|
||||
check("isConnected() 存在(我第一版漏了,编译才发现)",
|
||||
check("isConnected() 存在(第一版漏了,编译才发现)",
|
||||
"private boolean isConnected()" in main_java)
|
||||
|
||||
# ---------------- 五、电脑端 allow_new_pairing ----------------
|
||||
# ---------------- 五之二、连接后的选择页 ----------------
|
||||
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.features import Features
|
||||
|
||||
class FakeActions:
|
||||
"""Features 要的是 actions 对象,不是 store(第一版就是这里传错了)。"""
|
||||
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
self.ui = None
|
||||
|
||||
d = Path(tempfile.mkdtemp())
|
||||
st = Store(d)
|
||||
st = Store(Path(tempfile.mkdtemp()))
|
||||
feats = Features(FakeActions(st))
|
||||
|
||||
check("能查到当前状态",
|
||||
"允许" in feats.run("allow_new_pairing", {}))
|
||||
check("能查到当前状态", "允许" in feats.run("allow_new_pairing", {}))
|
||||
out = feats.run("allow_new_pairing", {"allow": False})
|
||||
check("能关掉", "禁止" in out, "拿到 %r" % out)
|
||||
check(" ★ 存储里真的变了",
|
||||
st.get("allow_new_pairing") is False,
|
||||
check(" ★ 存储里真的变了", st.get("allow_new_pairing") is False,
|
||||
"拿到 %r" % st.get("allow_new_pairing"))
|
||||
check(" 关掉之后再查也是关",
|
||||
"不允许" in feats.run("allow_new_pairing", {}))
|
||||
@@ -173,7 +243,6 @@ def main() -> int:
|
||||
check("能打开", "已允许" in out, "拿到 %r" % out)
|
||||
check(" 存储里也变了", st.get("allow_new_pairing") is True)
|
||||
|
||||
# 不存在名字要报错(别静默成功)
|
||||
try:
|
||||
feats.run("allow_new_pairing_typo", {})
|
||||
check("不认识的名字会报错", False, "它竟然没报错")
|
||||
|
||||
@@ -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
@@ -24,69 +24,23 @@ $androidJar = Join-Path $tc "android-sdk\platforms\android-34\android.jar"
|
||||
|
||||
$script:results = @()
|
||||
|
||||
# 当前会话是不是锁屏 / 没有交互桌面?
|
||||
# 锁屏时截屏类测试**必然**失败(拿不到屏幕内容),但那不是代码问题。
|
||||
# 不识别的话汇总表一片红,真问题反而被淹没。
|
||||
# ⚠ 这里**原来有一段锁屏检测**,锁屏时把 needsDesktop 的阶段记成
|
||||
# `SKIP(锁屏)` 而不是 FAIL。**已经按用户要求删掉了。**
|
||||
#
|
||||
# ⚠ 判断不能只看 OpenInputDesktop 成不成功 —— **锁屏时它同样成功**
|
||||
# (那时输入桌面是 Winlogon)。实测漏判过一次:屏幕明明锁着,
|
||||
# desktopAvailable 却是 true,于是 6/7、7/7 两个截屏阶段记成 FAIL,
|
||||
# 把真正的结论淹掉了。
|
||||
# 正确做法是拿输入桌面的**名字**:Default = 正常,Winlogon = 锁屏。
|
||||
$script:desktopAvailable = $true
|
||||
$script:desktopName = "(未检测)"
|
||||
try {
|
||||
Add-Type -Namespace Ykxmz -Name Desk -MemberDefinition @'
|
||||
[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
|
||||
}
|
||||
# 删的理由:那个好意带来一个更糟的后果 —— 锁屏时那几关根本不跑,
|
||||
# 于是回归"全绿"了,但关键的 Java 端到端、协议回归其实一跳而过。
|
||||
# 用户看到"全部通过"会以为没问题。
|
||||
#
|
||||
# **"因为环境不满足所以不测"比"测了发现失败"更危险**:
|
||||
# 失败至少是信息,跳过是假的安全感。
|
||||
#
|
||||
# 现在一律真跑、真记。环境不合适导致失败的话,就去修环境或改测试,
|
||||
# 而不是让它变成 SKIP。
|
||||
|
||||
function Record($name, $ok, $needsDesktop = $false) {
|
||||
# needsDesktop 的阶段在锁屏时记 SKIP —— 避免误报把真问题淹没
|
||||
if ($needsDesktop -and -not $script:desktopAvailable) {
|
||||
$script:results += [pscustomobject]@{
|
||||
项目 = $name; 结果 = "SKIP(锁屏)" }
|
||||
return
|
||||
}
|
||||
# ⚠ $needsDesktop 参数**保留但不再使用** ——
|
||||
# 6 个调用点还在传它,留着就不用改那些地方。
|
||||
# 以后真要重新引入"特定环境下跳过",签名已经在了。
|
||||
$script:results += [pscustomobject]@{
|
||||
项目 = $name; 结果 = $(if ($ok) { "PASS" } else { "FAIL" }) }
|
||||
}
|
||||
@@ -554,6 +508,20 @@ $stageOut = & python (Join-Path $root "_tests\test_quickbar.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "手表快捷栏" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a14
|
||||
Banner "3a14/9 语音播报降级链(修 Win7 上用不了)"
|
||||
# 用户反馈:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"。
|
||||
#
|
||||
# 原来只有一条路(powershell + System.Speech),而 Win7 上
|
||||
# PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后必须保证:
|
||||
# * cscript/VBScript 排最前(从 Win98 就有,SAPI5 是系统组件,不用装东西)
|
||||
# * 前一条失败要接着试下一条,不是试一条就放弃
|
||||
# * 全失败要**报错**,不能像原来那样 Popen 不等待、明明没出声却说"已播报"
|
||||
# * VBScript 用 ANSI 写(UTF-8 的话 cscript 读出来中文是乱码)
|
||||
$stageOut = & python (Join-Path $root "_tests\test_tts.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "语音播报降级" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a5
|
||||
Banner "3a5/9 局域网/中转对比基准(防测试自己骗人)"
|
||||
# 这个基准一度是红的,而且报告里写着「这是中转链路的产品 bug」——
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.lanremote.watch"
|
||||
android:versionCode="5"
|
||||
android:versionName="2.1.7">
|
||||
android:versionName="2.2.0">
|
||||
|
||||
<!-- ⚠ 允许明文 HTTP。
|
||||
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
|
||||
|
||||
@@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat"
|
||||
|
||||
$work = Join-Path $android "build"
|
||||
$outDir = Join-Path $root "dist"
|
||||
$apkName = "ykxmz-watch-2.1.7.apk"
|
||||
$apkName = "ykxmz-watch-2.2.0.apk"
|
||||
|
||||
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
|
||||
|
||||
@@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk"
|
||||
--min-sdk-version 24 `
|
||||
--target-sdk-version 34 `
|
||||
--version-code 5 `
|
||||
--version-name "2.1.7" `
|
||||
--version-name "2.2.0" `
|
||||
--auto-add-overlay
|
||||
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
|
||||
|
||||
|
||||
@@ -315,7 +315,8 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
private final java.util.List<Button> quickButtons =
|
||||
new java.util.ArrayList<Button>();
|
||||
/** 设置页里那 3 个位置各放什么动作。null 表示这个位置空着。 */
|
||||
private String[] quickSlots = new String[]{"file", "mirror", "sfx"};
|
||||
private String[] quickSlots =
|
||||
new String[]{"file", "mirror", "sfx", "desktop"};
|
||||
|
||||
/** 快捷栏上能放的动作。id 会存进设置,**改名会让老配置失效**,
|
||||
* 所以这几个英文 id 不要动,界面上显示的中文随便改。 */
|
||||
@@ -327,12 +328,36 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
{"power", "关机"},
|
||||
{"tts", "语音播报"},
|
||||
{"image", "投图到电脑"},
|
||||
// 用户最早说这一格"不能改",后来又改成四格都能换 ——
|
||||
// 所以它现在是**普通可选动作**,只是默认放在第 4 格。
|
||||
{"desktop", "显示桌面"},
|
||||
};
|
||||
private static final String QUICK_PREF = "quick_slots";
|
||||
private static final int QUICK_CUSTOM_SLOTS = 3; // 固定那个不算在内
|
||||
/** 快捷栏一共几格。用户最初要求"最多 4 个",
|
||||
* 后来又改成"第一二三四格都能换东西",所以 4 格都可自定义。 */
|
||||
private static final int QUICK_SLOTS = 4;
|
||||
/** 快捷栏高度(dp)。46 接近 Android 建议的最小可点区域 48。
|
||||
* ⚠ 面板的 topMargin 也用这个值 —— 分两处写的话改一个忘一个,
|
||||
* 表现是"内容被栏盖住"或"上面空一条"。 */
|
||||
private static final int QUICK_BAR_DP = 46;
|
||||
|
||||
// ---------------- 鼠标控制(触摸板)----------------
|
||||
private FrameLayout mousePanel;
|
||||
|
||||
/**
|
||||
* 连上之后先给用户看的那一页:远控 / 静默更新被控端。
|
||||
*
|
||||
* <p>用户要求:"输入配对码后,进入一个页面,页面有两个按钮,
|
||||
* 第一个按钮是远控,第二个按钮是静默更新被控端"。
|
||||
*
|
||||
* <p>为什么不让它直接进控制页:老版本的被控端可能缺功能或者有 bug,
|
||||
* "先更新再远控"是更合理的顺序 —— 但也不能强制,所以给用户选。
|
||||
*/
|
||||
private FrameLayout choicePanel;
|
||||
/** 选择页上显示"电脑端版本 X.Y.Z"那一行。 */
|
||||
private TextView choiceVersionText;
|
||||
/** 选择页上显示"已连接 XXX"。 */
|
||||
private TextView choiceWhoText;
|
||||
private View mousePad;
|
||||
private boolean mouseDragging = false;
|
||||
private float mouseLastX = 0f;
|
||||
@@ -419,6 +444,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
// 编译就报 cannot find symbol。教训:写调用之前先 grep 一遍。
|
||||
loadQuickSlots();
|
||||
buildMousePanel(); // 只建,先不显示(showOnly 会切)
|
||||
buildChoicePanel(); // 同上:连上之后才显示
|
||||
buildQuickBar();
|
||||
|
||||
// 面板顶部给快捷栏让出一截,否则每页顶部内容会被盖住
|
||||
@@ -428,14 +454,19 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
applyPanelTopMargin(lanPanel);
|
||||
applyPanelTopMargin(settingsPanel);
|
||||
applyPanelTopMargin(mousePanel);
|
||||
applyPanelTopMargin(choicePanel);
|
||||
|
||||
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);
|
||||
// 快捷栏最后加 -> 永远在最上层
|
||||
root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30)));
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP)));
|
||||
|
||||
setContentView(root);
|
||||
|
||||
@@ -2435,10 +2466,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
settingsExtraBox.removeAllViews();
|
||||
|
||||
settingsExtraBox.addView(sectionTitle("快捷栏"));
|
||||
settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。"
|
||||
+ "最后一个「显示桌面」是固定的、改不了;"
|
||||
+ "前三个点下面自己选。(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {
|
||||
settingsExtraBox.addView(hintText("顶部那条栏,4 个格子"
|
||||
+ "**每一个都能换**,点下面自己选。"
|
||||
+ "(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_SLOTS; slot++) {
|
||||
final int s = slot;
|
||||
String now = (quickSlots[s] == null || quickSlots[s].isEmpty())
|
||||
? "空着" : quickLabel(quickSlots[s]);
|
||||
@@ -2451,10 +2482,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
}));
|
||||
}
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD,
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效 / 显示桌面)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx"};
|
||||
quickSlots = new String[]{"file", "mirror", "sfx", "desktop"};
|
||||
saveQuickSlots();
|
||||
fillSettingsExtra();
|
||||
toast("快捷栏已恢复默认");
|
||||
@@ -3001,7 +3032,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
* <p>快捷栏是贴在最上层的常驻元素(用户要求"不管干什么上面都有一栏"),
|
||||
* 面板内容如果不缩进就会被它盖住顶部一截。
|
||||
*
|
||||
* <p>集中在这里改,是因为"每个面板各写一遍 dp(30)"很容易漏一个 ——
|
||||
* <p>集中在这里改,是因为"每个面板各写一遍高度"很容易漏一个 ——
|
||||
* 表现就是"别的页正常,就某一页顶部被切掉"。
|
||||
*/
|
||||
private void applyPanelTopMargin(View panel) {
|
||||
@@ -3011,7 +3042,8 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
ViewGroup.LayoutParams lp = panel.getLayoutParams();
|
||||
if (lp instanceof FrameLayout.LayoutParams) {
|
||||
FrameLayout.LayoutParams flp = (FrameLayout.LayoutParams) lp;
|
||||
flp.topMargin = dp(30); // 跟 quickBar 的高度一致
|
||||
// 用同一个常量 —— 栏高改了这里自动跟着改
|
||||
flp.topMargin = dp(QUICK_BAR_DP);
|
||||
panel.setLayoutParams(flp);
|
||||
}
|
||||
}
|
||||
@@ -3025,10 +3057,10 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
|
||||
quickButtons.clear();
|
||||
// 4 个按钮:3 个自定义 + 1 个固定
|
||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) {
|
||||
for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(11f);
|
||||
b.setTextSize(14f); // 11 太小,用户说"按不到"
|
||||
b.setAllCaps(false);
|
||||
b.setTextColor(0xFFFFFFFF);
|
||||
b.setBackgroundColor(0x33FFFFFF);
|
||||
@@ -3052,7 +3084,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
}
|
||||
// 高度固定,免得按钮里的字多一个字整条栏就变高、页面跟着抖
|
||||
FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30));
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP));
|
||||
barLp.gravity = android.view.Gravity.TOP;
|
||||
quickBar.setLayoutParams(barLp);
|
||||
|
||||
@@ -3072,7 +3104,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = (i < QUICK_CUSTOM_SLOTS) ? quickSlots[i] : "desktop";
|
||||
String id = quickSlots[i];
|
||||
if (id == null || id.isEmpty()) {
|
||||
b.setText("-"); // 这个位置空着
|
||||
b.setEnabled(false);
|
||||
@@ -3097,9 +3129,12 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
return "-";
|
||||
}
|
||||
|
||||
/** 点第 slot 个按钮。0..2 是自定义,3 是那个固定的显示桌面。 */
|
||||
/** 点第 slot 个按钮。四格完全平等,都从 quickSlots 里取。 */
|
||||
private void onQuickClick(int slot) {
|
||||
String id = (slot < QUICK_CUSTOM_SLOTS) ? quickSlots[slot] : "desktop";
|
||||
if (slot < 0 || slot >= quickSlots.length) {
|
||||
return;
|
||||
}
|
||||
String id = quickSlots[slot];
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -3173,25 +3208,25 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
private void loadQuickSlots() {
|
||||
String raw = prefs().getString(QUICK_PREF, "");
|
||||
String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx"}
|
||||
? new String[]{"file", "mirror", "sfx", "desktop"}
|
||||
: raw.split(",", -1);
|
||||
// ⚠ 一定要过滤 + 补齐:
|
||||
// 过滤 —— 万一以后删了某个动作,老配置里的 id 就不认识了,
|
||||
// 不过滤的话那格会显示空白,用户以为坏了
|
||||
// 补齐 —— 保证长度正好 3,少了下标会越界
|
||||
String[] out = new String[QUICK_CUSTOM_SLOTS];
|
||||
String[] out = new String[QUICK_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_CUSTOM_SLOTS) {
|
||||
if (n >= QUICK_SLOTS) {
|
||||
break;
|
||||
}
|
||||
if (id != null && !id.isEmpty() && !"desktop".equals(id)
|
||||
&& isKnownQuickAction(id)) {
|
||||
// ⚠ 这里**不再排除 desktop** —— 用户改成四格都能换了
|
||||
if (id != null && !id.isEmpty() && isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx"};
|
||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS; i++) {
|
||||
String[] fallback = {"file", "mirror", "sfx", "desktop"};
|
||||
for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||
if (out[i] == null) {
|
||||
// 补一个没被用过的默认动作
|
||||
for (String f : fallback) {
|
||||
@@ -3408,12 +3443,143 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
"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) {
|
||||
// ⚠ 新增面板必须加进这个数组 —— 漏了的话那个面板永远不隐藏,
|
||||
// 表现是"进了别的页面它还压在上面"。
|
||||
// (注释里那条"漏了 lanPanel 导致局域网连上没反应"就是这个坑。)
|
||||
View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,
|
||||
settingsPanel, mousePanel};
|
||||
settingsPanel, mousePanel, choicePanel};
|
||||
for (View v : all) {
|
||||
if (v != null) {
|
||||
v.setVisibility(v == keep ? View.VISIBLE : View.GONE);
|
||||
@@ -3675,6 +3841,14 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
|
||||
/** 刚才连的地址/配对码/令牌,切回中转时要用。 */
|
||||
private volatile String lastHost = "";
|
||||
/**
|
||||
* 当前连上的电脑名。选择页要显示「已连接『电脑名』」——
|
||||
* 只显示 IP 的话用户不好认是哪台。
|
||||
*
|
||||
* <p>⚠ 我第一版在这里凭直觉写了个 `lastDeviceName` 却忘了定义它,
|
||||
* 编译报 "找不到符号"。**写代码时要用的变量,先确认它存在。**
|
||||
*/
|
||||
private volatile String lastDeviceName = "";
|
||||
private volatile int lastPort;
|
||||
private volatile String lastCode = "";
|
||||
private volatile String lastToken = "";
|
||||
@@ -3903,7 +4077,15 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
+ (finalName == null ? host : finalName)
|
||||
+ (finalLanDirect ? "(局域网)" : "(中转)"));
|
||||
}
|
||||
showControlPanel();
|
||||
// 用户要求:输入配对码连上之后先到选择页
|
||||
// (远控 / 静默更新被控端),不直接进控制页。
|
||||
//
|
||||
// ⚠ 只有**这个**调用点改。下面"自动切到中转恢复"
|
||||
// 那个 showControlPanel() 不改 —— 用户本来就在
|
||||
// 远控页,中途弹选择页会打断他。
|
||||
lastDeviceName = (finalName == null
|
||||
? host : finalName);
|
||||
showChoicePanel();
|
||||
appendLog("已连接到 " + host);
|
||||
toast("连接成功");
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:2.1.7
|
||||
版本:2.2.0
|
||||
作者:小麦子
|
||||
|
||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||
@@ -197,7 +197,7 @@
|
||||
八、版本记录
|
||||
--------------------------------------------------------
|
||||
|
||||
2.1.7
|
||||
2.2.0
|
||||
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
||||
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
||||
合计 10 条连接),一种协议犯病另一种还在服务
|
||||
@@ -218,6 +218,6 @@
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 2.1.7
|
||||
软件:ykxmz 2.2.0
|
||||
许可条款见《许可协议》(LICENSE.txt)
|
||||
--------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:2.1.7
|
||||
版本:2.2.0
|
||||
作者:小麦子
|
||||
|
||||
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
||||
@@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 2.1.7
|
||||
软件:ykxmz 2.2.0
|
||||
继续安装,即表示你已阅读并同意《许可协议》。
|
||||
--------------------------------------------------------
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz(远程管理工具)
|
||||
版本:2.1.7
|
||||
版本:2.2.0
|
||||
作者:小麦子
|
||||
生效日期: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)。
|
||||
|
||||
+40
-10
@@ -4,19 +4,49 @@
|
||||
|
||||
---
|
||||
|
||||
## 一、发布流程:先给用户,用户验完才推送
|
||||
## 一、发布流程:按「预案」执行
|
||||
|
||||
> 用户原话:「你以后就这样,你先把所有东西给我,我运行没问题再告诉你帮我推送」
|
||||
> 用户原话:「以后我会说按照预案几处理」
|
||||
>
|
||||
> 用户原话(预案一):「做完以后测试,没问题上传」
|
||||
> 用户原话(预案二):「做完以后测试,测试完以后别上传,直接给我」
|
||||
|
||||
**每次发版必须按这个顺序:**
|
||||
**用户会明确说"按预案几",说了就按那个走。**
|
||||
|
||||
1. 本地把**所有产物**构建完成
|
||||
2. 跑测试(全量回归 + 安装包周期 + 发布验收)
|
||||
3. **把产物交给用户**去验证
|
||||
4. **等用户明确说「可以推」之后**,才创建 Gitee 发布 / 上传发布附件
|
||||
| 预案 | 做什么 |
|
||||
|---|---|
|
||||
| **预案一** | 做完 → **测试** → 没问题 → **上传**(创建 Gitee 发布 + 传附件 + 部署到服务器) |
|
||||
| **预案二** | 做完 → **测试** → **不上传**,产物交给用户 |
|
||||
|
||||
❌ **绝对不能**自己构建完就直接建发布。
|
||||
✅ 代码提交到 `main` 是可以的(那不是"发布")。
|
||||
### 两个预案共同的部分(都必须做)
|
||||
|
||||
1. 本地把**所有产物**构建完成(APK / 安装包 / 服务端包)
|
||||
2. 跑测试:全量回归 + 安装包周期 + 发布验收
|
||||
3. **测试不绿就停**,先说清楚哪儿红了 —— 不许带着红测试交付
|
||||
|
||||
### 只有预案一才做的部分
|
||||
|
||||
4. 提交 `main` + 推送
|
||||
5. 创建 Gitee 发布、上传三个附件
|
||||
6. 部署到服务器(`_build/deploy_lightsail_release.py`),并**实测**线上:
|
||||
|
||||
- `/v1/update/latest` 返回的版本 = 刚发的
|
||||
- 官网角标 = 刚发的
|
||||
- 安装包能下(看真实字节数)
|
||||
- `/v1/health` 正常
|
||||
|
||||
### 用户没说预案几的时候
|
||||
|
||||
**默认按预案一**(那是长期以来的既有习惯),但要在交付时**说明**
|
||||
"我按预案一做了,已经上传",让用户有机会纠正。
|
||||
|
||||
### 历史(这条规矩怎么来的)
|
||||
|
||||
最早用户说「你先把所有东西给我,我运行没问题再告诉你帮我推送」,
|
||||
中间放宽过一次(「你自己上传就行了」),后来又要"自己测完再上传"。
|
||||
现在固化成两个预案,由用户每次指定。
|
||||
|
||||
**仍然绝对不做的**:不测就上传。
|
||||
|
||||
---
|
||||
|
||||
@@ -33,7 +63,7 @@
|
||||
- **我(AI)在对话里给出的总结、汇报、方案**
|
||||
- 任何我会贴给用户看、或用户可能转给别人的材料
|
||||
|
||||
**只能提官网 `https://relay.xxmxxs.top`。**
|
||||
**只能提官网 `https://yk.xxmxxs.top`。**
|
||||
|
||||
**不要说**:`/zhongzhuanzhan`、`/admin`、"管理后台"、"管理页"、
|
||||
"管理入口"、管理密码之类。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz(远程管理工具)
|
||||
版本:2.1.7
|
||||
版本:2.2.0
|
||||
作者:小麦子
|
||||
生效日期: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)。
|
||||
@@ -173,7 +173,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称: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
|
||||
版本:2.1.7
|
||||
版本:2.2.0
|
||||
作者:小麦子
|
||||
|
||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||
@@ -539,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
八、版本记录
|
||||
--------------------------------------------------------
|
||||
|
||||
2.1.7
|
||||
2.2.0
|
||||
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
||||
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
||||
合计 10 条连接),一种协议犯病另一种还在服务
|
||||
@@ -560,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 2.1.7
|
||||
软件:ykxmz 2.2.0
|
||||
许可条款见《许可协议》(LICENSE.txt)
|
||||
--------------------------------------------------------
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@
|
||||
; ============================================================================
|
||||
|
||||
#define MyAppName "ykxmz"
|
||||
#define MyAppVersion "2.1.7"
|
||||
#define MyAppVersion "2.2.0"
|
||||
#define MyAppPublisher "小麦子"
|
||||
#define MyAppExeName "ykxmz.exe"
|
||||
|
||||
@@ -163,7 +163,7 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \
|
||||
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
|
||||
; (PowerShell 的 Start-Process -Wait 就是)。
|
||||
; 学校批量部署请用 cmd:
|
||||
; cmd /c ykxmz-setup-2.1.7.exe /VERYSILENT /CODE=123456
|
||||
; cmd /c ykxmz-setup-2.2.0.exe /VERYSILENT /CODE=123456
|
||||
; cmd 只等安装器本身,不等孙子进程,不会卡。
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
||||
Description: "立即启动(后台运行,不显示窗口)"; \
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "2.1.7"
|
||||
VERSION = "2.2.0"
|
||||
|
||||
# ---------------- 参数(都能用环境变量覆盖)----------------
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""局域网遥控 · Windows 被控端。"""
|
||||
|
||||
__version__ = "2.1.7"
|
||||
__version__ = "2.2.0"
|
||||
APP_NAME = "ykxmz"
|
||||
|
||||
+180
-34
@@ -538,59 +538,205 @@ class Actions:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。"""
|
||||
"""列出系统可用的 TTS 音色。
|
||||
|
||||
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话
|
||||
System.Speech 那条路列不出来,而 SAPI5 一定有。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
script = (
|
||||
ps = self._powershell_path()
|
||||
if not ps:
|
||||
return []
|
||||
out = []
|
||||
for script in (
|
||||
# 优先 SAPI5 COM
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }",
|
||||
# 退到 System.Speech
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"
|
||||
)
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }",
|
||||
):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[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:
|
||||
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()]
|
||||
vbs = self._write_vbs(text, voice, rate)
|
||||
except Exception: # noqa: BLE001
|
||||
vbs = None
|
||||
if vbs is not None:
|
||||
# 1 / 2:完全不依赖 PowerShell 和 .NET
|
||||
cmds.append(("cscript", ["cscript", "//nologo", str(vbs)]))
|
||||
cmds.append(("wscript", ["wscript", "//nologo", str(vbs)]))
|
||||
|
||||
ps = self._powershell_path()
|
||||
esc = text.replace("'", "''")
|
||||
if ps:
|
||||
# 3:绕开 .NET,只用系统原生 SAPI5
|
||||
sapi = ["$v = New-Object -ComObject SAPI.SpVoice;"]
|
||||
if voice:
|
||||
sapi.append("foreach ($o in $v.GetVoices()) { "
|
||||
"if ($o.GetDescription() -like '*%s*') { "
|
||||
"$v.Voice = $o } };" % voice.replace("'", "''"))
|
||||
if rate:
|
||||
sapi.append("$v.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sapi.append("$v.Speak('%s')" % esc)
|
||||
cmds.append(("ps-sapi", [ps, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sapi)]))
|
||||
|
||||
# 4:原来的做法,但用绝对路径
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-speech", [ps, "-NoProfile", "-WindowStyle",
|
||||
"Hidden", "-Command", "".join(sp)]))
|
||||
|
||||
# 5:最原始的(PATH + System.Speech),兜底
|
||||
import shutil as _sh
|
||||
if _sh.which("powershell"):
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-path", ["powershell", "-NoProfile",
|
||||
"-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sp)]))
|
||||
return cmds
|
||||
|
||||
def say(self, text: str, voice: str = "", rate: int = 0) -> str:
|
||||
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。"""
|
||||
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。
|
||||
|
||||
⚠ 会**等播报结束**并看退出码,失败就抛错。
|
||||
原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。
|
||||
代价是同步等待(念多久等多久),但"报成功其实没出声"
|
||||
比"慢一点"糟糕得多。
|
||||
"""
|
||||
if not text.strip():
|
||||
raise ActionError("播报内容为空")
|
||||
if not IS_WINDOWS:
|
||||
raise ActionError("语音播报目前仅支持 Windows")
|
||||
|
||||
chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip()
|
||||
chosen = (voice or "").strip() or str(
|
||||
self.store.get("tts_voice") or "").strip()
|
||||
try:
|
||||
speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
speed = 0
|
||||
speed = max(-10, min(10, speed))
|
||||
|
||||
safe = text.replace("'", "''")
|
||||
parts = [
|
||||
"Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;",
|
||||
]
|
||||
if chosen:
|
||||
parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} "
|
||||
"catch { };")
|
||||
if speed:
|
||||
parts.append(f"$s.Rate = {speed};")
|
||||
parts.append(f"$s.Speak('{safe}')")
|
||||
script = "".join(parts)
|
||||
cmds = self._tts_commands(text, chosen, speed)
|
||||
if not cmds:
|
||||
raise ActionError("这台电脑上找不到可用的语音播报方式"
|
||||
"(没有 cscript 也没有 powershell?)")
|
||||
|
||||
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 '默认音色'})"
|
||||
# 上次成功的那条优先 —— 不然每播一次都要把几条全试一遍
|
||||
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))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 播放本机音频文件(MP3 等)
|
||||
|
||||
@@ -27,12 +27,12 @@ VSVersionInfo(
|
||||
[
|
||||
StringStruct("CompanyName", "ykxmz"),
|
||||
StringStruct("FileDescription", "ykxmz"),
|
||||
StringStruct("FileVersion", "2.1.7.0"),
|
||||
StringStruct("FileVersion", "2.2.0.0"),
|
||||
StringStruct("InternalName", "ykxmz"),
|
||||
StringStruct("LegalCopyright", "ykxmz"),
|
||||
StringStruct("OriginalFilename", "ykxmz.exe"),
|
||||
StringStruct("ProductName", "ykxmz"),
|
||||
StringStruct("ProductVersion", "2.1.7.0"),
|
||||
StringStruct("ProductVersion", "2.2.0.0"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user