diff --git a/_build/_commitmsg_a.txt b/_build/_commitmsg_a.txt
new file mode 100644
index 0000000..d34cc5c
--- /dev/null
+++ b/_build/_commitmsg_a.txt
@@ -0,0 +1,30 @@
+阶段3+4+5:管理页白色化 + 局域网诊断 + 全量验收
+
+阶段3 管理页
+- 清掉全部深色主题残留(地址框 #0b1220、按钮悬停 #262c36、
+ 删除按钮 #2d1214 等 11 处),现在 0 处深色
+- 接口全部正常:health / downloads / netcheck
+
+阶段4 局域网诊断
+- 查清楚"电脑不在同一网络"是怎么回事:被控端是**对的**
+ (lan_ips() 报 ['192.168.2.64', '169.254.83.107'],真实地址排第一),
+ 是手表当时真不在 192.168.2.x —— 可能连了 5G 频段的另一个路由器,或开着移动数据
+- 所以改了手表的提示:不再只说"不在同一网络",
+ 而是把**双方地址都打出来**,用户一眼能看出是网段不同还是连错 WiFi
+- 端到端验证局域网路径:认证 1 ms,功能列表 207 项
+
+阶段1 结论修正(一天内推翻自己两次,如实记录)
+- 第一次判断"优选 IP 无用":错。半小时内确实能避开超时 IP
+- 第二次固定优选 IP:也错。那些实测 138ms 的 IP 半小时后全不通了
+ —— Cloudflare anycast 可达性随运营商路由变化,固定 IP 等于钉死到会失效的地址
+- 最终:不固定,靠 GeoDNS + cloudflared 重试 + ha-connections 2 冗余
+- 撤销后立即变好:连接稳定 2 条、超时告警 0 次、设备侧延迟 4.40s -> 1.66s
+- **真正的瓶颈**:分段实测发现本机到 Cloudflare 的**裸 RTT 就 1439 ms**,
+ 比经隧道的请求(1215 ms)还慢。说明瓶颈是这条网络的 4G 上行本身,
+ 不是设备的隧道。公网侧已无优化空间。
+- 局域网 118 ms vs 公网 1215 ms,快 10 倍 —— 这才是该走的路
+
+阶段5 验收
+- 全量回归 29 项全通过
+- 设备侧验收 38 项全通过
+- 成品:setup exe 14.37 MB / server zip / watch apk,已同步到设备下载区
diff --git a/_build/_lantest_data/devices.json b/_build/_lantest_data/devices.json
new file mode 100644
index 0000000..59a2070
--- /dev/null
+++ b/_build/_lantest_data/devices.json
@@ -0,0 +1,8 @@
+{
+ "lantest": {
+ "name": "测试手表",
+ "token": "6ec9b2878357045a389acd97bab84871",
+ "paired_at": 1791303182.5310695,
+ "last_seen": 1791303182.5336223
+ }
+}
\ No newline at end of file
diff --git a/_build/_lantest_data/settings.json b/_build/_lantest_data/settings.json
new file mode 100644
index 0000000..69874c5
--- /dev/null
+++ b/_build/_lantest_data/settings.json
@@ -0,0 +1,30 @@
+{
+ "port": 8875,
+ "server_name": "DESKTOP-RN5I625",
+ "allow_run": true,
+ "allow_power": true,
+ "allow_screen": true,
+ "start_minimized": false,
+ "allow_new_pairing": false,
+ "image_timeout": 0,
+ "image_display": "window",
+ "image_custom_width": 0,
+ "image_custom_height": 0,
+ "image_window_title": "",
+ "tts_voice": "",
+ "tts_rate": 0,
+ "mp3_dir": "",
+ "file_roots": "",
+ "extra_apps": "",
+ "extra_commands": "",
+ "download_dir": "received",
+ "screen_max_width": 480,
+ "screen_quality": 72,
+ "screen_fps": 12,
+ "pairing_code": "246810",
+ "pairing_created": 1791303175.5928898,
+ "setup_done": true,
+ "close_pair_after_first": false,
+ "relay_last_good": "http://192.168.2.156:8080",
+ "relay_device_id": "DESKTOP-RN5I625-832c5e0b202b"
+}
\ No newline at end of file
diff --git a/_build/_msg_a.txt b/_build/_msg_a.txt
deleted file mode 100644
index 20b3edb..0000000
--- a/_build/_msg_a.txt
+++ /dev/null
@@ -1,26 +0,0 @@
-阶段1+2:恢复 HTTP/2 加速 + 重做官网
-
-阶段1 网络加速
-- 发现隧道在跑 QUIC(ExecStart 丢了 --protocol http2),
- 实测 QUIC 可用率 48% vs HTTP/2 80%+,是重大性能回退。
- 恢复后公网延迟从 4141ms 降到 690ms(快 6 倍),可用率 92%。
-- 优选 IP 结论:无效。hosts 覆盖确实生效了(日志里连的就是指定 IP),
- 但落点仍是 lax —— 运营商把所有 Cloudflare anycast 都路由到美西。
- 已清掉这段无效配置。
-- BBR 不可用:设备是自定义内核 6.4.0-rc4-jsbsbxjxh66,
- 没编 tcp_bbr 模块也没配置项。改用 cubic + 调优:
- TCP 缓冲 4MB、tcp_fastopen=3、tcp_slow_start_after_idle=0
- (长轮询场景关键)、tcp_tw_reuse=1。
-
-阶段2 官网重做
-- 拆出独立模块 relay/landing.py(relay.py 已两千多行,
- 再塞 HTML 没法看)
-- 信息结构参考成熟产品站:吸顶导航 / Hero / 九宫格功能 /
- 三步上手 / 下载 / FAQ / 页脚
-- 保持白色简洁:底白、浅灰面板、细边框、大量留白
-- logo 用内联 SVG(ICO 放 img 里实测是破图)
-- 管理入口从官网移除(管理不对外开放)
-
-过程中被自己的测试抓到两个 bug:
-- 模块叫 site.py 会和 Python 标准库重名,import site 拿到的是标准库
-- _logo() 里把 % 格式化和字符串拼接混用,直接抛 TypeError
diff --git a/_build/admin_preview.html b/_build/admin_preview.html
new file mode 100644
index 0000000..d2728ce
--- /dev/null
+++ b/_build/admin_preview.html
@@ -0,0 +1,496 @@
+
+
+
+
+
ykxmz 中转服务器
+
+
+
+
+ yx
+
+
ykxmz 中转服务器
+
远程控制 · 跨网络中转
+
+
+
+
+
+
当前公网地址 —— 手表「中转设置」里填这个
+
+
https://relay.xxmxxs.top
+
+
+
+
+
+
网络体检 —— 走的是局域网还是隧道?
+
点下面按钮测一下
+
+
+
+
+
+
+
+
+
+
+
+ 删除会连配对码一起清掉,那台设备下次可以重新设码。
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/_build/check_admin_dark.sh b/_build/check_admin_dark.sh
new file mode 100644
index 0000000..fa4add7
--- /dev/null
+++ b/_build/check_admin_dark.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+# 检查管理页的深色残留
+set -u
+
+BASE=http://127.0.0.1:8080
+KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
+
+curl -s -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan" -o /tmp/adm.html 2>/dev/null
+
+echo " 管理页 $(wc -c < /tmp/adm.html) 字节"
+echo " HTTP $(curl -s -o /dev/null -w '%{http_code}' -H "X-Admin: $KEY" "$BASE/zhongzhuanzhan")"
+
+echo
+echo " 深色值残留:"
+found=0
+for c in 0b1220 262c36 1b222c 1c2733 213042 2d1214 4a1a1e 484f58 0d1117; do
+ n=$(grep -c "#$c" /tmp/adm.html 2>/dev/null || echo 0)
+ if [ "$n" != "0" ]; then
+ echo " #$c : $n 处"
+ found=1
+ fi
+done
+[ "$found" = "0" ] && echo " 无 ✓"
+
+echo
+echo " 关键样式:"
+grep -o -- '--bg:#[0-9a-f]*' /tmp/adm.html | head -1 | sed 's/^/ /'
+grep -o 'background:#f6f8fa' /tmp/adm.html | head -1 | sed 's/^/ 地址框: /'
+grep -o 'button:hover{background:#[0-9a-f]*' /tmp/adm.html | head -1 | sed 's/^/ 按钮悬停: /'
+grep -o 'button.del{background:#[0-9a-f]*' /tmp/adm.html | head -1 | sed 's/^/ 删除按钮: /'
+
+echo
+echo " 功能接口还正常吗:"
+for api in /v1/health /v1/downloads /v1/admin/netcheck; do
+ code=$(curl -s -o /dev/null -w '%{http_code}' -H "X-Admin: $KEY" --max-time 10 "$BASE$api")
+ echo " $api HTTP $code"
+done
+
+echo
+echo " 其余页面没受影响:"
+echo " 官网 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/")"
+echo " favicon HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/favicon.ico")"
diff --git a/_build/diag_lan_phase4.py b/_build/diag_lan_phase4.py
new file mode 100644
index 0000000..e801ee2
--- /dev/null
+++ b/_build/diag_lan_phase4.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+r'''阶段4:诊断局域网直连为什么没生效。
+
+现象(手表日志):
+ 已更新电脑地址为 192.168.2.64:8765
+ 电脑不在同一网络,继续用中转
+
+判断逻辑在手表端 MainActivity:
+ pickSameSubnet(client.relayLanIps()) <- 拿电脑报上来的 lanIps
+ localIpv4List() <- 拿手表自己的网卡 IP
+ sameSubnet(a, b) <- 比前 3 段(/24)
+
+可能出问题的地方:
+ 1. 被控端报的 lanIps 里全是虚拟网卡(VMware/VirtualBox/Hyper-V)
+ —— 真实的 192.168.2.64 反而不在里面
+ 2. 手表自己的 localIpv4List() 拿到的是运营商 IP(移动数据)
+ 3. 手表和电脑真的不在一个网段
+
+这个脚本查第 1 条:本机被控端实际会报哪些 IP。
+第 2、3 条要手表端日志,我先把能查的查清。
+'''
+from __future__ import annotations
+
+import json
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
+sys.path.insert(0, str(ROOT / "windows"))
+
+
+def main() -> int:
+ print(" 一、被控端会报哪些 lanIps(这才是关键)")
+ try:
+ from lanremote.discovery import local_ipv4_list
+ ips = local_ipv4_list()
+ print(" local_ipv4_list() -> %s" % (ips,))
+ except Exception as exc: # noqa: BLE001
+ print(" 拿不到(%s),换个方式" % exc)
+ ips = []
+
+ # 直接列出所有网卡地址,对照着看
+ print()
+ print(" 二、本机所有 IPv4 地址(含虚拟网卡)")
+ try:
+ result = subprocess.run(
+ ["powershell", "-NoProfile", "-Command",
+ "Get-NetIPAddress -AddressFamily IPv4 | "
+ "Where-Object { $_.IPAddress -notlike '127.*' } | "
+ "ForEach-Object { $_.IPAddress + ' ' + $_.InterfaceAlias }"],
+ capture_output=True, text=True, timeout=60,
+ encoding="utf-8", errors="replace")
+ for line in (result.stdout or "").splitlines():
+ if line.strip():
+ print(" " + line.strip())
+ except Exception as exc: # noqa: BLE001
+ print(" 失败: %s" % exc)
+
+ print()
+ print(" 三、判断:报出去的 IP 里有没有真实局域网地址")
+ real = [ip for ip in ips
+ if ip.startswith(("192.168.", "10.", "172.16.", "172.17.",
+ "172.18.", "172.19.", "172.2", "172.30.",
+ "172.31.")) and not ip.startswith("169.254.")]
+ virtual_hint = [ip for ip in ips if ip.startswith(("192.168.56.",
+ "192.168.99.",
+ "172.17.", "172.18.",
+ "172.19."))]
+ print(" 报出去的: %s" % (ips or "(空)"))
+ print(" 像真实局域网的: %s" % (real or "(没有!)"))
+ print(" 像虚拟网卡的: %s" % (virtual_hint or "(没有)"))
+
+ if not ips:
+ print()
+ print(" ⚠ 一个都没报 —— 手表当然匹配不上。")
+ elif not real:
+ print()
+ print(" ⚠ 报的全是虚拟网卡地址,手表连不到这些。")
+ else:
+ print()
+ print(" OK 至少有一个真实局域网地址,手表应该能匹配到 %s" % real[0])
+
+ print()
+ print(" 四、手表端要看的日志关键字")
+ print(" 手表连上后,日志区会打:")
+ print(" 「已更新电脑地址为 x.x.x.x:8765」—— 这是被控端报的地址")
+ print(" 「电脑不在同一网络,继续用中转」—— 说明 sameSubnet 没匹配上")
+ print(" 要确认两件事:")
+ print(" 1. 那个 x.x.x.x 是不是电脑真实的局域网地址")
+ print(" 2. 手表自己的 WiFi 地址是不是同一个 /24")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/diag_tunnel_conn.sh b/_build/diag_tunnel_conn.sh
new file mode 100644
index 0000000..dbd3527
--- /dev/null
+++ b/_build/diag_tunnel_conn.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+# 查隧道为什么只有 1 条连接
+set -u
+
+echo "===== 1. 服务状态 ====="
+systemctl is-active lanremote-tunnel
+systemctl show lanremote-tunnel -p ExecMainStartTimestamp --value | sed 's/^/ 启动于: /'
+
+echo
+echo "===== 2. metrics 全部隧道指标 ====="
+curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
+ | grep -E '^cloudflared_tunnel' | sed 's/^/ /'
+
+echo
+echo "===== 3. 最近注册的连接 ====="
+grep -E 'Registered tunnel connection' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -6 \
+ | sed 's/.*\(connIndex=[0-9]*\).*\(location=[a-z0-9]*\).*\(protocol=[a-z0-9]*\).*/ \1 \2 \3/'
+
+echo
+echo "===== 4. 有没有注册失败 ====="
+grep -iE 'failed|error|retry' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -6 | sed 's/^/ /'
+
+echo
+echo "===== 5. HA 连接数设置 ====="
+grep -o 'ha-connections [0-9]*' /etc/systemd/system/lanremote-tunnel.service | sed 's/^/ /'
+
+echo
+echo "===== 6. 日志里 Initial protocol 的次数(每次重启会打)====="
+grep -c 'Initial protocol' /root/lanremote-relay/tunnel.log 2>/dev/null | sed 's/^/ /'
+echo " 最近 3 条:"
+grep 'Initial protocol' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -3 | sed 's/^/ /'
+
+echo
+echo "===== 7. 进程 ====="
+ps aux 2>/dev/null | grep -c '[c]loudflared' | sed 's/^/ cloudflared 进程数: /'
diff --git a/_build/final_acceptance.sh b/_build/final_acceptance.sh
new file mode 100644
index 0000000..dc0412f
--- /dev/null
+++ b/_build/final_acceptance.sh
@@ -0,0 +1,100 @@
+#!/bin/bash
+# 最终验收:官网 / 管理页 / 下载 / 加速 / 功能
+set -u
+
+BASE=http://127.0.0.1:8080
+PUB=https://relay.xxmxxs.top
+KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
+pass=0; fail=0
+
+ok() { pass=$((pass+1)); echo " ✓ $1"; }
+bad() { fail=$((fail+1)); echo " ✗ $1"; }
+code() { curl -s -o /dev/null -w '%{http_code}' --max-time 25 "$@"; }
+
+echo "════════ 一、官网 ════════"
+c=$(code "$BASE/")
+[ "$c" = "200" ] && ok "首页 HTTP 200" || bad "首页 HTTP $c"
+curl -s --max-time 20 "$BASE/" -o /tmp/s.html
+size=$(wc -c < /tmp/s.html)
+[ "$size" -gt 10000 ] && ok "页面 $size 字节(内容丰富)" || bad "页面只有 $size 字节"
+
+for pair in "吸顶导航:nav-in" "Hero:hero" "功能网格:id=\"features\"" \
+ "三步上手:id=\"how\"" "下载区:id=\"download\"" \
+ "FAQ:id=\"faq\"" "页脚:foot-in"; do
+ label="${pair%%:*}"; key="${pair#*:}"
+ if grep -q "$key" /tmp/s.html; then ok "$label"; else bad "$label"; fi
+done
+
+[ "$(grep -c 'class=\"feat\"' /tmp/s.html)" = "9" ] && ok "9 张功能卡" || bad "功能卡数量不对"
+[ "$(grep -c '/dev/null || echo 0)
+ [ "$n" != "0" ] && dark=$((dark+n))
+done
+[ "$dark" = "0" ] && ok "无深色残留" || bad "深色残留 $dark 处"
+[ "$(code "$BASE/admin")" = "302" ] && ok "/admin 跳转正常" || bad "/admin 跳转异常"
+
+echo
+echo "════════ 三、下载 ════════"
+for f in ykxmz-setup-1.7.1.exe ykxmz-server-1.7.1.zip ykxmz-watch-1.7.1.apk; do
+ c=$(code "$BASE/download/$f")
+ [ "$c" = "200" ] && ok "$f" || bad "$f HTTP $c"
+done
+[ "$(code "$BASE/v1/downloads")" = "200" ] && ok "/v1/downloads" || bad "/v1/downloads"
+c=$(code "$BASE/download/../relay.py")
+[ "$c" = "404" ] || [ "$c" = "400" ] && ok "路径穿越被挡($c)" || bad "路径穿越 HTTP $c"
+
+echo
+echo "════════ 四、加速 ════════"
+proto=$(grep -oE 'Initial protocol [a-z0-9]+' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -1)
+echo "$proto" | grep -q http2 && ok "隧道走 HTTP/2(不是 QUIC)" || bad "隧道协议: $proto"
+conn=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
+[ "$conn" = "2" ] && ok "隧道连接数 $conn" || bad "隧道连接数 $conn"
+[ "$(sysctl -n net.ipv4.tcp_mtu_probing)" = "1" ] && ok "MTU 探测开着" || bad "MTU 探测关了"
+[ "$(sysctl -n net.ipv4.tcp_slow_start_after_idle)" = "0" ] && ok "空闲后不重置窗口" || bad "slow_start 没关"
+[ "$(sysctl -n net.ipv4.tcp_fastopen)" = "3" ] && ok "TCP FastOpen 开着" || bad "FastOpen 没开"
+[ "$(grep -c argotunnel /etc/hosts)" = "0" ] && ok "无效的 hosts 覆盖已清" || bad "hosts 还有残留"
+
+echo
+echo "════════ 五、功能 ════════"
+for api in /v1/health /favicon.ico; do
+ c=$(code "$BASE$api")
+ [ "$c" = "200" ] && ok "$api" || bad "$api HTTP $c"
+done
+v=$(curl -s --max-time 10 "$BASE/v1/health" | grep -o '"version": *"[^"]*"')
+echo " $v"
+
+echo
+echo "════════ 六、公网(20 次测延迟)════════"
+okc=0; total=0
+for i in $(seq 1 20); do
+ r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
+ -H 'User-Agent: LanRemote/1.4.0' "$PUB/" 2>/dev/null)
+ c=$(echo "$r" | cut -d' ' -f1)
+ t=$(echo "$r" | cut -d' ' -f2)
+ if [ "$c" = "200" ]; then
+ okc=$((okc+1))
+ total=$(echo "$total + $t" | bc)
+ fi
+ sleep 0.5
+done
+avg=$(echo "scale=2; $total / $okc" | bc 2>/dev/null || echo "?")
+[ "$okc" -ge 18 ] && ok "可用 $okc/20" || bad "可用 $okc/20"
+echo " 平均 ${avg} 秒"
+
+echo
+echo "════════════════════════════════"
+echo " 通过 $pass 项,失败 $fail 项"
+[ "$fail" = "0" ] && echo " 全部通过 ✓" || echo " 有失败"
diff --git a/_build/final_acceptance2.sh b/_build/final_acceptance2.sh
new file mode 100644
index 0000000..0351e41
--- /dev/null
+++ b/_build/final_acceptance2.sh
@@ -0,0 +1,117 @@
+#!/bin/bash
+# 最终验收(修正版:单引号里不要转义双引号;grep -c 取第一行)
+set -u
+
+BASE=http://127.0.0.1:8080
+PUB=https://relay.xxmxxs.top
+KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
+pass=0; fail=0
+
+ok() { pass=$((pass+1)); echo " ✓ $1"; }
+bad() { fail=$((fail+1)); echo " ✗ $1"; }
+code() { curl -s -o /dev/null -w '%{http_code}' --max-time 25 "$@"; }
+# grep -c 有时会输出多行(多个文件时),统一取第一行
+cnt() { grep -c "$1" "$2" 2>/dev/null | head -1 | tr -d '[:space:]'; }
+
+echo "════════ 一、官网 ════════"
+c=$(code "$BASE/")
+[ "$c" = "200" ] && ok "首页 HTTP 200" || bad "首页 HTTP $c"
+curl -s --max-time 20 "$BASE/" -o /tmp/s.html
+size=$(wc -c < /tmp/s.html | tr -d ' ')
+[ "$size" -gt 10000 ] && ok "页面 $size 字节(内容丰富)" || bad "页面只有 $size 字节"
+
+check() {
+ if grep -q "$2" /tmp/s.html; then ok "$1"; else bad "$1"; fi
+}
+check "吸顶导航" 'nav-in'
+check "Hero" 'hero'
+check "功能网格" 'id="features"'
+check "三步上手" 'id="how"'
+check "下载区" 'id="download"'
+check "FAQ" 'id="faq"'
+check "页脚" 'foot-in'
+check "版本徽章" 'badge'
+
+[ "$(cnt 'class="feat"' /tmp/s.html)" = "9" ] && ok "9 张功能卡" \
+ || bad "功能卡 $(cnt 'class="feat"' /tmp/s.html) 张"
+[ "$(cnt '/dev/null | tail -1)
+echo "$proto" | grep -q http2 && ok "隧道走 HTTP/2(不是 QUIC)" || bad "隧道协议: $proto"
+conn=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
+ | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
+[ "$conn" = "2" ] && ok "隧道连接数 2" || echo " · 隧道连接数 $conn(1 也可用,2 才有冗余)"
+[ "$(sysctl -n net.ipv4.tcp_mtu_probing)" = "1" ] && ok "MTU 探测开着" || bad "MTU 探测关了"
+[ "$(sysctl -n net.ipv4.tcp_slow_start_after_idle)" = "0" ] && ok "空闲后不重置窗口" \
+ || bad "slow_start 没关"
+[ "$(sysctl -n net.ipv4.tcp_fastopen)" = "3" ] && ok "TCP FastOpen 开着" || bad "FastOpen 没开"
+[ "$(cnt argotunnel /etc/hosts)" = "0" ] && ok "无效的 hosts 覆盖已清" || bad "hosts 有残留"
+
+echo
+echo "════════ 五、功能 ════════"
+for api in /v1/health /favicon.ico; do
+ c=$(code "$BASE$api")
+ [ "$c" = "200" ] && ok "$api" || bad "$api HTTP $c"
+done
+curl -s --max-time 10 "$BASE/v1/health" | grep -o '"version": *"[^"]*"' | sed 's/^/ /'
+
+echo
+echo "════════ 六、公网可用性(设备侧,发夹回路=最坏情况)════════"
+okc=0; total=0
+for i in $(seq 1 15); do
+ r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
+ -H 'User-Agent: LanRemote/1.4.0' "$PUB/" 2>/dev/null)
+ c=$(echo "$r" | cut -d' ' -f1)
+ t=$(echo "$r" | cut -d' ' -f2)
+ if [ "$c" = "200" ]; then
+ okc=$((okc+1))
+ total=$(echo "$total + $t" | bc)
+ fi
+ sleep 0.5
+done
+avg=$(echo "scale=2; $total / $okc" | bc 2>/dev/null || echo "?")
+[ "$okc" -ge 14 ] && ok "可用 $okc/15" || bad "可用 $okc/15"
+echo " 平均 ${avg} 秒(真实用户路径比这个快,见 Windows 侧实测)"
+
+echo
+echo "════════════════════════════════"
+echo " 通过 $pass 项,失败 $fail 项"
+[ "$fail" = "0" ] && echo " 全部通过 ✓" || echo " 有失败"
diff --git a/_build/final_acceptance3.sh b/_build/final_acceptance3.sh
new file mode 100644
index 0000000..ce63fdc
--- /dev/null
+++ b/_build/final_acceptance3.sh
@@ -0,0 +1,116 @@
+#!/bin/bash
+# 最终验收(修正版 2)
+# 上次的坑:grep -c 数的是**匹配行数**,HTML 压缩后 9 张卡在同一行 -> 返回 1。
+# 要数出现次数必须 grep -o | wc -l。
+set -u
+
+BASE=http://127.0.0.1:8080
+PUB=https://relay.xxmxxs.top
+KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
+pass=0; fail=0
+
+ok() { pass=$((pass+1)); echo " ✓ $1"; }
+bad() { fail=$((fail+1)); echo " ✗ $1"; }
+code() { curl -s -o /dev/null -w '%{http_code}' --max-time 25 "$@"; }
+
+# 数**出现次数**(不是行数)
+occ() { grep -o "$1" "$2" 2>/dev/null | wc -l | tr -d ' '; }
+# 数**包含该串的行数**(用于"有没有出现")
+has() { grep -q "$1" "$2" 2>/dev/null && echo 1 || echo 0; }
+
+echo "════════ 一、官网 ════════"
+c=$(code "$BASE/")
+[ "$c" = "200" ] && ok "首页 HTTP 200" || bad "首页 HTTP $c"
+curl -s --max-time 20 "$BASE/" -o /tmp/s.html
+size=$(wc -c < /tmp/s.html | tr -d ' ')
+[ "$size" -gt 10000 ] && ok "页面 $size 字节" || bad "页面只有 $size 字节"
+
+check() { [ "$(has "$2" /tmp/s.html)" = "1" ] && ok "$1" || bad "$1"; }
+check "吸顶导航" 'nav-in'
+check "Hero" 'hero'
+check "功能网格" 'id="features"'
+check "三步上手" 'id="how"'
+check "下载区" 'id="download"'
+check "FAQ" 'id="faq"'
+check "页脚" 'foot-in'
+check "版本徽章" 'badge'
+echo
+n=$(occ 'class="feat"' /tmp/s.html); [ "$n" = "9" ] && ok "9 张功能卡" || bad "功能卡 $n 张"
+n=$(occ '/dev/null | tail -1)
+echo "$proto" | grep -q http2 && ok "隧道走 HTTP/2(不是 QUIC)" || bad "隧道协议: $proto"
+conn=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \
+ | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1)
+[ "$conn" = "2" ] && ok "隧道连接数 2" || echo " · 隧道连接数 $conn(1 也能用,2 才有冗余)"
+[ "$(sysctl -n net.ipv4.tcp_mtu_probing)" = "1" ] && ok "MTU 探测开着" || bad "MTU 探测关了"
+[ "$(sysctl -n net.ipv4.tcp_slow_start_after_idle)" = "0" ] && ok "空闲后不重置窗口" || bad "slow_start 没关"
+[ "$(sysctl -n net.ipv4.tcp_fastopen)" = "3" ] && ok "TCP FastOpen 开着" || bad "FastOpen 没开"
+[ "$(occ argotunnel /etc/hosts)" = "0" ] && ok "无效的 hosts 覆盖已清" || bad "hosts 有残留"
+[ "$(sysctl -n net.core.rmem_max)" = "4194304" ] && ok "TCP 接收缓冲 4MB" || bad "接收缓冲不对"
+
+echo
+echo "════════ 五、功能 ════════"
+for api in /v1/health /favicon.ico; do
+ c=$(code "$BASE$api")
+ [ "$c" = "200" ] && ok "$api" || bad "$api HTTP $c"
+done
+curl -s --max-time 10 "$BASE/v1/health" | grep -o '"version": *"[^"]*"' | sed 's/^/ /'
+
+echo
+echo "════════ 六、公网可用性(设备侧发夹回路 = 最坏情况)════════"
+okc=0; total=0
+for i in $(seq 1 15); do
+ r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
+ -H 'User-Agent: LanRemote/1.4.0' "$PUB/" 2>/dev/null)
+ c=$(echo "$r" | cut -d' ' -f1)
+ t=$(echo "$r" | cut -d' ' -f2)
+ if [ "$c" = "200" ]; then
+ okc=$((okc+1))
+ total=$(echo "$total + $t" | bc)
+ fi
+ sleep 0.5
+done
+avg=$(echo "scale=2; $total / $okc" | bc 2>/dev/null || echo "?")
+[ "$okc" -ge 14 ] && ok "可用 $okc/15" || bad "可用 $okc/15"
+echo " 平均 ${avg} 秒(这是设备自己请求自己的最坏情况,"
+echo " 真实用户路径从 Windows 侧实测约 0.69 秒)"
+
+echo
+echo "════════════════════════════════"
+echo " 通过 $pass 项,失败 $fail 项"
+[ "$fail" = "0" ] && echo " 全部通过 ✓" || echo " 有失败"
diff --git a/_build/fix_admin_dark2.py b/_build/fix_admin_dark2.py
new file mode 100644
index 0000000..4389e52
--- /dev/null
+++ b/_build/fix_admin_dark2.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+r'''清掉管理页深色残留(修正版:CSS 在 f-string 里,大括号是双写的)。
+
+上一版只改成功了 4 处,因为我的匹配串用了单大括号 {{ }},
+而 relay.py 里那段 CSS 在 f-string 里,实际是 {{{{ }}}}。
+'''
+from __future__ import annotations
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+RELAY = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay" r"\relay.py")
+
+# 用「深色值 -> 浅色值」直接替换,不管外层大括号怎么写的。
+# 这样最稳,不会再被 f-string 的花括号坑到。
+COLOR_MAP = [
+ ("#262c36", "#f3f4f6"), # 按钮悬停底色
+ ("#484f58", "#d0d7de"), # 边框/圆点
+ ("#1b222c", "#f6f8fa"), # 表格行悬停
+ ("#1c2733", "#eff6ff"), # 选中行
+ ("#213042", "#dbeafe"), # 选中行悬停
+ ("#2d1214", "#fff5f5"), # 删除按钮底
+ ("#6e2b2b", "#ffc9c9"), # 删除按钮边
+ ("#ff7b72", "#c92a2a"), # 删除按钮字
+ ("#4a1a1e", "#ffe3e3"), # 删除悬停底
+ ("#ffa198", "#a61b1b"), # 删除悬停字
+ ("#4a95f8", "#1d4ed8"), # 主按钮悬停
+ ("#0b1220", "#f6f8fa"), # 地址框底
+ ("#1f6feb44", "#d0d7de"), # 地址框边
+ ("#0008", "#0002"), # 提示条阴影
+]
+
+# 深色主题才用的值,替换后不该再有
+DARK_LEFTOVERS = ["#0b1220", "#262c36", "#1b222c", "#1c2733", "#213042",
+ "#2d1214", "#4a1a1e", "#484f58", "#0d1117"]
+
+
+def main() -> int:
+ text = RELAY.read_text(encoding="utf-8")
+ counts = {}
+
+ for dark, light in COLOR_MAP:
+ n = text.count(dark)
+ if n:
+ text = text.replace(dark, light)
+ counts[dark] = (light, n)
+
+ if counts:
+ RELAY.write_text(text, encoding="utf-8", newline="\n")
+ print(" 换了这些颜色:")
+ for dark, (light, n) in counts.items():
+ print(" %s -> %s (%d 处)" % (dark, light, n))
+ else:
+ print(" 没有要改的")
+
+ try:
+ ast.parse(text)
+ print(" 语法 OK")
+ except SyntaxError as exc:
+ print(" [失败] 语法错误: 行 %s: %s" % (exc.lineno, exc.msg))
+ return 1
+
+ print()
+ print(" 残留深色值:")
+ left = [c for c in DARK_LEFTOVERS if c in text]
+ if left:
+ for c in left:
+ print(" %s:" % c)
+ for m in re.finditer(r".*" + re.escape(c) + r".*", text):
+ print(" %s" % m.group(0).strip()[:76])
+ else:
+ print(" 无 ✓")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/fix_admin_dark_leftovers.py b/_build/fix_admin_dark_leftovers.py
new file mode 100644
index 0000000..2c56ab8
--- /dev/null
+++ b/_build/fix_admin_dark_leftovers.py
@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+r'''清掉管理页剩下的深色主题残留。
+
+管理页整体已经改成白色了(--bg:#ffffff),
+但 CSS 里还有几处是给深色底配的,在白底上很扎眼:
+
+ .url{background:#0b1220} 深藏青的地址框
+ button:hover{background:#262c36} 深灰悬停
+ tbody tr:hover td{#1b222c} 深灰行悬停
+ tr.picked td{#1c2733} 深色选中行
+ button.del{background:#2d1214} 暗红删除按钮
+ .dot{background:#484f58} 深灰圆点
+ a.logout{border-bottom:#484f58} 深灰虚线下划线
+
+换成浅色系。
+'''
+from __future__ import annotations
+
+import ast
+import sys
+from pathlib import Path
+
+ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
+RELAY = ROOT / "relay" / "relay.py"
+
+# (旧, 新, 说明)
+FIXES = [
+ # 公网地址框:深藏青 -> 浅蓝
+ ("background:#0b1220;border:1px solid #1f6feb44",
+ "background:#f6f8fa;border:1px solid #d0d7de",
+ "地址框底色"),
+ ("color:#0969da;word-break:break-all;\n background:#f6f8fa",
+ "color:#0969da;word-break:break-all;\n background:#f6f8fa",
+ "(保持)"),
+ # 按钮悬停
+ ("button:hover{background:#262c36;border-color:#484f58}",
+ "button:hover{background:#f3f4f6;border-color:#d1d5db}",
+ "按钮悬停"),
+ ("button.primary:hover{background:#4a95f8;border-color:#4a95f8}",
+ "button.primary:hover{background:#1d4ed8;border-color:#1d4ed8}",
+ "主按钮悬停"),
+ # 删除按钮:暗红 -> 浅红
+ ("button.del{background:#2d1214;border-color:#6e2b2b;color:#ff7b72}",
+ "button.del{background:#fff5f5;border-color:#ffc9c9;color:#c92a2a}",
+ "删除按钮"),
+ ("button.del:hover{background:#4a1a1e;border-color:#a33;color:#ffa198}",
+ "button.del:hover{background:#ffe3e3;border-color:#ffa8a8;color:#a61b1b}",
+ "删除按钮悬停"),
+ # 表格行悬停
+ ("tbody tr:hover td{background:#1b222c}",
+ "tbody tr:hover td{background:#f6f8fa}",
+ "表格行悬停"),
+ # 选中行
+ ("tr.picked td{background:#1c2733}",
+ "tr.picked td{background:#eff6ff}",
+ "选中行"),
+ ("tr.picked:hover td{background:#213042}",
+ "tr.picked:hover td{background:#dbeafe}",
+ "选中行悬停"),
+ # 圆点
+ ("vertical-align:middle;background:#484f58}",
+ "vertical-align:middle;background:#d0d7de}",
+ "状态圆点"),
+ # 退出登录的下划线
+ ("border-bottom:1px dashed #484f58;transition:.12s}",
+ "border-bottom:1px dashed #d0d7de;transition:.12s}",
+ "退出登录下划线"),
+ # 提示条阴影(白底上要淡一点)
+ ("box-shadow:0 8px 24px #0008;opacity:0;",
+ "box-shadow:0 8px 24px #0002;opacity:0;",
+ "提示条阴影"),
+]
+
+
+def main() -> int:
+ text = RELAY.read_text(encoding="utf-8")
+ changed = []
+
+ for old, new, why in FIXES:
+ if old == new:
+ continue
+ if new in text:
+ continue
+ if old in text:
+ text = text.replace(old, new)
+ changed.append(why)
+
+ if changed:
+ RELAY.write_text(text, encoding="utf-8", newline="\n")
+ print(" 改了:")
+ for c in changed:
+ print(" " + c)
+ else:
+ print(" 没有要改的")
+
+ try:
+ ast.parse(text)
+ print(" 语法 OK")
+ except SyntaxError as exc:
+ print(" [失败] 语法错误: 行 %s: %s" % (exc.lineno, exc.msg))
+ return 1
+
+ # 复核:还有没有深色残留
+ print()
+ print(" 残留的深色值检查:")
+ DARK = ["#0b1220", "#262c36", "#1b222c", "#1c2733", "#213042",
+ "#2d1214", "#4a1a1e", "#484f58", "#2a313c", "#0d1117"]
+ left = [c for c in DARK if c in text]
+ if left:
+ print(" 还有: %s" % " ".join(left))
+ for c in left:
+ import re
+ for m in re.finditer(r".*" + re.escape(c) + r".*", text):
+ print(" %s" % m.group(0).strip()[:80])
+ else:
+ print(" 无 ✓")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/grab_admin.py b/_build/grab_admin.py
new file mode 100644
index 0000000..197a863
--- /dev/null
+++ b/_build/grab_admin.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+r'''抓管理页 HTML 到本地,供截图查看。
+管理页要登录,无头浏览器不方便带头,所以先用 curl 抓下来存本地。'''
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
+sys.path.insert(0, str(ROOT / "_build"))
+
+from devssh import run # noqa: E402
+
+
+def main() -> int:
+ _c, out, _e = run(
+ "KEY=$(cat /root/lanremote-relay/data/admin_key.txt); "
+ "curl -s --max-time 20 -H \"X-Admin: $KEY\" "
+ "http://127.0.0.1:8080/zhongzhuanzhan",
+ timeout=120)
+ if not out or " %s" % (len(out), target.name))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/measure_public.py b/_build/measure_public.py
new file mode 100644
index 0000000..42af858
--- /dev/null
+++ b/_build/measure_public.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+r'''从本机测公网延迟 —— 这是真实用户路径。
+
+为什么不在设备上测:
+ 设备上 curl 自己的公网域名,流量要出到 Cloudflare 边缘再经隧道
+ 绕回同一台设备,是"发夹回路",最坏情况。
+ 真实用户(手表/电脑)在别处,路径短得多。
+'''
+from __future__ import annotations
+
+import statistics
+import sys
+import time
+import urllib.error
+import urllib.request
+
+URL = "https://relay.xxmxxs.top/"
+HEALTH = "https://relay.xxmxxs.top/v1/health"
+HEADERS = {"User-Agent": "LanRemote/1.4.0"}
+
+
+def measure(url: str, rounds: int) -> list:
+ times = []
+ for i in range(rounds):
+ req = urllib.request.Request(url, headers=HEADERS)
+ start = time.perf_counter()
+ try:
+ with urllib.request.urlopen(req, timeout=25) as resp:
+ resp.read()
+ if resp.status == 200:
+ times.append(time.perf_counter() - start)
+ except (urllib.error.URLError, OSError, TimeoutError):
+ pass
+ time.sleep(0.4)
+ return times
+
+
+def report(label: str, times: list, rounds: int) -> None:
+ if not times:
+ print(" %s: 全部失败" % label)
+ return
+ print(" %s: 可用 %d/%d" % (label, len(times), rounds))
+ print(" 平均 %.0f ms 中位 %.0f ms 最快 %.0f ms 最慢 %.0f ms"
+ % (statistics.mean(times) * 1000,
+ statistics.median(times) * 1000,
+ min(times) * 1000,
+ max(times) * 1000))
+
+
+def main() -> int:
+ print(" 公网真实路径测试(30 次)")
+ print()
+ report("官网首页", measure(URL, 30), 30)
+ print()
+ report("健康接口", measure(HEALTH, 20), 20)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/measure_segments.py b/_build/measure_segments.py
new file mode 100644
index 0000000..6567dec
--- /dev/null
+++ b/_build/measure_segments.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+r'''确认延迟瓶颈到底在哪一段。
+
+对比三段:
+ 1. 局域网直连(设备本机 -> relay 本机) 预期几十毫秒
+ 2. 公网(本机 -> Cloudflare 边缘 -> 隧道 -> 设备)
+ 3. 纯 Cloudflare 边缘 RTT(本机 -> 随便一个 CF 站点)
+
+差值能告诉我们:
+ 2 - 1 = 隧道 + 公网往返的开销
+ 3 = 到边缘的裸 RTT
+ 2 - 3 - 1 = 隧道那一段的开销(设备到边缘)
+'''
+from __future__ import annotations
+
+import statistics
+import sys
+import time
+import urllib.request
+
+HEADERS = {"User-Agent": "LanRemote/1.4.0"}
+
+
+def timed(url: str, rounds: int = 15) -> list:
+ out = []
+ for _ in range(rounds):
+ try:
+ start = time.perf_counter()
+ with urllib.request.urlopen(
+ urllib.request.Request(url, headers=HEADERS),
+ timeout=25) as resp:
+ resp.read()
+ if resp.status == 200:
+ out.append((time.perf_counter() - start) * 1000)
+ except Exception: # noqa: BLE001
+ pass
+ time.sleep(0.3)
+ return out
+
+
+def show(label: str, values: list, rounds: int) -> float:
+ if not values:
+ print(" %-24s 全失败" % label)
+ return float("nan")
+ med = statistics.median(values)
+ print(" %-24s 可用 %2d/%d 中位 %7.0f ms 最快 %7.0f ms"
+ % (label, len(values), rounds, med, min(values)))
+ return med
+
+
+def main() -> int:
+ print(" 分段测延迟(各 15 次取中位数)")
+ print()
+
+ lan = show("1. 局域网直连", timed("http://192.168.2.156:8080/v1/health"), 15)
+ pub = show("2. 公网(经隧道)",
+ timed("https://relay.xxmxxs.top/v1/health"), 15)
+ edge = show("3. Cloudflare 边缘裸 RTT",
+ timed("https://cloudflare.com/cdn-cgi/trace"), 15)
+
+ print()
+ print(" 分析:")
+ if lan == lan and pub == pub:
+ print(" 局域网 %.0f ms -> 公网 %.0f ms" % (lan, pub))
+ print(" 公网比局域网多 %.0f ms" % (pub - lan))
+ if edge == edge and pub == pub:
+ print()
+ print(" 到 Cloudflare 边缘的裸 RTT: %.0f ms" % edge)
+ print(" 公网请求 %.0f ms,其中边缘往返占 %.0f ms(%.0f%%)"
+ % (pub, edge, edge / pub * 100))
+ print(" 剩下的 %.0f ms 是:边缘 -> 设备(隧道)那一段 + 服务端处理"
+ % (pub - edge))
+
+ print()
+ print(" 结论:")
+ print(" 如果「边缘 -> 设备」那段占比大,说明瓶颈在设备的 4G 上行链路,")
+ print(" Cloudflare 这一侧没得优化了 —— 只能靠换网络或换中转位置。")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/pin_edge_ips.sh b/_build/pin_edge_ips.sh
new file mode 100644
index 0000000..aa1afc3
--- /dev/null
+++ b/_build/pin_edge_ips.sh
@@ -0,0 +1,103 @@
+#!/bin/bash
+# 用实测最快的边缘 IP 固定隧道出口。
+#
+# 之前我判断"优选 IP 没用"是**错的** —— 结论下得太急。
+# 真正的价值不是降低延迟(落点仍是 lax,改不了),
+# 而是**避开会握手超时的 IP**:
+#
+# 日志里大量 dial tcp 198.41.192.167:7844: i/o timeout
+# Retrying connection in up to 32s
+# GeoDNS 随机给的 IP 里有一部分连不上,
+# cloudflared 就连上-掉线-重连反复折腾,
+# 表现就是延迟在 0.69s ~ 4.4s 之间乱跳。
+#
+# 实测(probe_cf_edge2.py)最快且稳定的:
+# 198.41.200.238 138ms
+# 198.41.192.214 143ms
+set -u
+
+echo "===== 1. 备份 ====="
+cp -f /etc/hosts "/root/hosts.bak.$(date +%s)"
+echo " 已备份 /etc/hosts"
+
+echo
+echo "===== 2. 先复测这两个 IP 还通不通 ====="
+python3 - <<'PY'
+import socket, time
+for ip in ("198.41.200.238", "198.41.192.214", "198.41.200.13", "198.41.192.37"):
+ s = socket.socket(); s.settimeout(3)
+ t = time.perf_counter()
+ try:
+ s.connect((ip, 7844))
+ print(" %-16s %.0f ms" % (ip, (time.perf_counter() - t) * 1000))
+ except OSError as e:
+ print(" %-16s 不通 (%s)" % (ip, e.__class__.__name__))
+ finally:
+ s.close()
+PY
+
+echo
+echo "===== 3. 写 /etc/hosts(固定两个最快的)====="
+sed -i '/argotunnel.com/d' /etc/hosts
+{
+ echo ""
+ echo "# ykxmz:固定 Cloudflare 隧道边缘 IP"
+ echo "# 目的不是降低延迟(落点仍是 lax),而是避开会握手超时的 IP。"
+ echo "# GeoDNS 随机发的 IP 里有些连不上,会导致隧道反复重连、延迟乱跳。"
+ echo "198.41.200.238 region1.v2.argotunnel.com"
+ echo "198.41.192.214 region2.v2.argotunnel.com"
+} >> /etc/hosts
+grep argotunnel /etc/hosts | sed 's/^/ /'
+
+echo
+echo "===== 4. 确认解析走的是我们指定的 ====="
+python3 - <<'PY'
+import socket
+for host in ("region1.v2.argotunnel.com", "region2.v2.argotunnel.com"):
+ infos = socket.getaddrinfo(host, 7844, socket.AF_INET)
+ ips = sorted({i[4][0] for i in infos})
+ print(" %s -> %s" % (host, ips))
+PY
+
+echo
+echo "===== 5. 重启隧道并观察 60 秒 ====="
+systemctl restart lanremote-tunnel
+: > /root/reconnect_watch.txt
+for i in $(seq 1 12); do
+ sleep 5
+ conn=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
+ | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
+ errs=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
+ | grep '^cloudflared_tunnel_request_errors' | awk '{print $2}')
+ echo " ${i}0s: 连接 $conn 错误 $errs"
+done
+
+echo
+echo "===== 6. 这 60 秒里有几次超时告警 ====="
+since=$(date -u -d '70 seconds ago' '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "")
+if [ -n "$since" ]; then
+ n=$(awk -v s="$since" '$0 >= s' /root/lanremote-relay/tunnel.log 2>/dev/null \
+ | grep -c 'i/o timeout')
+ echo " 超时告警 $n 次(改之前每分钟都有好几次)"
+else
+ echo " (算不出时间窗,跳过)"
+fi
+
+echo
+echo "===== 7. 当前落点 ====="
+grep -E 'Registered tunnel connection' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -3 \
+ | grep -oE 'ip=[0-9.]+ location=[a-z0-9]+ protocol=[a-z0-9]+' | sed 's/^/ /'
+
+echo
+echo "===== 8. 公网延迟(10 次)====="
+ok=0; total=0
+for i in $(seq 1 10); do
+ r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
+ -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health 2>/dev/null)
+ c=$(echo "$r" | cut -d' ' -f1)
+ t=$(echo "$r" | cut -d' ' -f2)
+ [ "$c" = "200" ] && { ok=$((ok+1)); total=$(echo "$total + $t" | bc); }
+ sleep 1
+done
+avg=$(echo "scale=2; $total / $ok" | bc 2>/dev/null || echo "?")
+echo " 可用 $ok/10 平均 ${avg} 秒"
diff --git a/_build/revert_edge_pin.sh b/_build/revert_edge_pin.sh
new file mode 100644
index 0000000..5881f7b
--- /dev/null
+++ b/_build/revert_edge_pin.sh
@@ -0,0 +1,77 @@
+#!/bin/bash
+# 撤掉边缘 IP 固定。
+#
+# 教训(一天内自己推翻自己两次):
+# 第一次:以为优选 IP 能降延迟 -> 错。落点还是 lax,改不了路径。
+# 第二次:以为优选 IP 能避开超时 -> 半小时内对,但对不久。
+# 当时实测 138ms 的两个 IP,30 分钟后再测全都不通了。
+#
+# 根因:Cloudflare anycast 的可达性受运营商路由影响,
+# 会随时间变化。**固定 IP 等于钉死到一个可能失效的地址上**,
+# 比让 GeoDNS 动态解析更糟 —— 后者至少每次重连会重新挑。
+#
+# 结论:不固定。靠 cloudflared 自己的重试 + --ha-connections 2 冗余。
+set -u
+
+echo "===== 1. 清掉 hosts 固定 ====="
+before=$(grep -c argotunnel /etc/hosts 2>/dev/null || echo 0)
+sed -i '/argotunnel.com/d' /etc/hosts
+after=$(grep -c argotunnel /etc/hosts 2>/dev/null || echo 0)
+echo " argotunnel 条目: $before -> $after"
+
+echo
+echo "===== 2. 确认解析回到 GeoDNS ====="
+python3 - <<'PY'
+import socket
+for host in ("region1.v2.argotunnel.com", "region2.v2.argotunnel.com"):
+ try:
+ infos = socket.getaddrinfo(host, 7844, socket.AF_INET)
+ ips = sorted({i[4][0] for i in infos})
+ print(" %s -> %d 个地址" % (host, len(ips)))
+ print(" %s" % " ".join(ips[:5]))
+ except OSError as e:
+ print(" %s 解析失败 %s" % (host, e))
+PY
+
+echo
+echo "===== 3. 重启隧道,观察 90 秒连接稳定性 ====="
+systemctl restart lanremote-tunnel
+stable=1
+for i in $(seq 1 9); do
+ sleep 10
+ conn=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
+ | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')
+ errs=$(curl -s --max-time 3 http://127.0.0.1:20241/metrics 2>/dev/null \
+ | grep '^cloudflared_tunnel_request_errors' | awk '{print $2}')
+ echo " ${i}0s: 连接 $conn 请求错误 $errs"
+ [ "$i" -ge 6 ] && [ "$conn" != "2" ] && stable=0
+done
+[ "$stable" = "1" ] && echo " ✓ 后段稳定在 2 条连接" || echo " · 还在收敛"
+
+echo
+echo "===== 4. 这段时间的超时告警数 ====="
+since=$(date -u -d '100 seconds ago' '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "")
+if [ -n "$since" ]; then
+ n=$(awk -v s="$since" '$0 >= s' /root/lanremote-relay/tunnel.log 2>/dev/null | grep -c 'i/o timeout')
+ echo " $n 次"
+fi
+
+echo
+echo "===== 5. 当前落点(应为 2 条)====="
+grep -E 'Registered tunnel connection' /root/lanremote-relay/tunnel.log 2>/dev/null | tail -2 \
+ | grep -oE 'ip=[0-9.]+ location=[a-z0-9]+ protocol=[a-z0-9]+' | sed 's/^/ /'
+
+echo
+echo "===== 6. 公网延迟(15 次,设备侧最坏情况)====="
+ok=0; total=0
+for i in $(seq 1 15); do
+ r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 25 \
+ -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health 2>/dev/null)
+ c=$(echo "$r" | cut -d' ' -f1)
+ t=$(echo "$r" | cut -d' ' -f2)
+ [ "$c" = "200" ] && { ok=$((ok+1)); total=$(echo "$total + $t" | bc); printf " %2d: %s 秒\n" "$i" "$t"; } \
+ || printf " %2d: 失败\n" "$i"
+ sleep 1
+done
+avg=$(echo "scale=2; $total / $ok" | bc 2>/dev/null || echo "?")
+echo " 可用 $ok/15 平均 ${avg} 秒"
diff --git a/_build/test_lan_e2e.py b/_build/test_lan_e2e.py
new file mode 100644
index 0000000..d229f91
--- /dev/null
+++ b/_build/test_lan_e2e.py
@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+r'''端到端验证局域网直连路径。
+
+结论已经很清楚了:
+ 局域网 118 ms vs 公网 1215 ms —— 快 10 倍
+瓶颈是这条网络到 Cloudflare 的 4G 上行(裸 RTT 就 1.4 秒),
+Cloudflare 那一侧没得优化。
+
+所以真正要保证的是**局域网这条路能用**。
+这个脚本起一个真实被控端,走 192.168.2.x 直连,量一下握手耗时。
+'''
+from __future__ import annotations
+
+import json
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
+PORT = 8875
+CODE = "246810"
+
+
+def talk(payload: dict, timeout: float = 12.0) -> dict:
+ sock = socket.create_connection(("127.0.0.1", PORT), timeout=timeout)
+ sock.settimeout(timeout)
+ sock.sendall((json.dumps(payload, ensure_ascii=False) + "\n").encode())
+ raw = b""
+ while b"\n" not in raw:
+ chunk = sock.recv(65536)
+ if not chunk:
+ break
+ raw += chunk
+ sock.close()
+ return json.loads(raw.split(b"\n")[0].decode("utf-8"))
+
+
+def lan_ip() -> str:
+ try:
+ probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ probe.connect(("8.8.8.8", 80))
+ addr = probe.getsockname()[0]
+ probe.close()
+ return addr
+ except OSError:
+ return "192.168.2.64"
+
+
+def main() -> int:
+ env = dict(__import__("os").environ)
+ env["YKXMZ_DATA"] = str(ROOT / "_build" / "_lantest_data")
+ Path(env["YKXMZ_DATA"]).mkdir(parents=True, exist_ok=True)
+
+ print(" 启动被控端(端口 %d,配对码 %s)" % (PORT, CODE))
+ proc = subprocess.Popen(
+ [sys.executable, str(ROOT / "windows" / "run_agent.py"),
+ "--port", str(PORT), "--code", CODE, "--silent"],
+ cwd=str(ROOT), env=env,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+ passed = failed = 0
+
+ def check(name, ok, detail=""):
+ nonlocal passed, failed
+ if ok:
+ passed += 1
+ print(" [PASS] " + name)
+ else:
+ failed += 1
+ print(" [FAIL] " + name + " " + str(detail))
+
+ try:
+ time.sleep(7)
+ ip = lan_ip()
+ print(" 本机局域网地址: %s" % ip)
+ print()
+
+ # 1. 本机回环
+ print(" 一、回环路径")
+ t = time.perf_counter()
+ reply = talk({"cmd": "pair", "deviceId": "lantest",
+ "deviceName": "测试手表", "code": CODE})
+ loop_ms = (time.perf_counter() - t) * 1000
+ check("回环配对成功", reply.get("ok") is True, str(reply)[:90])
+ token = reply.get("token", "")
+ print(" 握手 %.0f ms" % loop_ms)
+
+ # 2. 局域网地址(真实用户路径)
+ print()
+ print(" 二、局域网地址 %s(手表实际走这条)" % ip)
+ sock = socket.create_connection((ip, PORT), timeout=10)
+ sock.settimeout(10)
+ t = time.perf_counter()
+ sock.sendall((json.dumps({"cmd": "auth", "deviceId": "lantest",
+ "token": token}, ensure_ascii=False)
+ + "\n").encode())
+ raw = b""
+ while b"\n" not in raw:
+ raw += sock.recv(65536)
+ lan_ms = (time.perf_counter() - t) * 1000
+ auth = json.loads(raw.split(b"\n")[0].decode("utf-8"))
+ check("局域网认证成功", auth.get("ok") is True, str(auth)[:90])
+ print(" 认证 %.0f ms" % lan_ms)
+
+ # 3. 功能列表
+ t = time.perf_counter()
+ sock.sendall((json.dumps({"cmd": "feature_list"}, ensure_ascii=False)
+ + "\n").encode())
+ raw = b""
+ while b"\n" not in raw:
+ raw += sock.recv(262144)
+ feat = json.loads(raw.split(b"\n")[0].decode("utf-8"))
+ feat_ms = (time.perf_counter() - t) * 1000
+ features = feat.get("features", [])
+ check("功能列表拿到 %d 项" % len(features), len(features) > 100)
+ print(" 功能列表 %.0f ms" % feat_ms)
+ sock.close()
+
+ print()
+ print(" 三、结论")
+ print(" 局域网往返 %.0f ms vs 公网 1215 ms" % lan_ms)
+ print(" 局域网快 %.1f 倍" % (1215 / max(lan_ms, 1)))
+ check("局域网明显快于公网", lan_ms < 300, "%.0f ms" % lan_ms)
+
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=8)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+
+ print()
+ print("=" * 52)
+ print(" 通过 %d 项,失败 %d 项" % (passed, failed))
+ return 0 if failed == 0 else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/test_lan_ips.py b/_build/test_lan_ips.py
new file mode 100644
index 0000000..b2b7b96
--- /dev/null
+++ b/_build/test_lan_ips.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+r'''测被控端实际报出去的 lanIps 列表。'''
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
+sys.path.insert(0, str(ROOT / "windows"))
+
+
+def main() -> int:
+ from lanremote.server import lan_ips, local_ip, _ip_rank
+
+ ips = lan_ips()
+ print(" lan_ips() -> %s" % (ips,))
+ for ip in ips:
+ print(" %-18s rank=%d" % (ip, _ip_rank(ip)))
+ print()
+ print(" local_ip() -> %s" % local_ip())
+
+ print()
+ print(" 手表那边的判断(sameSubnet 比前 3 段):")
+ if ips:
+ first = ips[0]
+ print(" 被控端报的第一个: %s" % first)
+ print(" 如果手表在 192.168.2.x -> 匹配 ✓")
+ print(" 如果手表在别的网段 -> 不匹配,走中转")
+
+ print()
+ print(" 结论:")
+ real = [ip for ip in ips if ip.startswith(("192.168.", "10."))]
+ if real:
+ print(" ✓ 报了真实局域网地址 %s,手表同网段时能直连" % real[0])
+ print(" 所以「电脑不在同一网络」的提示,意味着")
+ print(" 手表当时**真的不在** 192.168.2.x —— 比如连了 5G 频段")
+ print(" 的另一个路由,或者开着移动数据。")
+ else:
+ print(" ✗ 没有真实局域网地址,手表永远匹配不上")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/write_commit_msg_a.py b/_build/write_commit_msg_a.py
new file mode 100644
index 0000000..3981517
--- /dev/null
+++ b/_build/write_commit_msg_a.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+r'''写提交消息文件(纯 UTF-8 无 BOM,避开 PowerShell 的坑)。'''
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+MSG = """阶段3+4+5:管理页白色化 + 局域网诊断 + 全量验收
+
+阶段3 管理页
+- 清掉全部深色主题残留(地址框 #0b1220、按钮悬停 #262c36、
+ 删除按钮 #2d1214 等 11 处),现在 0 处深色
+- 接口全部正常:health / downloads / netcheck
+
+阶段4 局域网诊断
+- 查清楚"电脑不在同一网络"是怎么回事:被控端是**对的**
+ (lan_ips() 报 ['192.168.2.64', '169.254.83.107'],真实地址排第一),
+ 是手表当时真不在 192.168.2.x —— 可能连了 5G 频段的另一个路由器,或开着移动数据
+- 所以改了手表的提示:不再只说"不在同一网络",
+ 而是把**双方地址都打出来**,用户一眼能看出是网段不同还是连错 WiFi
+- 端到端验证局域网路径:认证 1 ms,功能列表 207 项
+
+阶段1 结论修正(一天内推翻自己两次,如实记录)
+- 第一次判断"优选 IP 无用":错。半小时内确实能避开超时 IP
+- 第二次固定优选 IP:也错。那些实测 138ms 的 IP 半小时后全不通了
+ —— Cloudflare anycast 可达性随运营商路由变化,固定 IP 等于钉死到会失效的地址
+- 最终:不固定,靠 GeoDNS + cloudflared 重试 + ha-connections 2 冗余
+- 撤销后立即变好:连接稳定 2 条、超时告警 0 次、设备侧延迟 4.40s -> 1.66s
+- **真正的瓶颈**:分段实测发现本机到 Cloudflare 的**裸 RTT 就 1439 ms**,
+ 比经隧道的请求(1215 ms)还慢。说明瓶颈是这条网络的 4G 上行本身,
+ 不是设备的隧道。公网侧已无优化空间。
+- 局域网 118 ms vs 公网 1215 ms,快 10 倍 —— 这才是该走的路
+
+阶段5 验收
+- 全量回归 29 项全通过
+- 设备侧验收 38 项全通过
+- 成品:setup exe 14.37 MB / server zip / watch apk,已同步到设备下载区
+"""
+
+
+def main() -> int:
+ target = Path(__file__).resolve().parent / "_commitmsg_a.txt"
+ target.write_bytes(MSG.encode("utf-8"))
+ print(" 写好 %s(%d 字节,无 BOM)" % (target.name, target.stat().st_size))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$100.class b/_tests/out_app/com/lanremote/watch/MainActivity$100.class
index 9d16215..43d0e53 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$100.class and b/_tests/out_app/com/lanremote/watch/MainActivity$100.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$101$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$101$1.class
index ef7da16..7554c8e 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$101$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$101$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$101.class b/_tests/out_app/com/lanremote/watch/MainActivity$101.class
index 19b43f3..7a26ecf 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$101.class and b/_tests/out_app/com/lanremote/watch/MainActivity$101.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$102$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$102$1.class
index 9f8a9b8..55f4d63 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$102$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$102$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$102.class b/_tests/out_app/com/lanremote/watch/MainActivity$102.class
index e3f5a5b..b693210 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$102.class and b/_tests/out_app/com/lanremote/watch/MainActivity$102.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$103.class b/_tests/out_app/com/lanremote/watch/MainActivity$103.class
index a37b2f0..aa6d28e 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$103.class and b/_tests/out_app/com/lanremote/watch/MainActivity$103.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$104.class b/_tests/out_app/com/lanremote/watch/MainActivity$104.class
index b93c8a3..3095b3e 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$104.class and b/_tests/out_app/com/lanremote/watch/MainActivity$104.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$105$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$105$1.class
index 62080df..04e130a 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$105$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$105$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$105.class b/_tests/out_app/com/lanremote/watch/MainActivity$105.class
index 6d510aa..cfb7b80 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$105.class and b/_tests/out_app/com/lanremote/watch/MainActivity$105.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$106$1$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$106$1$1.class
index cb6dd96..7b90509 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$106$1$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$106$1$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$106$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$106$1.class
index 049cb11..23d129e 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$106$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$106$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$106.class b/_tests/out_app/com/lanremote/watch/MainActivity$106.class
index cc01e82..0f212de 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$106.class and b/_tests/out_app/com/lanremote/watch/MainActivity$106.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$107$1$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$107$1$1.class
index 5a8e91e..37a8c26 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$107$1$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$107$1$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$107$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$107$1.class
index 31985fb..558b38f 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$107$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$107$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$107.class b/_tests/out_app/com/lanremote/watch/MainActivity$107.class
index d3dc29b..62565c8 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$107.class and b/_tests/out_app/com/lanremote/watch/MainActivity$107.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$108.class b/_tests/out_app/com/lanremote/watch/MainActivity$108.class
index c968dac..0938cb0 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$108.class and b/_tests/out_app/com/lanremote/watch/MainActivity$108.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$109.class b/_tests/out_app/com/lanremote/watch/MainActivity$109.class
index d93b18d..f4ba85f 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$109.class and b/_tests/out_app/com/lanremote/watch/MainActivity$109.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$110.class b/_tests/out_app/com/lanremote/watch/MainActivity$110.class
index 213c1f8..cb5481c 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$110.class and b/_tests/out_app/com/lanremote/watch/MainActivity$110.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$111$1$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$111$1$1.class
index cb2da19..8c43673 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$111$1$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$111$1$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$111$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$111$1.class
index cc025fa..f9e8c35 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$111$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$111$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$111.class b/_tests/out_app/com/lanremote/watch/MainActivity$111.class
index 3254fb4..abeae8f 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$111.class and b/_tests/out_app/com/lanremote/watch/MainActivity$111.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class
index 2397c63..686542c 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$112.class b/_tests/out_app/com/lanremote/watch/MainActivity$112.class
index 4436b64..82ed9d5 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$112.class and b/_tests/out_app/com/lanremote/watch/MainActivity$112.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$113.class b/_tests/out_app/com/lanremote/watch/MainActivity$113.class
index c60db35..fecafd2 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$113.class and b/_tests/out_app/com/lanremote/watch/MainActivity$113.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$114.class b/_tests/out_app/com/lanremote/watch/MainActivity$114.class
index caa13f8..f5b8860 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$114.class and b/_tests/out_app/com/lanremote/watch/MainActivity$114.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$115$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$115$1.class
index cc506d6..9309105 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$115$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$115$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$115.class b/_tests/out_app/com/lanremote/watch/MainActivity$115.class
index 2c6fdc0..771f4cd 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$115.class and b/_tests/out_app/com/lanremote/watch/MainActivity$115.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$116$1$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$116$1$1.class
index bed390c..d40fa9b 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$116$1$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$116$1$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$116$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$116$1.class
index 098050b..3b894a9 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$116$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$116$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$116.class b/_tests/out_app/com/lanremote/watch/MainActivity$116.class
index 63067a6..1fee8d5 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$116.class and b/_tests/out_app/com/lanremote/watch/MainActivity$116.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$117$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$117$1.class
index efbbab8..6659c13 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$117$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$117$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$117.class b/_tests/out_app/com/lanremote/watch/MainActivity$117.class
index bcd3105..6811eba 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$117.class and b/_tests/out_app/com/lanremote/watch/MainActivity$117.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$118.class b/_tests/out_app/com/lanremote/watch/MainActivity$118.class
index 27950ce..bdd0212 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$118.class and b/_tests/out_app/com/lanremote/watch/MainActivity$118.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$119.class b/_tests/out_app/com/lanremote/watch/MainActivity$119.class
index ccad8ac..efd80a1 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$119.class and b/_tests/out_app/com/lanremote/watch/MainActivity$119.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$120.class b/_tests/out_app/com/lanremote/watch/MainActivity$120.class
index 1a8b970..f9d66e4 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$120.class and b/_tests/out_app/com/lanremote/watch/MainActivity$120.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$121.class b/_tests/out_app/com/lanremote/watch/MainActivity$121.class
index 93d9f31..16b2d7d 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$121.class and b/_tests/out_app/com/lanremote/watch/MainActivity$121.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$122$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$122$1.class
index a99efdf..edf1273 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$122$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$122$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$122.class b/_tests/out_app/com/lanremote/watch/MainActivity$122.class
index 42353c1..a0eb3d8 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$122.class and b/_tests/out_app/com/lanremote/watch/MainActivity$122.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$123.class b/_tests/out_app/com/lanremote/watch/MainActivity$123.class
index 55e9150..d334155 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$123.class and b/_tests/out_app/com/lanremote/watch/MainActivity$123.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$124.class b/_tests/out_app/com/lanremote/watch/MainActivity$124.class
index 9973705..d751028 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$124.class and b/_tests/out_app/com/lanremote/watch/MainActivity$124.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$125.class b/_tests/out_app/com/lanremote/watch/MainActivity$125.class
index 01e617c..5b574a9 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$125.class and b/_tests/out_app/com/lanremote/watch/MainActivity$125.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$126.class b/_tests/out_app/com/lanremote/watch/MainActivity$126.class
index 12977d8..93c3139 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$126.class and b/_tests/out_app/com/lanremote/watch/MainActivity$126.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$127$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$127$1.class
index 7f9a510..0487185 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$127$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$127$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$127$2.class b/_tests/out_app/com/lanremote/watch/MainActivity$127$2.class
index d337685..5494560 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$127$2.class and b/_tests/out_app/com/lanremote/watch/MainActivity$127$2.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$127.class b/_tests/out_app/com/lanremote/watch/MainActivity$127.class
index d724f4e..7b8d566 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$127.class and b/_tests/out_app/com/lanremote/watch/MainActivity$127.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$128.class b/_tests/out_app/com/lanremote/watch/MainActivity$128.class
index 3beb3fb..5d854ea 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$128.class and b/_tests/out_app/com/lanremote/watch/MainActivity$128.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$129.class b/_tests/out_app/com/lanremote/watch/MainActivity$129.class
index 42c4eb3..958533d 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$129.class and b/_tests/out_app/com/lanremote/watch/MainActivity$129.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$94$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$94$1.class
index f27a6ad..14097f2 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$94$1.class and b/_tests/out_app/com/lanremote/watch/MainActivity$94$1.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$94.class b/_tests/out_app/com/lanremote/watch/MainActivity$94.class
index e58f5e7..d010e17 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$94.class and b/_tests/out_app/com/lanremote/watch/MainActivity$94.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$95.class b/_tests/out_app/com/lanremote/watch/MainActivity$95.class
index 0c0a578..8dc5a4c 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$95.class and b/_tests/out_app/com/lanremote/watch/MainActivity$95.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$96.class b/_tests/out_app/com/lanremote/watch/MainActivity$96.class
index f687ad8..e4f4da1 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$96.class and b/_tests/out_app/com/lanremote/watch/MainActivity$96.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$97.class b/_tests/out_app/com/lanremote/watch/MainActivity$97.class
index 9eec8db..9b0e28b 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$97.class and b/_tests/out_app/com/lanremote/watch/MainActivity$97.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$98.class b/_tests/out_app/com/lanremote/watch/MainActivity$98.class
index 80bb030..335bd45 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$98.class and b/_tests/out_app/com/lanremote/watch/MainActivity$98.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$99.class b/_tests/out_app/com/lanremote/watch/MainActivity$99.class
index ca6983b..ad946f6 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity$99.class and b/_tests/out_app/com/lanremote/watch/MainActivity$99.class differ
diff --git a/_tests/out_app/com/lanremote/watch/MainActivity.class b/_tests/out_app/com/lanremote/watch/MainActivity.class
index f8a5990..7d26a69 100644
Binary files a/_tests/out_app/com/lanremote/watch/MainActivity.class and b/_tests/out_app/com/lanremote/watch/MainActivity.class differ
diff --git a/_tests/shots/1-屏幕中间.png b/_tests/shots/1-屏幕中间.png
index 683dd74..6471835 100644
Binary files a/_tests/shots/1-屏幕中间.png and b/_tests/shots/1-屏幕中间.png differ
diff --git a/_tests/shots/3-自定义640x400.png b/_tests/shots/3-自定义640x400.png
index 4e38939..d0daadc 100644
Binary files a/_tests/shots/3-自定义640x400.png and b/_tests/shots/3-自定义640x400.png differ
diff --git a/android/src/com/lanremote/watch/MainActivity.java b/android/src/com/lanremote/watch/MainActivity.java
index 64e7d22..9c5faf1 100644
--- a/android/src/com/lanremote/watch/MainActivity.java
+++ b/android/src/com/lanremote/watch/MainActivity.java
@@ -2552,7 +2552,19 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
}
}
} else {
- appendLog("电脑不在同一网络,继续用中转");
+ // 不在同一网段 -> 继续走中转。
+ // 光说"不在同一网络"用户没法自查,所以把
+ // 双方地址都打出来:一眼就能看出是网段不同、
+ // 还是手表连错了 WiFi。
+ java.util.List theirs = client.relayLanIps();
+ java.util.List mine = localIpv4List();
+ appendLog("电脑在 " + (theirs == null || theirs.isEmpty()
+ ? "(没报地址)" : theirs.get(0))
+ + ",手表在 " + (mine.isEmpty()
+ ? "(读不到网卡)" : mine.get(0))
+ + " —— 不在同一网段,走中转");
+ appendLog("想让手表直连:确认两边连的是同一个"
+ + " WiFi(注意别连成 5G 频段的另一个名字)");
}
}
} else {
diff --git a/relay/relay.py b/relay/relay.py
index 1fec15f..65a8d67 100644
--- a/relay/relay.py
+++ b/relay/relay.py
@@ -1511,7 +1511,7 @@ class Handler(BaseHTTPRequestHandler):
line-height:1.5}}
.meta b{{color:var(--dim);font-weight:500}}
.meta a.logout{{color:var(--dim);text-decoration:none;
- border-bottom:1px dashed #484f58;transition:.12s}}
+ border-bottom:1px dashed #d0d7de;transition:.12s}}
.meta a.logout:hover{{color:var(--accent);border-bottom-color:var(--accent)}}
/* ---------- 通用卡片 ---------- */
@@ -1524,7 +1524,7 @@ class Handler(BaseHTTPRequestHandler):
.urlrow{{display:flex;align-items:center;gap:10px;flex-wrap:wrap}}
.url{{flex:1;min-width:220px;font:600 15px/1.5 ui-monospace,Consolas,
'Courier New',monospace;color:#0969da;word-break:break-all;
- background:#0b1220;border:1px solid #1f6feb44;border-radius:8px;
+ background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;
padding:10px 12px}}
.url.empty{{color:var(--muted);font-weight:400;font-style:normal}}
@@ -1540,7 +1540,7 @@ class Handler(BaseHTTPRequestHandler):
background:var(--accent);opacity:.65}}
.stat.ok::after{{background:var(--ok)}}
.stat.warn::after{{background:var(--warn)}}
- .stat.mute::after{{background:#484f58}}
+ .stat.mute::after{{background:#d0d7de}}
/* ---------- 工具条 ---------- */
.toolbar{{display:flex;align-items:center;gap:10px;flex-wrap:wrap;
@@ -1552,14 +1552,14 @@ class Handler(BaseHTTPRequestHandler):
button{{font-family:inherit;font-size:13px;cursor:pointer;
border-radius:8px;padding:7px 14px;border:1px solid var(--border);
background:var(--panel2);color:var(--fg);transition:.12s}}
- button:hover{{background:#262c36;border-color:#484f58}}
+ button:hover{{background:#f3f4f6;border-color:#d0d7de}}
button:active{{transform:translateY(1px)}}
button:disabled{{opacity:.4;cursor:not-allowed;transform:none}}
button.primary{{background:var(--accent);border-color:var(--accent);
color:#fff;font-weight:600}}
- button.primary:hover{{background:#4a95f8;border-color:#4a95f8}}
- button.del{{background:#2d1214;border-color:#6e2b2b;color:#ff7b72}}
- button.del:hover{{background:#4a1a1e;border-color:#a33;color:#ffa198}}
+ button.primary:hover{{background:#1d4ed8;border-color:#1d4ed8}}
+ button.del{{background:#fff5f5;border-color:#ffc9c9;color:#c92a2a}}
+ button.del:hover{{background:#ffe3e3;border-color:#a33;color:#a61b1b}}
button.del:disabled{{opacity:.4}}
button.small{{padding:5px 11px;font-size:12px}}
@@ -1574,7 +1574,7 @@ class Handler(BaseHTTPRequestHandler):
th{{background:var(--panel2);color:var(--dim);font-weight:500;
font-size:12px;white-space:nowrap}}
tbody tr{{transition:background .1s}}
- tbody tr:hover td{{background:#1b222c}}
+ tbody tr:hover td{{background:#f6f8fa}}
tbody tr:last-child td{{border-bottom:none}}
td.name{{font-weight:500}}
td.num{{font-family:ui-monospace,Consolas,monospace;color:var(--dim);
@@ -1589,7 +1589,7 @@ class Handler(BaseHTTPRequestHandler):
letter-spacing:1.5px;font-family:ui-monospace,Consolas,monospace;
font-size:13px;color:#0969da}}
.dot{{display:inline-block;width:7px;height:7px;border-radius:50%;
- margin-right:7px;vertical-align:middle;background:#484f58}}
+ margin-right:7px;vertical-align:middle;background:#d0d7de}}
.dot.on{{background:var(--ok);box-shadow:0 0 0 3px #1a7f3722}}
.badge{{font-size:11px;padding:2px 8px;border-radius:20px;
border:1px solid transparent;white-space:nowrap}}
@@ -1598,13 +1598,13 @@ class Handler(BaseHTTPRequestHandler):
.pickcell{{width:38px;text-align:center;padding-right:0}}
input[type=checkbox]{{width:15px;height:15px;cursor:pointer;
accent-color:var(--accent);vertical-align:middle}}
- tr.picked td{{background:#1c2733}}
- tr.picked:hover td{{background:#213042}}
+ tr.picked td{{background:#eff6ff}}
+ tr.picked:hover td{{background:#dbeafe}}
/* ---------- 提示条 ---------- */
#toast{{position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);
background:#1f6feb;color:#fff;padding:11px 20px;border-radius:10px;
- font-size:13px;box-shadow:0 8px 24px #0008;opacity:0;
+ font-size:13px;box-shadow:0 8px 24px #0002;opacity:0;
transition:.22s;pointer-events:none;max-width:88vw;text-align:center}}
#toast.show{{opacity:1;transform:translate(-50%,0)}}
#toast.err{{background:#b62324}}