2.2.0:手表端连上后先到「远控 / 静默更新被控端」选择页

== 用户要求 ==

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

== 做成什么样 ==

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

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

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

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

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

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

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

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

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

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

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

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

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

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

== 验证 ==

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