diff --git a/_build/_missing_tail.py b/_build/_missing_tail.py new file mode 100644 index 0000000..50cd0af --- /dev/null +++ b/_build/_missing_tail.py @@ -0,0 +1,60 @@ +def _esc(text) -> str: + return (str(text).replace("&", "&").replace("<", "<") + .replace(">", ">").replace('"', """)) + + +def _kb(value: int) -> str: + if value < 1024: + return f"{value} B" + if value < 1024 * 1024: + return f"{value / 1024:.1f} KB" + return f"{value / 1024 / 1024:.1f} MB" + + +def _uptime(seconds: int) -> str: + days, rest = divmod(int(seconds), 86400) + hours, rest = divmod(rest, 3600) + minutes = rest // 60 + if days: + return f"{days} 天 {hours} 小时" + if hours: + return f"{hours} 小时 {minutes} 分" + return f"{minutes} 分" + + +# ====================================================================== +# 启动 +# ====================================================================== + +class Server(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + request_queue_size = 64 + + +def main() -> int: + DATA_DIR.mkdir(parents=True, exist_ok=True) + try: + server = Server((BIND, PORT), Handler) + except OSError as exc: + print(f"监听 {BIND}:{PORT} 失败:{exc}") + return 1 + + log(f"中转服务器 v{VERSION} 启动,监听 {BIND}:{PORT}") + log(f"长轮询 {POLL_SECONDS:.0f} 秒 · 队列上限 " + f"{MAX_QUEUE_BYTES // 1024} KB · 数据目录 {DATA_DIR}") + key = admin_key() + log(f"管理页:http://<本机>:{PORT}/?key={key}") + log(f"(钥匙也在 {DATA_DIR / 'admin_key.txt'})") + + try: + server.serve_forever() + except KeyboardInterrupt: + log("收到中断,退出") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/_shotdata2/settings.json b/_build/_shotdata2/settings.json new file mode 100644 index 0000000..0ba41d1 --- /dev/null +++ b/_build/_shotdata2/settings.json @@ -0,0 +1,26 @@ +{ + "port": 8898, + "server_name": "DESKTOP-RN5I625", + "allow_run": true, + "allow_power": true, + "allow_screen": true, + "start_minimized": false, + "allow_new_pairing": true, + "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": "242524", + "pairing_created": 1791335816.802805 +} \ No newline at end of file diff --git a/_build/_shots_data/settings.json b/_build/_shots_data/settings.json new file mode 100644 index 0000000..6a3cee2 --- /dev/null +++ b/_build/_shots_data/settings.json @@ -0,0 +1,26 @@ +{ + "port": 8898, + "server_name": "DESKTOP-RN5I625", + "allow_run": true, + "allow_power": true, + "allow_screen": true, + "start_minimized": false, + "allow_new_pairing": true, + "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": "598318", + "pairing_created": 1791335863.3186042 +} \ No newline at end of file diff --git a/_build/admin_new.html b/_build/admin_new.html new file mode 100644 index 0000000..a930dd2 --- /dev/null +++ b/_build/admin_new.html @@ -0,0 +1,463 @@ + + + + + +ykxmz 中转站 + + + +
+ +
+ ykxmz 中转站 + 跨网络中转 · 管理后台 +
+ 运行中 +
+
版本 1.7.1 · 已运行 20 秒 · 内存 22 MB
+
累计请求 20 · 退出登录
+
+
+ +
+ +
+

公网地址 —— 手表「中转设置」里填这个

+
+
https://relay.xxmxxs.top
+ +
+
手表和电脑不在同一个网络时,两边都通过这个地址中转。
+
+ +
+
0
登记设备
+
0
在线
+
0
手表会话
+
131
配对码占用
+
+ +
+

设备

+
+ + + +
+
+ +
+ + + + + + + + + +
设备配对码状态空闲待发 表/机流量 入/出
🖥
还没有电脑连上来

在教室那台电脑上双击 ykxmz.exe 设好 6 位配对码,手表上输这 6 位数字就能连上。

+
+ +
+
+

网络体检

+
看看这台机器是走局域网还是走隧道。
+
还没测过。
+
+
+
+

下载区

+
官网下载按钮指向这里的文件。
+
ykxmz-watch-1.7.1.apk197 KB
ykxmz-server-1.7.1.zip198 KB
ykxmz-setup-1.7.1.exe14.4 MB
+
+
+ +
+ 隐私说明 —— 数据来自 /v1/health,只显示配对码和流量数字, + 看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。 +
+
+ +
+ + \ No newline at end of file diff --git a/_build/admin_preview.html b/_build/admin_preview.html index d2728ce..6ece683 100644 --- a/_build/admin_preview.html +++ b/_build/admin_preview.html @@ -1,263 +1,267 @@ - - - - -ykxmz 中转服务器 + + + + + +ykxmz 中转站 + -
-
- -
-

ykxmz 中转服务器

-
远程控制 · 跨网络中转
-
-
-
版本 1.7.1 · 已运行 1 分
-
本进程内存 22 MB · 累计请求 3 - · 退出登录
-
-
+
+ +
+ ykxmz 中转站 + 跨网络中转 · 管理后台 +
+ 运行中 +
+
版本 1.7.1 · 已运行 9 小时 10 分 · 内存 24 MB
+
累计请求 1919 · 退出登录
+
+
-
-
当前公网地址 —— 手表「中转设置」里填这个
-
-
https://relay.xxmxxs.top
- -
-
+
-
-
网络体检 —— 走的是局域网还是隧道?
-
点下面按钮测一下
-
- -
-
+
+

公网地址 —— 手表「中转设置」里填这个

+
+
https://relay.xxmxxs.top
+ +
+
手表和电脑不在同一个网络时,两边都通过这个地址中转。
+
-
-
下载最新版
-
正在读…
-
+
+
2
登记设备
+
1
在线
+
0
手表会话
+
2
配对码占用
+
-
-
0
登记设备
-
0
在线
-
0
手表会话
-
49
配对码占用
-
+
+

设备

+
+ + + +
+
-
- - -
- -
-
- 删除会连配对码一起清掉,那台设备下次可以重新设码。 -
+
+ + + + + + + + + +
设备配对码状态空闲待发 表/机流量 入/出
教室电脑
DESKTOP-ABC-123
246810在线3s0 / 012 KB / 7 KB
办公室电脑
DESKTOP-ABC-456
135790离线900s0 / 00 B / 0 B
+
-
- - - - - - - - - -
设备配对码状态空闲待发(表/机)流量(入/出)
还没有电脑连上来
在被控端双击 ykxmz.exe 设好配对码,手表上输那 6 位数字就能连上。
-
+
+
+

网络体检

+
看看这台机器是走局域网还是走隧道。
+
还没测过。
+
+
+
+

下载区

+
官网下载按钮指向这里的文件。
+
ykxmz-setup-1.7.1.exe14.4 MB
ykxmz-watch-1.7.1.apk197 KB
+
+
-
- 数据来自 /v1/health · 只显示配对码与流量,看不到任何传输内容
- 中转只是转发字节,不解析、不存储会话内容。 -
+
+ 隐私说明 —— 数据来自 /v1/health,只显示配对码和流量数字, + 看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。 +
-
- +
- \ No newline at end of file + \ No newline at end of file diff --git a/_build/commit_round79.py b/_build/commit_round79.py new file mode 100644 index 0000000..615141f --- /dev/null +++ b/_build/commit_round79.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +r'''最终验证 + 提交这一轮(隧道修复 + 管理页重做)。''' +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +GIT = r"C:\Program Files\Git\bin\git.exe" +sys.path.insert(0, str(ROOT / "_build")) + +from devssh import run # noqa: E402 + +MSG = """隧道"假活"真因修复 + 中转管理后台重做 + +用户反馈两件事: + 1. 早上打开 relay.xxmxxs.top 看到 Cloudflare Error 1033 —— "这就是你说的优化好了?" + 2. "中转管理那边 ui 界面大改,现在难看的要死" + +============ 一、隧道 1033/502/530 的真因 ============ + +之前我只测 15~25 次就说"可用率 100%",太草率 —— +实际是**间歇性假活**:隧道显示 ha_connections=2 连着, +公网却 502/530/000。watchdog 日志把过程记了下来。 + +根因:**4G 链路静默断开 TCP 连接**(不发 FIN/RST,双方都以为还活着)。 +Cloudflare 把请求发到这条僵尸连接上就回错误。 +Linux 默认 tcp_keepalive_time = **7200 秒(2 小时)**才发现, +中间这段时间服务一直"看着在线、实际打不开"。 + +修法(都有实测数据支撑): + 1. tcp_keepalive 30 秒 + 每 10 秒探测 3 次 + => 最多约 60 秒发现僵尸连接并重连 + 2. 隧道连接数 2 -> 4 + (对照实验:http2+2 只有 1 条连上时公网 0/12; + http2+4 稳定 12/12。连接数太少会出现"只有 1 条"的危险窗口) + 3. 协议定为 http2(cloudflared 自检说 TCP 7844 被墙, + 但实测 http2 反而比 quic 稳) + +实测:http2+4 之后公网 40/40 = 100%,最慢 1.3 秒,0 次重连。 + +============ 二、中转管理后台重做 ============ + +原来是几百行内联在 relay.py 的 f-string 里(HTML+CSS+JS 全挤一起), +改一处样式要在一堆转义花括号里翻。拆成独立模块 relay/admin.py。 + +新版设计(对齐官网那套白色简洁): + * 吸顶顶栏:品牌 + 运行状态 + 版本/内存/请求数 + * 公网地址给最大权重(这是用户最需要复制的东西) + * 四个数字卡(在线/会话数带颜色区分) + * 设备表卡片:状态圆点、等宽配对码、行悬停 + * 工具卡:网络体检 + 下载区并排 + * 空状态改成图标+引导文案,不再是干巴巴一句 + * 窄屏时表格自动变卡片 + +relay.py 从 2089 行缩到 1550 行。 + +============ 三、顺手修的两个真 bug ============ + +1. 设备记录只增不减 + self.devices 从不清理,被控端每次换端口/重装就多一条, + 实测一天攒了 75 条僵尸记录,/v1/health 从几 KB 涨到 20KB。 + 加 DEVICE_TTL(6 小时)在 snapshot() 里清。 + +2. 配对码映射也没清 + RELAY.codes 和 devices 是一对孪生 dict, + 只清 devices 会留下指向已删设备的孤儿条目 —— + 管理页显示"配对码占用 131",实际一台设备都没有。 + 现在一起清。 + +============ 四、我自己造的两次事故(如实记录) ============ + +1. 删文件删过头 + 用"从 _admin_page 截到文件末尾"的方式替换方法, + 但 _admin_page 后面**还有 _esc/_kb/_uptime/class Server/main()**。 + 结果 relay 起不来,systemd 重启 24 次,所有接口 000。 + 教训:**"删到文件末尾"之前必须先确认末尾有什么。** + +2. ADMIN_PATH 引用了不存在的常量 + 路由里写的是字面量,我却在新代码里用了 ADMIN_PATH, + 管理页一访问就 NameError。现在统一定义了常量。 + +二者都是"改完没立刻验"造成的。现在每次改完都跑一遍 +管理页 + 官网 + 关键接口的回归。 +""" + + +def verify() -> None: + print(" 一、验证配对码清理") + out = run( + "curl -s -b /tmp/ck.txt --max-time 20 " + "http://127.0.0.1:8080/zhongzhuanzhan | " + "grep -A2 '配对码占用' | head -3", + timeout=120)[1] + print(" " + (out or "").strip()) + + code = run( + "curl -s --max-time 10 http://127.0.0.1:8080/v1/health | " + "python3 -c \"import sys,json; d=json.load(sys.stdin); " + "print('设备', len(d.get('devices',[])))\"", + timeout=60)[1] + print(" " + (code or "").strip()) + + +def commit() -> None: + print() + print(" 二、提交") + subprocess.run([GIT, "add", "-A"], cwd=str(ROOT), check=False) + msg_file = ROOT / "_build" / "_cmsg7.txt" + msg_file.write_bytes(MSG.encode("utf-8")) + r = subprocess.run([GIT, "commit", "-q", "-F", str(msg_file)], + cwd=str(ROOT), capture_output=True, text=True, + encoding="utf-8", errors="replace") + msg_file.unlink(missing_ok=True) + print(" commit exit=%s" % r.returncode) + show = subprocess.run([GIT, "log", "--oneline", "-1"], cwd=str(ROOT), + capture_output=True, text=True, encoding="utf-8", + errors="replace") + print(" " + (show.stdout or "").strip()) + env = dict(os.environ) + env["GIT_SSH_COMMAND"] = r'"C:\Program Files\Git\usr\bin\ssh.exe"' + push = subprocess.run([GIT, "push", "origin", "main"], cwd=str(ROOT), + capture_output=True, text=True, encoding="utf-8", + errors="replace", env=env) + for line in (push.stderr or push.stdout or "").strip().splitlines()[-2:]: + print(" " + line) + + +def main() -> int: + verify() + commit() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/compare_protocol.sh b/_build/compare_protocol.sh new file mode 100644 index 0000000..a1c626d --- /dev/null +++ b/_build/compare_protocol.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# 对照实验:QUIC vs HTTP/2,连接数 2 vs 4。 +# +# 背景: +# cloudflared 自己的 precheck 说 "TCP 7844 blocked"(运营商墙了), +# 而 --protocol http2 走的正是 TCP 7844。 +# UDP 那边 precheck 显示 region1 成功、region2 失败 —— 部分可用。 +# +# 实测公网:/favicon.ico 能 200,其它路径 530。 +# 530 = 边缘找不到可用的隧道连接(请求落 SEA,隧道在 LAX)。 +# +# 所以要把"协议 + 连接数"这四种组合都跑一遍,用数据选,不靠猜。 +set -u + +PUB=https://relay.xxmxxs.top +UNIT=/etc/systemd/system/lanremote-tunnel.service +RELAY_DIR=/root/lanremote-relay + +probe() { + # 返回 "可用数/总数" + local ok=0 + for i in $(seq 1 12); do + c=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \ + -H 'User-Agent: LanRemote/1.4.0' "$PUB/v1/health") + [ "$c" = "200" ] && ok=$((ok + 1)) + sleep 0.6 + done + echo "$ok" +} + +set_combo() { + local proto="$1" conns="$2" + echo " ---- 设置 protocol=$proto ha-connections=$conns ----" + sed -i "s/--protocol [a-z0-9]*/--protocol $proto/" "$UNIT" + sed -i "s/--ha-connections [0-9]*/--ha-connections $conns/" "$UNIT" + systemctl daemon-reload + systemctl restart lanremote-tunnel + sleep 35 +} + +report() { + local label="$1" + local conns + conns=$(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') + local ok + ok=$(probe) + printf " %-28s 连接 %-3s 公网 %2s/12\n" "$label" "${conns:-?}" "$ok" + return 0 +} + +echo "════════ 对照实验开始(每种组合约 1.5 分钟)════════" +echo + +echo "组合 1/4:HTTP/2 + 2 连接" +set_combo http2 2 +report "http2 + 2" + +echo +echo "组合 2/4:HTTP/2 + 4 连接" +set_combo http2 4 +report "http2 + 4" + +echo +echo "组合 3/4:QUIC + 2 连接" +set_combo quic 2 +report "quic + 2" + +echo +echo "组合 4/4:QUIC + 4 连接" +set_combo quic 4 +report "quic + 4" + +echo +echo "════════ 各组合的落地机房 ════════" +grep -E 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null | tail -8 \ + | grep -oE 'location=[a-z0-9]+ protocol=[a-z0-9]+' | sed 's/^/ /' + +echo +echo " 说明:这个脚本只做测量,最终选哪种组合由数据定," +echo " 跑完我会把最优的一组写回 unit 并重启。" diff --git a/_build/deep_tunnel.sh b/_build/deep_tunnel.sh new file mode 100644 index 0000000..9f91cb1 --- /dev/null +++ b/_build/deep_tunnel.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# 隧道还是不稳 —— 深挖一层 +set -u + +echo "════════ 1. 服务与连接 ════════" +echo " relay: $(systemctl is-active lanremote-relay)" +echo " tunnel: $(systemctl is-active lanremote-tunnel)" +echo " 连接数: $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')" + +echo +echo "════════ 2. watchdog 最近 20 行(它在报什么)════════" +tail -20 /root/lanremote-relay/watchdog.log 2>/dev/null | sed 's/^/ /' + +echo +echo "════════ 3. watchdog 是怎么被调起来的 ════════" +systemctl is-active lanremote-watchdog 2>&1 | sed 's/^/ 服务: /' +crontab -l 2>/dev/null | grep -i watch | sed 's/^/ cron: /' +ls -la /etc/systemd/system/ | grep -i watch | sed 's/^/ unit: /' +ps aux 2>/dev/null | grep '[w]atchdog' | head -3 | sed 's/^/ 进程: /' + +echo +echo "════════ 4. 隧道日志最后 25 行 ════════" +tail -25 /root/lanremote-relay/tunnel.log 2>/dev/null | sed 's/^/ /' + +echo +echo "════════ 5. 本机 relay 通不通 ════════" +for i in 1 2 3; do + printf " 本机 %d: HTTP %s\n" "$i" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:8080/v1/health)" + sleep 1 +done + +echo +echo "════════ 6. 公网连打 10 次看规律 ════════" +for i in $(seq 1 10); do + curl -s -o /dev/null -w " $i: HTTP %{http_code} %{time_total}s\n" \ + --max-time 20 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health + sleep 1 +done + +echo +echo "════════ 7. 隧道出口 IP 的可达性(现在还通吗)════════" +python3 - <<'PY' +import socket +import time +# 看隧道日志里出现过的 IP +ips = ["198.41.192.107", "198.41.200.33", "198.41.192.227", + "198.41.200.63", "198.41.192.37"] +for ip in ips: + 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 "════════ 8. 网络本身的质量 ════════" +echo " 丢包测试(ping 1.1.1.1 10 次):" +ping -c 10 -W 2 1.1.1.1 2>/dev/null | tail -3 | sed 's/^/ /' +echo " 到 Cloudflare 边缘的丢包:" +ping -c 10 -W 2 198.41.192.107 2>/dev/null | tail -3 | sed 's/^/ /' diff --git a/_build/diag_admin000.sh b/_build/diag_admin000.sh new file mode 100644 index 0000000..4838452 --- /dev/null +++ b/_build/diag_admin000.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# 管理页 000 —— 直接打,看真实响应 +set -u + +BASE=http://127.0.0.1:8080 +KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null) + +echo " key 长度: ${#KEY}" +echo +echo "════════ 1. 带 X-Admin 头 ════════" +curl -sv -H "X-Admin: $KEY" --max-time 15 "$BASE/zhongzhuanzhan" 2>&1 \ + | grep -E '^< HTTP|^< |Connection|Empty' | head -12 | sed 's/^/ /' + +echo +echo "════════ 2. 不带钥匙(应该看到登录页)════════" +curl -s -o /tmp/lg.html -w ' HTTP %{http_code} %{size_download} 字节\n' --max-time 15 "$BASE/zhongzhuanzhan" +head -c 200 /tmp/lg.html | sed 's/^/ /' +echo + +echo +echo "════════ 3. 直接用 cookie 登录一次再访 ════════" +PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null) +echo " 密码长度: ${#PASS}" +curl -s -c /tmp/ck.txt -o /dev/null --max-time 15 \ + -d "username=xmz7891&password=$PASS" "$BASE/admin/login" +echo " cookie:" +cat /tmp/ck.txt 2>/dev/null | grep -v '^#' | sed 's/^/ /' +echo " 用 cookie 访管理页:" +curl -s -b /tmp/ck.txt -o /tmp/ad.html -w ' HTTP %{http_code} %{size_download} 字节\n' \ + --max-time 15 "$BASE/zhongzhuanzhan" +echo " 前 300 字符:" +head -c 300 /tmp/ad.html | sed 's/^/ /' +echo + +echo +echo "════════ 4. relay 日志里最近的错误 ════════" +journalctl -u lanremote-relay --since '3 minutes ago' --no-pager 2>/dev/null \ + | tail -30 | sed 's/^/ /' + +echo +echo "════════ 5. relay.log 尾部 ════════" +tail -20 /root/lanremote-relay/relay.log 2>/dev/null | sed 's/^/ /' diff --git a/_build/diag_origin.sh b/_build/diag_origin.sh new file mode 100644 index 0000000..fb12d19 --- /dev/null +++ b/_build/diag_origin.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# 502 但隧道连着 -> 回源这一段的问题。逐一排除。 +set -u + +echo "════════ 1. 隧道 ingress 全文 ════════" +cat /root/.cloudflared/config.yml 2>/dev/null | sed 's/^/ /' + +echo +echo "════════ 2. relay 在听哪个地址 ════════" +ss -lntp 2>/dev/null | grep -E ':8080' | sed 's/^/ /' + +echo +echo "════════ 3. 带 Host 头访问(模拟云隧道回源)════════" +for host in "relay.xxmxxs.top" "127.0.0.1" "localhost" ""; do + if [ -z "$host" ]; then + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:8080/v1/health) + echo " 不带 Host: HTTP $code" + else + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 -H "Host: $host" http://127.0.0.1:8080/v1/health) + echo " Host: $host -> HTTP $code" + fi +done + +echo +echo "════════ 4. 请求 /(官网)带 Host 头 ════════" +for host in "relay.xxmxxs.top" "127.0.0.1"; do + printf " Host: %-22s HTTP %s\n" "$host" \ + "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 -H "Host: $host" http://127.0.0.1:8080/)" +done + +echo +echo "════════ 5. 隧道进程的真实参数 ════════" +pid=$(pgrep -f 'cloudflared tunnel' | head -1) +[ -n "$pid" ] && tr '\0' ' ' < /proc/$pid/cmdline 2>/dev/null | sed 's/^/ /' +echo + +echo "════════ 6. cloudflared 有没有报回源错误 ════════" +tail -400 /root/lanremote-relay/tunnel.log 2>/dev/null \ + | grep -iE 'origin|502|refused|unreachable|no such host|proxy' | tail -10 | sed 's/^/ /' +echo " (没输出=没有回源错误)" + +echo +echo "════════ 7. relay 自己有没有收到隧道来的请求 ════════" +tail -60 /root/lanremote-relay/relay.log 2>/dev/null \ + | grep -E 'GET|POST|处理' | tail -10 | sed 's/^/ /' + +echo +echo "════════ 8. 关键:直接问 Cloudflare 边缘这个域名该怎么走 ════════" +echo " DNS 记录:" +getent hosts relay.xxmxxs.top | sed 's/^/ /' +echo " HTTP 响应头(看 cf-ray / server):" +curl -sI --max-time 20 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/v1/health \ + | head -12 | sed 's/^/ /' + +echo +echo "════════ 9. 换一个路径试(避开 /v1/health)════════" +for p in "/" "/favicon.ico" "/v1/health" "/v1/downloads"; do + printf " %-16s HTTP %s\n" "$p" \ + "$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H 'User-Agent: LanRemote/1.4.0' "https://relay.xxmxxs.top$p")" +done diff --git a/_build/diag_relay2.sh b/_build/diag_relay2.sh new file mode 100644 index 0000000..6ce1066 --- /dev/null +++ b/_build/diag_relay2.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -u +echo " relay: $(systemctl is-active lanremote-relay)" +echo +echo " 最近日志:" +journalctl -u lanremote-relay -n 30 --no-pager 2>/dev/null | tail -22 | sed 's/^/ /' +echo +echo " 手动 import 看报什么:" +cd /root/lanremote-relay +python3 -c " +import sys +sys.path.insert(0, '.') +try: + import relay + print(' import relay OK') +except Exception: + import traceback; traceback.print_exc() +" 2>&1 | tail -15 | sed 's/^/ /' +echo +echo " admin 模块:" +python3 -c " +import sys +sys.path.insert(0, '.') +try: + import admin + print(' import admin OK') +except Exception: + import traceback; traceback.print_exc() +" 2>&1 | tail -15 | sed 's/^/ /' diff --git a/_build/diag_relay3.sh b/_build/diag_relay3.sh new file mode 100644 index 0000000..a04cab4 --- /dev/null +++ b/_build/diag_relay3.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# relay 起不来 —— 直接在设备上手动跑,看真实报错 +set -u + +echo " 服务状态: $(systemctl is-active lanremote-relay)" +echo " restart 计数: $(systemctl show lanremote-relay -p NRestarts --value)" +echo +echo " 最近 journal:" +journalctl -u lanremote-relay -n 15 --no-pager 2>/dev/null | tail -10 | sed 's/^/ /' + +echo +echo "════════ 手动前台跑 5 秒(看它自己报什么)════════" +cd /root/lanremote-relay +timeout 5 python3 relay.py 2>&1 | head -25 | sed 's/^/ /' +echo " (退出码 $?)" + +echo +echo "════════ 单独 import + 调 main 的前半段 ════════" +python3 -c " +import sys +sys.path.insert(0, '.') +import relay +print(' VERSION =', relay.VERSION) +print(' PORT =', relay.PORT, 'BIND =', relay.BIND) +print(' ADMIN_PATH =', relay.ADMIN_PATH) +print(' main 存在:', callable(getattr(relay, 'main', None))) +" 2>&1 | tail -12 | sed 's/^/ /' + +echo +echo "════════ 端口有没有被占 ════════" +ss -lntp 2>/dev/null | grep -E ':8080' | sed 's/^/ /' +echo " (没输出=没人占)" + +echo +echo "════════ 数据目录权限 ════════" +ls -la /root/lanremote-relay/data/ 2>/dev/null | head -8 | sed 's/^/ /' diff --git a/_build/diag_relay_crash.sh b/_build/diag_relay_crash.sh new file mode 100644 index 0000000..261a1f5 --- /dev/null +++ b/_build/diag_relay_crash.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -u +echo " relay 状态: $(systemctl is-active lanremote-relay)" +echo +echo " 最近的错误:" +journalctl -u lanremote-relay -n 40 --no-pager 2>/dev/null | tail -25 | sed 's/^/ /' +echo +echo " 直接跑一下看报什么错:" +cd /root/lanremote-relay +python3 -c " +import sys +sys.path.insert(0, '.') +try: + import relay + print(' import OK') +except Exception as e: + import traceback + traceback.print_exc() +" 2>&1 | tail -20 | sed 's/^/ /' +echo +echo " landing 单独渲染:" +python3 -c " +import sys +sys.path.insert(0, '.') +try: + import landing + html = landing.render_site([{'name':'ykxmz-setup-1.7.1.exe','size':15063814,'mtime':1}]) + print(' 渲染 OK,%d 字符' % len(html)) +except Exception as e: + import traceback + traceback.print_exc() +" 2>&1 | tail -20 | sed 's/^/ /' diff --git a/_build/diff_relay_loss.py b/_build/diff_relay_loss.py new file mode 100644 index 0000000..a2da6b4 --- /dev/null +++ b/_build/diff_relay_loss.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +r'''对比 git 版本,找出 swap_admin_page.py 误删的所有东西。 + +事故: + 那个脚本把 relay.py 从 "_admin_page" 那行一直截到文件末尾, + 以为 _admin_page 是最后一个东西。实际它后面还有: + - 一大堆模块级函数/常量(ADMIN_PATH、Server、Handler...) + - main() 入口 + 结果 relay 起不来,systemd 重启 14 次。 + +这个脚本用 AST 对比两边的**顶层名字**,把丢了的一次性列清楚。 +''' +from __future__ import annotations + +import ast +import subprocess +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +RELAY = ROOT / "relay" / "relay.py" +GIT = r"C:\Program Files\Git\bin\git.exe" + + +def top_names(source: str) -> list: + """列出模块顶层的定义名(按出现顺序)。""" + tree = ast.parse(source) + out = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + out.append(("def", node.name, node.lineno)) + elif isinstance(node, ast.ClassDef): + out.append(("class", node.name, node.lineno)) + elif isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + out.append(("var", target.id, node.lineno)) + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, + ast.Name): + out.append(("var", node.target.id, node.lineno)) + return out + + +def main() -> int: + old = subprocess.run([GIT, "show", "HEAD:relay/relay.py"], + cwd=str(ROOT), capture_output=True, text=True, + encoding="utf-8", errors="replace").stdout + new = RELAY.read_text(encoding="utf-8") + + if not old: + print(" [失败] 取不到 git 版本") + return 1 + + old_names = top_names(old) + new_names = top_names(new) + new_set = {n[1] for n in new_names} + + missing = [item for item in old_names if item[1] not in new_set] + + print(" git 版本顶层定义 %d 个,当前 %d 个" + % (len(old_names), len(new_names))) + print() + if not missing: + print(" 没有缺失 ✓") + return 0 + + print(" 缺失 %d 个:" % len(missing)) + for kind, name, lineno in missing: + print(" %-6s %-28s (git 第 %d 行)" % (kind, name, lineno)) + + # 把缺失的部分从 git 里摘出来 + old_lines = old.splitlines() + print() + print(" 缺失部分在 git 里的行范围:") + # 找最小的起止行 + starts = [item[2] for item in missing] + print(" 从第 %d 行到文件末尾(共 %d 行)" + % (min(starts), len(old_lines) - min(starts) + 1)) + + print() + print(" 这些行的开头(确认是什么内容):") + for i in range(min(starts) - 1, min(len(old_lines), + min(starts) + 30)): + line = old_lines[i] + if line.strip(): + print(" %5d: %s" % (i + 1, line[:84])) + + # 存一份给恢复脚本用 + out = ROOT / "_build" / "_missing_tail.py" + out.write_text("\n".join(old_lines[min(starts) - 1:]) + "\n", + encoding="utf-8") + print() + print(" 缺失的整段已存到 _build/_missing_tail.py") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/finalize_tunnel.sh b/_build/finalize_tunnel.sh index 83dd00d..219ca27 100644 --- a/_build/finalize_tunnel.sh +++ b/_build/finalize_tunnel.sh @@ -1,117 +1,67 @@ #!/bin/bash -# 定稿:ha-connections=2,并做长时间稳定性验证。 -# -# 实测(见 /root/ha_result.txt): -# ha=4 -> 9/10,而且只成功注册 1 条连接(其余一直在握手超时) -# ha=2 -> 10/10,2 条都注册上 -# ha=1 -> 10/10,1 条 -# -# 原因很可能是这条 4G 线路对并发 UDP 有节制: -# 4 条 QUIC 同时握手会互相挤,反而条条失败。 -# ha=2 是甜点:够稳,又还有冗余。 +# 定稿:HTTP/2 + 4 连接,然后做**长时间**验证(这次不看瞬时值) set -u -RELAY_DIR=/root/lanremote-relay UNIT=/etc/systemd/system/lanremote-tunnel.service +PUB=https://relay.xxmxxs.top +RELAY_DIR=/root/lanremote-relay -echo "===== 1. 写入正式配置(ha=2)=====" -cat > "$UNIT" <<'EOF' -[Unit] -Description=LanRemote Cloudflare 具名隧道 -After=network-online.target lanremote-relay.service -# 用 Wants 不用 Requires:Requires 是双向的, -# 重启中转会把隧道一起停掉,而且没东西会再拉起来。 -Wants=network-online.target lanremote-relay.service - -[Service] -Type=simple -# 协议:必须 QUIC(默认)。 -# 实测这条线路把 HTTP/2 用的 TCP 7844 挡了(TLS 握手超时), -# 而 QUIC 走 UDP 7844 是通的。 -# 连接数:ha-connections=2。 -# 实测 4 条并发 QUIC 握手会互相挤,只成功 1 条、可用率 9/10; -# 降到 2 条后 10/10,且仍有冗余(连两个不同边缘节点)。 -# IPv4:这条线路有 IPv6 地址但实际不通,必须 --edge-ip-version 4。 -ExecStart=/usr/local/bin/cloudflared tunnel --no-autoupdate --edge-ip-version 4 --ha-connections 2 --retries 5 --config /root/.cloudflared/config.yml run lanremote -Restart=always -RestartSec=5 -MemoryMax=128M -StandardOutput=append:/root/lanremote-relay/tunnel.log -StandardError=append:/root/lanremote-relay/tunnel.log - -[Install] -WantedBy=multi-user.target -EOF - -systemctl stop lanremote-tunnel 2>/dev/null || true -pkill -f cloudflared 2>/dev/null || true -sleep 3 -rm -f "$RELAY_DIR/tunnel.log" +echo "════════ 1. 写回最优配置 ════════" +sed -i "s/--protocol [a-z0-9]*/--protocol http2/" "$UNIT" +sed -i "s/--ha-connections [0-9]*/--ha-connections 4/" "$UNIT" systemctl daemon-reload -systemctl enable lanremote-tunnel >/dev/null 2>&1 -systemctl start lanremote-tunnel +systemctl restart lanremote-tunnel +echo " $(grep -oE '\-\-protocol [a-z0-9]+|--ha-connections [0-9]+' "$UNIT" | tr '\n' ' ')" echo -echo "===== 2. 等它建齐连接 =====" -for i in 1 2 3 4 5; do - sleep 15 - N=$(grep -c 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null || echo 0) - echo " $((i*15)) 秒:已注册 $N 条" - [ "$N" -ge 2 ] && break +echo "════════ 2. 等连接全部建立 ════════" +for i in $(seq 1 12); do + sleep 5 + n=$(curl -s --max-time 4 http://127.0.0.1:20241/metrics 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') + echo " ${i}0s 内: 连接 $n" + [ "$n" = "4" ] && break done echo -echo "===== 3. 长时间稳定性:连打 30 次,每次隔 3 秒 =====" -OK=0 -FAIL=0 -FAILED_CODES="" -for i in $(seq 1 30); do - C=$(timeout 20 curl -4 -s -o /dev/null -w '%{http_code}' \ - -H 'User-Agent: LanRemote/1.4.0' \ - https://relay.xxmxxs.top/v1/health 2>/dev/null) - if [ "$C" = "200" ]; then - OK=$((OK + 1)); M="✓" +echo "════════ 3. 公网连测 40 次(约 2 分钟)════════" +ok=0; fail=0; worst=0; streak=0; worst_streak=0 +for i in $(seq 1 40); do + r=$(curl -s -o /dev/null -w '%{http_code} %{time_total}' --max-time 15 \ + -H 'User-Agent: LanRemote/1.4.0' "$PUB/v1/health") + c=$(echo "$r" | cut -d' ' -f1) + t=$(echo "$r" | cut -d' ' -f2) + if [ "$c" = "200" ]; then + ok=$((ok+1)); streak=0 + big=$(echo "$t > $worst" | bc 2>/dev/null || echo 0) + [ "$big" = "1" ] && worst=$t else - FAIL=$((FAIL + 1)); M="✗"; FAILED_CODES="$FAILED_CODES ${C:-超时}" + fail=$((fail+1)); streak=$((streak+1)) + [ "$streak" -gt "$worst_streak" ] && worst_streak=$streak + [ $((i % 5)) -eq 0 ] && echo " 第 $i 次: $c" fi - printf " %2d: %s %s\n" "$i" "$M" "${C:-超时}" - sleep 3 + sleep 2.5 done -PCT=$((OK * 100 / 30)) +rate=$(echo "scale=1; $ok * 100 / 40" | bc) echo -echo " 成功 $OK / 30 ($PCT%)" -[ -n "$FAILED_CODES" ] && echo " 失败的返回码:$FAILED_CODES" +echo " 可用 $ok/40 = ${rate}% 最慢 ${worst}s" +echo " 最长连续失败 $worst_streak 次(约 $((worst_streak * 3)) 秒)" echo -echo "===== 4. 对照:中转本地(应该 100%)=====" -LOK=0 -for i in $(seq 1 10); do - C=$(timeout 8 curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/v1/health) - [ "$C" = "200" ] && LOK=$((LOK + 1)) -done -echo " 局域网直连 $LOK / 10" - -echo -echo "===== 5. 隧道日志统计 =====" -echo " 注册成功: $(grep -c 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null || echo 0)" -echo " 连接断开: $(grep -c 'Connection terminated' "$RELAY_DIR/tunnel.log" 2>/dev/null || echo 0)" -echo " quic 错误: $(grep -c 'Failed to dial a quic' "$RELAY_DIR/tunnel.log" 2>/dev/null || echo 0)" - -echo -echo "===== 6. 最终服务清单 =====" -for s in lanremote-relay lanremote-tunnel lanremote-watchdog.timer; do - printf " %-32s %-10s %s\n" "$s" \ - "$(systemctl is-active $s 2>/dev/null)" \ - "$(systemctl is-enabled $s 2>/dev/null)" -done -echo -echo " 公网地址: $(cat $RELAY_DIR/public_url.txt)" -echo " 内存: $(free -m | awk 'NR==2{printf "已用 %s MB / 可用 %s MB", $3, $7}')" - -if [ "$PCT" -ge 90 ]; then - echo - echo " ✓ 稳定了($PCT%)—— ha-connections=2 定稿" -else - echo - echo " ~ 还有抖动($PCT%),看门狗会兜底" +echo "════════ 4. 这段时间隧道重连了几次 ════════" +since=$(date -u -d '4 minutes ago' '+%Y-%m-%dT%H:%M:%S' 2>/dev/null || echo "") +if [ -n "$since" ]; then + awk -v s="$since" '$0 >= s' "$RELAY_DIR/tunnel.log" 2>/dev/null > /tmp/recent.log + for kw in 'Registered tunnel connection' 'Lost connection' 'Retrying connection' 'i/o timeout'; do + printf " %-32s %s 次\n" "$kw" "$(grep -c "$kw" /tmp/recent.log)" + done fi + +echo +echo "════════ 5. 现在的落点 ════════" +grep -E 'Registered tunnel connection' "$RELAY_DIR/tunnel.log" 2>/dev/null | tail -5 \ + | grep -oE 'ip=[0-9.]+ location=[a-z0-9]+ protocol=[a-z0-9]+' | sed 's/^/ /' + +echo +echo "════════ 6. watchdog 状态 ════════" +tail -6 "$RELAY_DIR/watchdog.log" 2>/dev/null | sed 's/^/ /' diff --git a/_build/grab_admin2.py b/_build/grab_admin2.py new file mode 100644 index 0000000..898ff3a --- /dev/null +++ b/_build/grab_admin2.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +r'''抓新版管理页到本地做截图(要带登录 cookie)。''' +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: + # 在设备上登录拿 cookie,再抓页面,然后把 cookie 去掉后存本地 + _c, out, _e = run( + "PASS=$(cat /root/lanremote-relay/data/admin_password.txt); " + "curl -s -c /tmp/ck.txt -o /dev/null " + "-d \"username=xmz7891&password=$PASS\" " + "http://127.0.0.1:8080/admin/login; " + "curl -s -b /tmp/ck.txt --max-time 20 " + "http://127.0.0.1:8080/zhongzhuanzhan", + timeout=120) + if not out or "ykxmz 中转站" not in out: + print(" 抓不到管理页") + print(out[:300]) + return 1 + + # 本地 file:// 打开时相对路径全失效,改绝对 + out = out.replace('href="/favicon.ico"', + 'href="https://relay.xxmxxs.top/favicon.ico"') + + target = ROOT / "_build" / "admin_new.html" + target.write_text(out, encoding="utf-8") + print(" 抓到 %d 字符 -> %s" % (len(out), target.name)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/list_windows.py b/_build/list_windows.py new file mode 100644 index 0000000..2600dad --- /dev/null +++ b/_build/list_windows.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +r'''启动被控端,把它的窗口标题都列出来(实测比翻代码快)。''' +from __future__ import annotations + +import ctypes +import os +import subprocess +import sys +import time +from ctypes import wintypes +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +EXE = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe" +user32 = ctypes.windll.user32 + + +def windows_of(pid: int) -> list: + out = [] + + @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + def cb(hwnd, _lp): + wpid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(wpid)) + if wpid.value == pid: + length = user32.GetWindowTextLengthW(hwnd) + buf = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buf, length + 1) + cls = ctypes.create_unicode_buffer(256) + user32.GetClassNameW(hwnd, cls, 256) + rect = wintypes.RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + out.append({ + "hwnd": hwnd, + "title": buf.value, + "class": cls.value, + "visible": bool(user32.IsWindowVisible(hwnd)), + "size": (rect.right - rect.left, rect.bottom - rect.top), + }) + return True + + user32.EnumWindows(cb, 0) + return out + + +def main() -> int: + data = ROOT / "_build" / "_shotdata2" + data.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env["YKXMZ_DATA"] = str(data) + + print(" 启动被控端(不带 --silent,应该出主窗口)") + proc = subprocess.Popen([str(EXE), "--port", "8898"], + cwd=str(ROOT), env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + time.sleep(12) + + wins = windows_of(proc.pid) + print(" 进程 PID=%d 的窗口(%d 个):" % (proc.pid, len(wins))) + for w in wins: + print(" hwnd=%d 可见=%s 大小=%s 类=%s" + % (w["hwnd"], w["visible"], w["size"], w["class"])) + print(" 标题: %r" % w["title"]) + + print() + print(" 提醒:PyInstaller --onedir 启动后,真正跑 GUI 的可能是子进程。") + print(" 所有 python/ykxmz 进程:") + r = subprocess.run( + ["powershell", "-NoProfile", "-Command", + "Get-Process ykxmz,python -ErrorAction SilentlyContinue | " + "ForEach-Object { $_.Id.ToString() + ' ' + $_.ProcessName }"], + capture_output=True, text=True, timeout=60, + encoding="utf-8", errors="replace") + print(" " + (r.stdout or "").replace("\n", "\n ")) + + # 子进程的窗口也列一下 + for line in (r.stdout or "").splitlines(): + parts = line.split() + if len(parts) == 2: + try: + child = int(parts[0]) + except ValueError: + continue + if child == proc.pid: + continue + for w in windows_of(child): + if w["visible"] and w["title"]: + print(" [子进程 %d] hwnd=%d 大小=%s 标题=%r" + % (child, w["hwnd"], w["size"], w["title"])) + + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + subprocess.run(["powershell", "-NoProfile", "-Command", + "Get-Process ykxmz -ErrorAction SilentlyContinue | " + "Stop-Process -Force"], + capture_output=True, timeout=60) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/polish_site.py b/_build/polish_site.py new file mode 100644 index 0000000..d3d9268 --- /dev/null +++ b/_build/polish_site.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +r'''官网细节打磨。 + +看截图发现的问题: + 1. 导航还是老的四个链接,新增的「长什么样」「怎么连上的」进不去 + 2. 手表 mockup 的状态栏「已连接 · 局域网 3 ms」被挤到换行 + 3. 连接示意的图标(⌚☁🖥)太小太淡,看不清 + 4. 电脑端截图直接贴了窗口标题栏,显得毛糙 —— 加个浅色边框和内边距 +''' +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +LANDING = ROOT / "relay" / "landing.py" +SHOWCASE = ROOT / "relay" / "showcase.py" + + +def polish_landing() -> None: + text = LANDING.read_text(encoding="utf-8") + changed = [] + + # 1) 导航加链接 + old_nav = ''' ''' + new_nav = ''' ''' + if old_nav in text: + text = text.replace(old_nav, new_nav, 1) + changed.append("导航链接") + + LANDING.write_text(text, encoding="utf-8", newline="\n") + try: + ast.parse(text) + print(" landing.py 语法 OK") + except SyntaxError as exc: + print(" [失败] landing.py 行 %s: %s" % (exc.lineno, exc.msg)) + for item in changed: + print(" 改了: %s" % item) + + +def polish_showcase() -> None: + text = SHOWCASE.read_text(encoding="utf-8") + changed = [] + + # 2) 状态栏:把「已连接 · 局域网 3 ms」拆短,别挤 + old = '● 已连接 · 局域网 3 ms' + new = '● 局域网 3 ms' + if old in text: + text = text.replace(old, new, 1) + changed.append("状态栏文案") + + # 3) 图标放大 + 连线加粗,并给节点一个底色圆 + css_fixes = [ + # 节点图标更大更清晰 + (".node{display:flex;flex-direction:column;align-items:center;gap:4px;\n" + " font-size:22px;flex:none}", + ".node{display:flex;flex-direction:column;align-items:center;gap:6px;\n" + " font-size:26px;flex:none;width:64px}"), + (".node span{font-size:11px;color:#6b7280}", + ".node span{font-size:12px;color:#4b5563;font-weight:500}"), + (".node.relay{font-size:19px}", + ".node.relay{font-size:24px}"), + # 连线更明显 + (".link .line{width:100%%;height:2px;background:#d1d5db;border-radius:2px}", + ".link .line{width:100%%;height:2px;background:#c7cdd6;\n" + " border-radius:2px}"), + # 手表状态栏字号再小一点,保证一行放得下 + (".w-status{display:flex;justify-content:space-between;align-items:center;\n" + " padding:11px 13px 7px;font-size:10px;font-weight:600}", + ".w-status{display:flex;justify-content:space-between;align-items:center;\n" + " padding:11px 12px 7px;font-size:9px;font-weight:600;\n" + " gap:6px;white-space:nowrap}"), + # 电脑端截图:加个浅色容器,别让窗口标题栏直接贴边 + (".pcshot img{width:100%%;max-width:360px;height:auto;\n" + " border:1px solid %(W_BORDER)s;border-radius:12px;\n" + " box-shadow:0 18px 40px rgba(16,24,40,.14)}", + ".pcshot img{width:100%%;max-width:352px;height:auto;\n" + " border-radius:10px;display:block;margin:0 auto;\n" + " box-shadow:0 18px 40px rgba(16,24,40,.16)}\n" + ".pcshot{padding:14px;background:#f2f4f7;border-radius:16px;\n" + " border:1px solid %(W_BORDER)s}"), + ] + for old_css, new_css in css_fixes: + if old_css in text: + text = text.replace(old_css, new_css, 1) + changed.append("样式") + + SHOWCASE.write_text(text, encoding="utf-8", newline="\n") + try: + ast.parse(text) + print(" showcase.py 语法 OK") + except SyntaxError as exc: + print(" [失败] showcase.py 行 %s: %s" % (exc.lineno, exc.msg)) + print(" 改了 %d 处" % len(changed)) + + +def main() -> int: + print(" 一、landing.py") + polish_landing() + print() + print(" 二、showcase.py") + polish_showcase() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/restore_main.py b/_build/restore_main.py new file mode 100644 index 0000000..34805cb --- /dev/null +++ b/_build/restore_main.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +r'''把被我误删的 main() 入口找回来。 + +事故经过: + swap_admin_page.py 把 relay.py 从 "_admin_page 那一行" 一直截到文件末尾, + 但 _admin_page 之后**还有 main() 和 if __name__ == "__main__"**。 + (我的扫描只找了 4 空格缩进的 " def ",没看见顶格的 "def main()" 和 "if __name__", + 于是"到文件末尾"就把入口一起干掉了。) + 结果:relay 一启动就退出,systemd 无限重启,所有接口 000。 + +教训:**"删到文件末尾"这种操作必须先确认末尾有什么。** + 现在这一段单独摘出来恢复,并加了校验。 +''' +from __future__ import annotations + +import ast +import subprocess +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +RELAY = ROOT / "relay" / "relay.py" +GIT = r"C:\Program Files\Git\bin\git.exe" + + +def main() -> int: + original = subprocess.run( + [GIT, "show", "HEAD:relay/relay.py"], + cwd=str(ROOT), capture_output=True, text=True, + encoding="utf-8", errors="replace").stdout + + if not original: + print(" [失败] 取不到 git 版本") + return 1 + + # 从 "\ndef main()" 开始,到文件末尾 + index = original.find("\ndef main()") + if index < 0: + # 试试别的名字 + for name in ("\ndef serve()", "\ndef run()"): + index = original.find(name) + if index >= 0: + break + if index < 0: + print(" [失败] git 版本里也找不到 main()") + return 1 + + tail = original[index:] + print(" 从 git 里摘到 %d 字符的入口代码(%d 行)" + % (len(tail), tail.count("\n"))) + + current = RELAY.read_text(encoding="utf-8") + if "if __name__" in current: + print(" 现在已经有入口了,不用补") + return 0 + + text = current.rstrip("\n") + "\n\n" + tail.lstrip("\n") + RELAY.write_text(text, encoding="utf-8", newline="\n") + + try: + ast.parse(text) + print(" relay.py 语法 OK") + except SyntaxError as exc: + print(" [失败] 行 %s: %s" % (exc.lineno, exc.msg)) + return 1 + + check = subprocess.run( + [sys.executable, "-c", + "import ast,sys;" + "tree=ast.parse(open(r'%s',encoding='utf-8').read());" + "names=[n.name for n in tree.body if isinstance(n,ast.FunctionDef)];" + "print(' 模块级函数:', names);" + "assert 'main' in names, 'main 不见了'" + % RELAY], + cwd=str(ROOT), capture_output=True, text=True, + encoding="utf-8", errors="replace") + print((check.stdout or "").strip()) + if check.returncode != 0: + print(" [失败] " + (check.stderr or "").strip()[-200:]) + return 1 + + print(" ✓ main() 恢复了") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/restore_missing.py b/_build/restore_missing.py new file mode 100644 index 0000000..5b687c9 --- /dev/null +++ b/_build/restore_missing.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +r'''把 swap_admin_page.py 误删的 4 个顶层定义补回来。 + +丢的(git 里还在): + _esc 2006 HTML 转义 + _kb 2011 字节数格式化 + _uptime 2019 运行时长格式化 + Server 2034 ThreadingHTTPServer 子类 —— main() 要用它, + 少了它就是 NameError,relay 起不来 + +做法:从 git 取下这一整段,去掉里面已经存在的部分(比如 main), + 插到当前文件的 main() 前面。 +''' +from __future__ import annotations + +import ast +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +RELAY = ROOT / "relay" / "relay.py" +GIT = r"C:\Program Files\Git\bin\git.exe" + + +def top_defs(source: str) -> set: + tree = ast.parse(source) + names = set() + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, + ast.ClassDef)): + names.add(node.name) + elif isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name): + names.add(target.id) + return names + + +def main() -> int: + old = subprocess.run([GIT, "show", "HEAD:relay/relay.py"], + cwd=str(ROOT), capture_output=True, text=True, + encoding="utf-8", errors="replace").stdout + cur = RELAY.read_text(encoding="utf-8") + + have = top_defs(cur) + missing = {"_esc", "_kb", "_uptime", "Server"} - have + if not missing: + print(" 没有缺的了") + return 0 + print(" 缺: %s" % ", ".join(sorted(missing))) + + # 从 git 里按段摘出来 + lines = old.splitlines() + blocks = [] + index = 0 + while index < len(lines): + line = lines[index] + match = re.match(r"^(def|class)\s+(\w+)", line) + if match and match.group(2) in missing: + # 收集这一段(直到下一个顶层定义或文件尾) + block = [line] + index += 1 + while index < len(lines): + nxt = lines[index] + if re.match(r"^(def|class)\s+\w+", nxt) or nxt.startswith("if __name__"): + break + block.append(nxt) + index += 1 + # 去掉尾部多余空行 + while block and not block[-1].strip(): + block.pop() + blocks.append("\n".join(block)) + print(" 摘到 %s(%d 行)" % (match.group(2), len(block))) + continue + index += 1 + + if not blocks: + print(" [失败] 一段都没摘到") + return 1 + + # 插到 main() 前面 + marker = "\ndef main(" + position = cur.find(marker) + if position < 0: + print(" [失败] 找不到 main()") + return 1 + + header = ("\n\n# ======================================================================\n" + "# 下面这几个是页面/启动辅助(重做管理页时被误删,从 git 恢复)\n" + "# ======================================================================\n\n") + text = (cur[:position + 1] + header + "\n\n".join(blocks) + "\n" + + cur[position:]) + + RELAY.write_text(text, encoding="utf-8", newline="\n") + + try: + ast.parse(text) + print(" relay.py 语法 OK") + except SyntaxError as exc: + print(" [失败] 行 %s: %s" % (exc.lineno, exc.msg)) + return 1 + + now = top_defs(text) + still = missing - now + if still: + print(" [失败] 还缺: %s" % ", ".join(sorted(still))) + return 1 + print(" ✓ 4 个都补回来了") + + print() + print(" 复核(关键定义是否都在):") + for name in ("_esc", "_kb", "_uptime", "Server", "Handler", "main", + "admin_key", "public_url", "_list_downloads"): + print(" %-18s %s" % (name, "有" if name in now else "**缺**")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/shoot_pc_main.py b/_build/shoot_pc_main.py new file mode 100644 index 0000000..31ca074 --- /dev/null +++ b/_build/shoot_pc_main.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +r'''拍电脑端主窗口截图(官网上用)。 + +踩过的坑: + 传 --code 启动是**一次性命令**,设完码就退出,窗口根本不会出现。 + 必须先单独跑一次 --code,再不带参数启动,才有主窗口。 + +窗口标题现在是 "ykxmz · 第一次设置"(设过码之后变成控制窗口)。 +''' +from __future__ import annotations + +import ctypes +import os +import subprocess +import sys +import time +from ctypes import wintypes +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +SHOTS = ROOT / "_build" / "site_shots" +EXE = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe" +DATA = ROOT / "_build" / "_shots_data" + +user32 = ctypes.windll.user32 +gdi32 = ctypes.windll.gdi32 + + +def windows_of(pid: int) -> list: + out = [] + + @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + def cb(hwnd, _lp): + wpid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(wpid)) + if wpid.value == pid: + rect = wintypes.RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + if user32.IsWindowVisible(hwnd) and (rect.right - rect.left) > 200: + out.append(hwnd) + return True + + user32.EnumWindows(cb, 0) + return out + + +def capture(hwnd: int, path: Path) -> bool: + rect = wintypes.RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + width = rect.right - rect.left + height = rect.bottom - rect.top + if width <= 0 or height <= 0: + return False + + hdc_window = user32.GetWindowDC(hwnd) + hdc_mem = gdi32.CreateCompatibleDC(hdc_window) + bitmap = gdi32.CreateCompatibleBitmap(hdc_window, width, height) + gdi32.SelectObject(hdc_mem, bitmap) + user32.PrintWindow(hwnd, hdc_mem, 2) # 2 = PW_RENDERFULLCONTENT + + class BMIH(ctypes.Structure): + _fields_ = [("biSize", wintypes.DWORD), ("biWidth", wintypes.LONG), + ("biHeight", wintypes.LONG), ("biPlanes", wintypes.WORD), + ("biBitCount", wintypes.WORD), + ("biCompression", wintypes.DWORD), + ("biSizeImage", wintypes.DWORD), + ("biXPelsPerMeter", wintypes.LONG), + ("biYPelsPerMeter", wintypes.LONG), + ("biClrUsed", wintypes.DWORD), + ("biClrImportant", wintypes.DWORD)] + + header = BMIH() + header.biSize = ctypes.sizeof(BMIH) + header.biWidth = width + header.biHeight = -height + header.biPlanes = 1 + header.biBitCount = 32 + + buf = ctypes.create_string_buffer(width * height * 4) + gdi32.GetDIBits(hdc_mem, bitmap, 0, height, buf, ctypes.byref(header), 0) + + from PIL import Image + img = Image.frombuffer("RGBA", (width, height), buf, "raw", "BGRA", 0, 1) + img.convert("RGB").save(path) + + gdi32.DeleteObject(bitmap) + gdi32.DeleteDC(hdc_mem) + user32.ReleaseDC(hwnd, hdc_window) + return True + + +def main() -> int: + SHOTS.mkdir(parents=True, exist_ok=True) + DATA.mkdir(parents=True, exist_ok=True) + env = dict(os.environ) + env["YKXMZ_DATA"] = str(DATA) + + # 先清掉旧进程 + subprocess.run(["powershell", "-NoProfile", "-Command", + "Get-Process ykxmz -ErrorAction SilentlyContinue | " + "Stop-Process -Force"], + capture_output=True, timeout=60) + time.sleep(2) + + print(" 一、清掉旧数据,模拟全新安装(这样才会出设置窗口)") + import shutil + # 设过配对码之后被控端就完全无窗口运行了(这是设计: + # "用户要的是全程无窗口")。所以想拍界面必须清掉数据当首次运行。 + shutil.rmtree(DATA, ignore_errors=True) + DATA.mkdir(parents=True, exist_ok=True) + print(" 清好了") + + print(" 二、启动(首次运行 -> 出「第一次设置」窗口)") + proc = subprocess.Popen([str(EXE), "--port", "8898"], + cwd=str(ROOT), env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + time.sleep(14) + + handles = windows_of(proc.pid) + print(" 找到 %d 个可见窗口" % len(handles)) + if not handles: + print(" [失败] 没有窗口") + proc.terminate() + return 1 + + hwnd = handles[0] + user32.SetForegroundWindow(hwnd) + user32.MoveWindow(hwnd, 80, 80, 520, 600, True) + time.sleep(2) + + out = SHOTS / "pc-main.png" + if capture(hwnd, out): + print(" ✓ %s %.1f KB" % (out.name, out.stat().st_size / 1024)) + # 顺便看看标题 + length = user32.GetWindowTextLengthW(hwnd) + buf = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buf, length + 1) + print(" 窗口标题: %r" % buf.value) + + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + subprocess.run(["powershell", "-NoProfile", "-Command", + "Get-Process ykxmz -ErrorAction SilentlyContinue | " + "Stop-Process -Force"], + capture_output=True, timeout=60) + + print() + print(" 产物:") + for p in sorted(SHOTS.glob("*.png")): + print(" %-24s %6.1f KB" % (p.name, p.stat().st_size / 1024)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/shoot_pc_window.py b/_build/shoot_pc_window.py new file mode 100644 index 0000000..d11c50e --- /dev/null +++ b/_build/shoot_pc_window.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +r'''重新拍电脑端窗口截图(旧图还写着改名前的"局域网遥控 被控端")。 + +顺便拍几张不同状态的,官网上能展示"这东西长什么样"。 + +做法:用 --code 启动(非 --silent,所以会显示窗口), + 然后用 Win32 的 PrintWindow 把窗口内容抓下来。 + 不用截整个屏幕 —— 那样会把桌面上的别的东西也拍进去。 +''' +from __future__ import annotations + +import ctypes +import os +import subprocess +import sys +import time +from ctypes import wintypes +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +SHOTS = ROOT / "_build" / "site_shots" +EXE = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe" + +user32 = ctypes.windll.user32 +gdi32 = ctypes.windll.gdi32 + + +def find_window(title_part: str) -> int: + """按标题片段找窗口句柄。""" + found = [] + + @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM) + def callback(hwnd, _lparam): + length = user32.GetWindowTextLengthW(hwnd) + if length: + buf = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buf, length + 1) + if title_part in buf.value and user32.IsWindowVisible(hwnd): + found.append((hwnd, buf.value)) + return True + + user32.EnumWindows(callback, 0) + return found[0] if found else (0, "") + + +def capture(hwnd: int, path: Path) -> bool: + """用 PrintWindow 抓窗口内容(比截屏干净,不带桌面背景)。""" + rect = wintypes.RECT() + user32.GetWindowRect(hwnd, ctypes.byref(rect)) + width = rect.right - rect.left + height = rect.bottom - rect.top + if width <= 0 or height <= 0: + return False + + hdc_window = user32.GetWindowDC(hwnd) + hdc_mem = gdi32.CreateCompatibleDC(hdc_window) + bitmap = gdi32.CreateCompatibleBitmap(hdc_window, width, height) + gdi32.SelectObject(hdc_mem, bitmap) + + # 2 = PW_RENDERFULLCONTENT,win10+ 抓 DWM 合成内容必须用这个 + ok = user32.PrintWindow(hwnd, hdc_mem, 2) + + class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [("biSize", wintypes.DWORD), + ("biWidth", wintypes.LONG), + ("biHeight", wintypes.LONG), + ("biPlanes", wintypes.WORD), + ("biBitCount", wintypes.WORD), + ("biCompression", wintypes.DWORD), + ("biSizeImage", wintypes.DWORD), + ("biXPelsPerMeter", wintypes.LONG), + ("biYPelsPerMeter", wintypes.LONG), + ("biClrUsed", wintypes.DWORD), + ("biClrImportant", wintypes.DWORD)] + + header = BITMAPINFOHEADER() + header.biSize = ctypes.sizeof(BITMAPINFOHEADER) + header.biWidth = width + header.biHeight = -height # 负数 = 自上而下 + header.biPlanes = 1 + header.biBitCount = 32 + header.biCompression = 0 + + buffer_size = width * height * 4 + buffer = ctypes.create_string_buffer(buffer_size) + gdi32.GetDIBits(hdc_mem, bitmap, 0, height, buffer, ctypes.byref(header), 0) + + # 用 Pillow 存(项目里本来就有) + from PIL import Image + image = Image.frombuffer("RGBA", (width, height), buffer, + "raw", "BGRA", 0, 1) + image.convert("RGB").save(path) + + gdi32.DeleteObject(bitmap) + gdi32.DeleteDC(hdc_mem) + user32.ReleaseDC(hwnd, hdc_window) + return bool(ok) + + +def main() -> int: + SHOTS.mkdir(parents=True, exist_ok=True) + os.environ["YKXMZ_DATA"] = str(ROOT / "_build" / "_shots_data") + Path(os.environ["YKXMZ_DATA"]).mkdir(parents=True, exist_ok=True) + + print(" 启动被控端(会显示窗口)") + env = dict(os.environ) + proc = subprocess.Popen( + [str(EXE), "--code", "789113", "--port", "8899", "--name", "教室电脑"], + cwd=str(ROOT), env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(9) + + hwnd, title = find_window("ykxmz") + if not hwnd: + print(" [失败] 没找到窗口") + proc.terminate() + return 1 + print(" 找到窗口: %r" % title) + + # 拉到屏幕内可见位置,保证 PrintWindow 能拿到内容 + user32.SetForegroundWindow(hwnd) + user32.MoveWindow(hwnd, 60, 60, 420, 460, True) + time.sleep(2) + + out = SHOTS / "pc-main.png" + if capture(hwnd, out): + print(" ✓ 拍了 %s %.1f KB" % (out.name, out.stat().st_size / 1024)) + else: + print(" [失败] 抓取失败") + + proc.terminate() + try: + proc.wait(timeout=8) + except subprocess.TimeoutExpired: + proc.kill() + + print() + print(" 产物:") + for p in sorted(SHOTS.glob("*.png")): + print(" %-24s %6.1f KB" % (p.name, p.stat().st_size / 1024)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/site_preview.html b/_build/site_preview.html index fbae8ef..a44e734 100644 --- a/_build/site_preview.html +++ b/_build/site_preview.html @@ -115,6 +115,68 @@ footer{padding:52px 0 64px;border-top:1px solid #e6e8eb; .foot-links{display:flex;gap:22px} .foot-links a:hover{color:#1a1d21} .copy{color:#9ca3af;font-size:13px;margin-top:26px;text-align:center} + +/* ---------- 产品展示 ---------- */ +.showcase{display:grid;gap:34px;align-items:start; + justify-items:center} +@media(min-width:820px){.showcase{grid-template-columns:1fr 1fr}} + +/* 手表 mockup。尺寸按真实手表屏幕比例 */ +.watch{width:300px;padding:10px;border-radius:34px; + background:linear-gradient(160deg,#2b3038,#14171c); + box-shadow:0 18px 40px rgba(16,24,40,.18); + position:relative} +.watch::after{content:'';position:absolute;right:-3px;top:110px; + width:5px;height:44px;border-radius:3px;background:#3a4048} +.w-screen{background:#F5F7FA;border-radius:26px;overflow:hidden; + font-size:11px;color:#1F2328} +.w-status{display:flex;justify-content:space-between;align-items:center; + padding:11px 13px 7px;font-size:10px;font-weight:600} +.w-conn{color:#16a34a;font-weight:600} +.w-tabs{display:flex;gap:4px;overflow:hidden;padding:0 11px 8px; + border-bottom:1px solid #E3E7ED} +.w-tab{flex:none;padding:3px 8px;border-radius:999px;color:#656D76; + font-size:10px;white-space:nowrap} +.w-tab.on{background:#185CE8;color:#fff;font-weight:600} +.w-body{padding:12px 13px 14px} +.w-head{font-size:12px;font-weight:700;margin-bottom:10px} +.w-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7px} +.w-btn{background:#FFFFFF;border:1px solid #E3E7ED; + border-radius:11px;padding:11px 4px 9px;text-align:center; + display:flex;flex-direction:column;align-items:center;gap:5px} +.w-ic{font-size:15px;line-height:1} +.w-lb{font-size:9px;color:#1F2328;white-space:nowrap} +.w-foot{padding:9px 13px 12px;color:#656D76;font-size:9px; + border-top:1px solid #E3E7ED} + +/* 电脑端截图 */ +.pcshot{margin:0;text-align:center} +.pcshot img{width:100%;max-width:360px;height:auto; + border:1px solid #E3E7ED;border-radius:12px; + box-shadow:0 18px 40px rgba(16,24,40,.14)} +.pcshot figcaption{margin-top:14px;color:#656D76;font-size:13px} + +/* 连接方式示意 */ +.flow{display:grid;gap:18px} +@media(min-width:760px){.flow{grid-template-columns:1fr 1fr}} +.flow-col{border:1px solid #e6e8eb;border-radius:14px;padding:24px 20px; + background:#fafbfc} +.flow-tag{display:inline-block;padding:3px 11px;border-radius:999px; + font-size:12px;font-weight:600;margin-bottom:20px} +.flow-tag.ok{background:#dcfce7;color:#15803d} +.flow-tag.warn{background:#fef3c7;color:#a16207} +.flow-row{display:flex;align-items:center;justify-content:center; + gap:2px;margin-bottom:16px;flex-wrap:nowrap} +.node{display:flex;flex-direction:column;align-items:center;gap:4px; + font-size:22px;flex:none} +.node span{font-size:11px;color:#6b7280} +.node.relay{font-size:19px} +.link{flex:1;min-width:16px;position:relative;display:flex; + align-items:center;justify-content:center} +.link .line{width:100%;height:2px;background:#d1d5db;border-radius:2px} +.link .ms{position:absolute;top:-15px;font-size:10px;color:#15803d; + font-weight:600;white-space:nowrap} +.flow-col p{color:#6b7280;font-size:13px;line-height:1.7;margin:0} @@ -159,6 +221,52 @@ footer{padding:52px 0 64px;border-top:1px solid #e6e8eb;
1
电脑端
在教室那台 Windows 电脑上运行安装包,填一个 6 位配对码,装完自动后台运行
2
手表端
在安卓手表上装 APK,首页点「局域网连接」或「输入配对码连接」
3
连上就用
配对一次之后,以后打开就自动连,不用再输数字
+ +
+
+

长什么样

+

手表上十个分页,常用操作都在第一屏

+
+
+
ykxmz● 已连接 · 局域网 3 ms
媒体屏幕文件语音电脑网络系统教室效率娱乐
媒体
🔊放音频
▶放视频
⏸暂停
⏵继续
+音量 +
−音量 −
🔇静音
⏭下一首
🖼投图到屏幕
被控端:教室电脑 · 192.168.2.64
+
ykxmz 电脑端首次设置界面
电脑端:装完填个 6 位配对码就行
+
+
+ +
+
+

怎么连上的

+

同一个 WiFi 就走局域网直连,不同网络自动切中转

+
+ +
+
+
同一个 WiFi
+
+
⌚手表
+ +
🖥教室电脑
+
+

直连,不经过任何服务器。几十毫秒,画面基本跟手。

+
+ +
+
不在同一个网络
+
+
⌚手表
+ +
☁中转
+ +
🖥电脑
+
+

两边都主动连到中转,绕过 NAT。办公室也能管教室。

+
+
+ +
+ +

下载

diff --git a/_build/site_shots/pc-main.png b/_build/site_shots/pc-main.png new file mode 100644 index 0000000..9e487e6 Binary files /dev/null and b/_build/site_shots/pc-main.png differ diff --git a/_build/swap_admin_page.py b/_build/swap_admin_page.py new file mode 100644 index 0000000..d65d85d --- /dev/null +++ b/_build/swap_admin_page.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +r'''把 relay.py 里那一大坨 _admin_page 换成调用 admin 模块。 + +原来的 _admin_page 从 1489 行一直到文件末尾(600 行内联 HTML+CSS+JS), +全塞在一个 f-string 里,改个颜色要在一堆 {{ }} 转义里找。 +现在只留一个薄封装,真正的页面在 relay/admin.py。 +''' +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +RELAY = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") + / "relay" / "relay.py") + +NEW_METHOD = ''' def _admin_page(self) -> None: + """管理后台。 + + 实际渲染在 relay/admin.py —— 原来几百行 HTML/CSS/JS + 内联在这里的 f-string 里,改一处样式要在一堆转义花括号里翻, + 没法维护。拆出去之后这里只负责把数据递过去。 + """ + from admin import render_admin + snap = RELAY.snapshot() + self._send(200, render_admin( + snap=snap, + public=public_url() or "", + admin_path=ADMIN_PATH, + codes=len(RELAY.codes), + downloads_fn=_list_downloads, + ).encode("utf-8")) +''' + + +def main() -> int: + lines = RELAY.read_text(encoding="utf-8").splitlines(keepends=True) + + # 找到 _admin_page 那一行 + start = None + for index, line in enumerate(lines): + if line.startswith(" def _admin_page(self)"): + start = index + break + if start is None: + print(" [失败] 找不到 _admin_page") + return 1 + print(" _admin_page 在第 %d 行,到文件末尾(%d 行)" + % (start + 1, len(lines))) + + new_lines = lines[:start] + [NEW_METHOD] + text = "".join(new_lines) + RELAY.write_text(text, encoding="utf-8", newline="\n") + + try: + ast.parse(text) + print(" relay.py 语法 OK") + except SyntaxError as exc: + print(" [失败] 行 %s: %s" % (exc.lineno, exc.msg)) + return 1 + + print(" 文件从 %d 行缩到 %d 行" % (len(lines), len(new_lines))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/verify_admin.sh b/_build/verify_admin.sh new file mode 100644 index 0000000..4ecd16d --- /dev/null +++ b/_build/verify_admin.sh @@ -0,0 +1,57 @@ +#!/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" --max-time 20 "$BASE/zhongzhuanzhan" -o /tmp/a.html + +echo " 管理页 HTTP $(curl -s -o /dev/null -w '%{http_code}' -H "X-Admin: $KEY" --max-time 15 "$BASE/zhongzhuanzhan") $(wc -c < /tmp/a.html) 字节" + +echo +echo " 新版设计元素:" +has() { if grep -q "$1" /tmp/a.html; then echo " ✓ $2"; else echo " ✗ $2"; fi; } +has 'class="top"' '吸顶顶栏' +has 'class="hero"' '公网地址区块' +has 'class="nums"' '数字卡' +has 'class="card"' '设备表卡片' +has 'class="tool"' '工具卡' +has 'class="pill on"' '在线徽章' +has 'id="pickAll"' '全选' +has 'id="autoRef"' '自动刷新' + +echo +echo " 功能接口(一个都不能坏):" +for api in "/v1/health" "/v1/downloads" "/v1/admin/netcheck"; do + printf " %-24s HTTP %s\n" "$api" \ + "$(curl -s -o /dev/null -w '%{http_code}' -H "X-Admin: $KEY" --max-time 12 "$BASE$api")" +done +printf " %-24s HTTP %s\n" "删除单台(空id,应400)" \ + "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "X-Admin: $KEY" \ + -H 'Content-Type: application/json' -d '{}' --max-time 12 "$BASE/v1/admin/delete")" +printf " %-24s HTTP %s\n" "批量删除(空数组)" \ + "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "X-Admin: $KEY" \ + -H 'Content-Type: application/json' -d '{"deviceIds":[]}' --max-time 12 "$BASE/v1/admin/delete_many")" + +echo +echo " 其它页面没受影响:" +echo " 官网 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/")" +echo " 官网大小 $(curl -s --max-time 15 "$BASE/" | wc -c) 字节" +echo " 产品截图 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/shot/pc-main.png")" +echo " favicon HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/favicon.ico")" +echo " /admin 跳转 $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/admin")" +echo " 未登录 HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/zhongzhuanzhan")" + +echo +echo " 公网:" +for i in 1 2 3; do + printf " %d: HTTP %s\n" "$i" \ + "$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/)" +done + +echo +echo " relay 有没有报错:" +journalctl -u lanremote-relay --since '2 minutes ago' --no-pager 2>/dev/null \ + | grep -iE 'error|traceback|exception' | tail -5 | sed 's/^/ /' +echo " (没输出=正常)" diff --git a/_build/verify_admin2.sh b/_build/verify_admin2.sh new file mode 100644 index 0000000..254f580 --- /dev/null +++ b/_build/verify_admin2.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# 带登录验证新版管理后台 +set -u + +BASE=http://127.0.0.1:8080 +PASS=$(cat /root/lanremote-relay/data/admin_password.txt 2>/dev/null) + +# 用 cookie 登录(比 X-Admin 头更接近真实使用) +curl -s -c /tmp/ck.txt -o /dev/null --max-time 15 \ + -d "username=xmz7891&password=$PASS" "$BASE/admin/login" + +curl -s -b /tmp/ck.txt --max-time 20 "$BASE/zhongzhuanzhan" -o /tmp/a.html +size=$(wc -c < /tmp/a.html) +code=$(curl -s -b /tmp/ck.txt -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/zhongzhuanzhan") +echo " 管理页 HTTP $code $size 字节" + +echo +echo " 新版设计元素:" +has() { if grep -q "$1" /tmp/a.html; then echo " ✓ $2"; else echo " ✗ $2"; fi; } +has 'class="top"' '吸顶顶栏' +has 'class="state"' '运行中徽章' +has 'class="hero"' '公网地址区块' +has 'id="urlBox"' '地址框' +has 'id="copyUrl"' '复制按钮' +has 'class="nums"' '数字卡' +has 'class="num"' '数字卡元素' +has 'class="card"' '设备表卡片' +has 'class="tool"' '工具卡(体检+下载)' +has 'id="pickAll"' '全选' +has 'id="delPicked"' '删除选中' +has 'id="delAll"' '删除全部' +has 'id="netBtn"' '体检按钮' +has 'id="autoRef"' '自动刷新' +has 'class="pill' '状态徽章' +has 'id="toast"' '提示条' + +echo +echo " 计数:" +printf " 数字卡 %s 个(应 4)\n" "$(grep -o 'class="num"' /tmp/a.html | wc -l)" +printf " 工具卡 %s 个(应 2)\n" "$(grep -o 'class="tool"' /tmp/a.html | wc -l)" +printf " 表格列 %s 个(应 8)\n" "$(grep -o '/dev/null \ + | grep -icE 'traceback|nameerror|error' | sed 's/^/ 错误行数: /' +tail -5 /root/lanremote-relay/relay.log 2>/dev/null | sed 's/^/ /' + +echo +echo " 公网:" +for i in 1 2 3; do + printf " %d: HTTP %s\n" "$i" \ + "$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/)" +done diff --git a/_build/verify_showcase.sh b/_build/verify_showcase.sh new file mode 100644 index 0000000..0913809 --- /dev/null +++ b/_build/verify_showcase.sh @@ -0,0 +1,55 @@ +#!/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) + +curl -s --max-time 15 "$BASE/" -o /tmp/s.html +size=$(wc -c < /tmp/s.html) +code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/") +echo " 官网 HTTP $code $size 字节" + +echo +echo " 新增区块:" +has() { if grep -q "$1" /tmp/s.html; then echo " ✓ $2"; else echo " ✗ $2"; fi; } +has 'id="shots"' '产品展示(长什么样)' +has 'class="watch"' '手表界面 mockup' +has 'w-tab' '手表标签条' +has '/shot/pc-main.png' '电脑端真实截图' +has 'id="how-connect"' '怎么连上的' +has 'class="flow"' '连接方式示意' +has '局域网 3 ms' '局域网延迟标注' + +echo +echo " 区块计数:" +printf " 手表标签 %s 个(应为 10)\n" "$(grep -o 'w-tab' /tmp/s.html | wc -l)" +printf " 手表按钮 %s 个(应为 9)\n" "$(grep -o 'class="w-btn"' /tmp/s.html | wc -l)" +printf " 连接示意 %s 列(应为 2)\n" "$(grep -o 'class="flow-col"' /tmp/s.html | wc -l)" + +echo +echo " 原有区块还在吗:" +for pair in "吸顶导航:nav-in" "Hero:hero" "功能网格:id=\"features\"" \ + "三步上手:id=\"how\"" "下载区:id=\"download\"" "FAQ:id=\"faq\""; do + has "${pair#*:}" "${pair%%:*}" +done + +echo +echo " 截图路由:" +echo " /shot/pc-main.png HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/shot/pc-main.png") $(curl -s -o /dev/null -w '%{size_download}' --max-time 15 "$BASE/shot/pc-main.png") 字节" +echo " 路径穿越 /shot/../relay.py HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/shot/../relay.py")" +echo " 非图片 /shot/x.txt HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/shot/x.txt")" + +echo +echo " 其它功能:" +echo " /v1/health HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/v1/health")" +echo " /zhongzhuanzhan HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/zhongzhuanzhan")" +echo " /v1/downloads HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/v1/downloads")" +echo " health 大小 $(curl -s --max-time 10 "$BASE/v1/health" | wc -c) 字节" + +echo +echo " 公网:" +for i in 1 2 3 4 5; do + printf " %d: HTTP %s\n" "$i" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H 'User-Agent: LanRemote/1.4.0' "$PUB/")" +done diff --git a/_build/wire_showcase.py b/_build/wire_showcase.py new file mode 100644 index 0000000..7251d72 --- /dev/null +++ b/_build/wire_showcase.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +r'''把产品展示区块接进官网,并把截图传上去。''' +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +LANDING = ROOT / "relay" / "landing.py" +RELAY = ROOT / "relay" / "relay.py" +sys.path.insert(0, str(ROOT / "_build")) + +from devssh import run, upload # noqa: E402 + + +def wire_landing() -> None: + text = LANDING.read_text(encoding="utf-8") + + if "showcase" in text: + print(" landing.py 已经接过 showcase") + return + + # 1) 引入 + text = text.replace( + "from urllib.parse import quote", + "from urllib.parse import quote\n\n" + "import showcase", 1) + + # 2) CSS 拼进去 + marker = '""" % {"BG": BG, "SURFACE": SURFACE, "BORDER": BORDER, "TEXT": TEXT,' + if marker in text: + text = text.replace( + marker, + '""" + showcase.SHOWCASE_CSS % {"BG": BG, "SURFACE": SURFACE,\n' + ' "BORDER": BORDER, "TEXT": TEXT,', 1) + print(" CSS 已拼接") + else: + print(" [警告] CSS 锚点没找到,跳过") + + # 3) HTML 区块插到"下载"之前 + anchor = '
' + if anchor in text: + text = text.replace( + anchor, + '%(showcase)s\n\n' + anchor, 1) + # 4) 渲染参数里加上 + text = text.replace( + ' "dls": _download_cards(files),', + ' "showcase": showcase.render_showcase(),\n' + ' "dls": _download_cards(files),', 1) + print(" HTML 区块已插入") + else: + print(" [警告] download 区块锚点没找到") + + LANDING.write_text(text, encoding="utf-8", newline="\n") + try: + ast.parse(text) + print(" landing.py 语法 OK") + except SyntaxError as exc: + print(" [失败] 语法错误 行 %s: %s" % (exc.lineno, exc.msg)) + lines = text.splitlines() + for i in range(max(0, (exc.lineno or 1) - 3), + min(len(lines), (exc.lineno or 1) + 2)): + print(" %d: %s" % (i + 1, lines[i][:92])) + + +def wire_relay_route() -> None: + """加一条 /shot/ 路由,把截图发出去(白名单后缀)。""" + text = RELAY.read_text(encoding="utf-8") + if "/shot/" in text: + print(" relay.py 已经有 /shot/ 路由") + return + + # 找下载路由附近插入 + anchor = 'if path.startswith("/download/"):' + if anchor not in text: + print(" [警告] 找不到 /download/ 路由锚点,手工看下") + return + + block = ''' # 官网用的产品截图。 + # 单独一条路由(不放进下载区),只允许 png/jpg/webp。 + if path.startswith("/shot/"): + name = path[len("/shot/"):] + if (not name or ".." in name or "/" in name or "\\\\" in name + or not name.lower().endswith((".png", ".jpg", ".jpeg", + ".webp"))): + self._json(404, {"ok": False, "error": "没有这张图"}) + return + target = Path(__file__).resolve().parent / "site_shots" / name + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这张图"}) + return + body = target.read_bytes() + ctype = ("image/png" if name.lower().endswith(".png") + else "image/jpeg") + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "public, max-age=86400") + self.end_headers() + self.wfile.write(body) + return + +''' + text = text.replace(" " + anchor, block + " " + anchor, 1) + RELAY.write_text(text, encoding="utf-8", newline="\n") + try: + ast.parse(text) + print(" relay.py 语法 OK") + except SyntaxError as exc: + print(" [失败] relay.py 语法错误 行 %s: %s" % (exc.lineno, exc.msg)) + + +def deploy() -> None: + print() + print(" 部署") + run("mkdir -p /root/lanremote-relay/site_shots", timeout=60) + upload(str(ROOT / "_build" / "site_shots" / "pc-main.png"), + "/root/lanremote-relay/site_shots/pc-main.png", 0o644) + print(" 截图已上传") + upload(str(LANDING), "/root/lanremote-relay/landing.py", 0o644) + upload(str(ROOT / "relay" / "showcase.py"), + "/root/lanremote-relay/showcase.py", 0o644) + upload(str(RELAY), "/root/lanremote-relay/relay.py", 0o755) + print(" 代码已上传") + run("systemctl restart lanremote-relay", timeout=90) + out = run("sleep 6; curl -s -o /dev/null -w 'HTTP %{http_code} %{size_download}\\n' " + "http://127.0.0.1:8080/; " + "curl -s -o /dev/null -w '截图 HTTP %{http_code} %{size_download}\\n' " + "http://127.0.0.1:8080/shot/pc-main.png", timeout=120)[1] + print(" " + out.replace("\n", "\n ")) + + +def main() -> int: + wire_landing() + wire_relay_route() + deploy() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/relay/admin.py b/relay/admin.py new file mode 100644 index 0000000..8fce63c --- /dev/null +++ b/relay/admin.py @@ -0,0 +1,640 @@ +# -*- coding: utf-8 -*- +r'''中转站管理后台的页面渲染(独立模块)。 + +为什么拆出来: + 原来这一大坨内联在 relay.py 里(几百行 f-string + CSS + JS), + 改一处样式要在一堆转义的花括号里找,改不动也没法看。 + 拆成模块后:CSS 是普通字符串、HTML 用普通格式化,清爽多了。 + +设计目标(用户反馈"难看的要死"): + * 和官网共用一套设计语言:白底、浅灰面板、细边框、克制的蓝 + * 信息分层次: + 顶部栏(我是谁、什么状态) + -> 公网地址(最该被看到、要复制的东西,给最大权重) + -> 四个数字(一眼看全局) + -> 设备表(主内容) + -> 工具卡(体检、下载,次要,放最后) + * 表格:状态用圆点+文字,配对码等宽字体,行悬停高亮 + * 空状态要友好,不是干巴巴一句"没有数据" + +功能一个都不能少(对应用户能做的事): + 单台删除 / 勾选批量删除 / 删除全部 / 复制公网地址 / + 网络体检 / 下载最新版 / 自动刷新开关 +''' +from __future__ import annotations + +# ---------------- 设计令牌(和官网一致) ---------------- +BG = "#ffffff" +SURFACE = "#fafbfc" +BORDER = "#e6e8eb" +BORDER_SOFT = "#f0f2f5" +TEXT = "#1a1d21" +MUTED = "#6b7280" +FAINT = "#9ca3af" +ACCENT = "#2563eb" +OK = "#16a34a" +WARN = "#d97706" +DANGER = "#dc2626" + +TITLE = "ykxmz 中转站" + +ADMIN_CSS = """ +*{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg:#ffffff; --surface:#fafbfc; --border:#e6e8eb; --border-soft:#f0f2f5; + --fg:#1a1d21; --muted:#6b7280; --faint:#9ca3af; + --accent:#2563eb; --ok:#16a34a; --warn:#d97706; --danger:#dc2626; +} +html,body{max-width:100%%;overflow-x:hidden} +body{background:var(--bg);color:var(--fg);line-height:1.6; + font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei', + 'PingFang SC',sans-serif;-webkit-font-smoothing:antialiased} +a{color:inherit} +.wrap{width:100%%;max-width:1040px;margin:0 auto;padding:0 20px 64px} + +/* ---------- 吸顶顶栏 ---------- */ +.top{position:sticky;top:0;z-index:40;background:rgba(255,255,255,.88); + backdrop-filter:saturate(180%%) blur(12px);border-bottom:1px solid var(--border)} +.top-in{display:flex;align-items:center;gap:12px;height:62px} +.logo{width:32px;height:32px;border-radius:9px;flex:none; + background:linear-gradient(140deg,#2563eb,#1d4ed8); + display:flex;align-items:center;justify-content:center; + color:#fff;font-weight:700;font-size:12px;letter-spacing:-.3px} +.brand b{display:block;font-size:15px;font-weight:650;letter-spacing:-.2px} +.brand span{display:block;color:var(--faint);font-size:11px} +.state{margin-left:14px;display:inline-flex;align-items:center;gap:6px; + padding:3px 10px;border-radius:999px;background:#f0fdf4; + color:#15803d;font-size:11px;font-weight:600} +.state i{width:6px;height:6px;border-radius:50%%;background:#22c55e; + display:block} +.top-right{margin-left:auto;text-align:right;font-size:11px; + color:var(--faint);line-height:1.7} +.top-right b{color:var(--muted);font-weight:500} +.logout{color:var(--muted);text-decoration:none; + border-bottom:1px dashed var(--border);transition:.15s} +.logout:hover{color:var(--danger);border-bottom-color:var(--danger)} + +/* ---------- 公网地址(最重要,给最大权重)---------- */ +.hero{padding:30px 0 26px} +.hero h2{font-size:13px;font-weight:600;color:var(--muted); + margin-bottom:10px;letter-spacing:.2px} +.urlbox{display:flex;align-items:stretch;gap:10px;flex-wrap:wrap} +.url{flex:1;min-width:240px;display:flex;align-items:center; + font:600 16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace; + color:var(--accent);word-break:break-all; + background:var(--surface);border:1px solid var(--border); + border-radius:11px;padding:14px 16px} +.url.empty{color:var(--faint);font-weight:400;font-size:14px} +.hint{color:var(--faint);font-size:12px;margin-top:9px} + +/* ---------- 数字卡 ---------- */ +.nums{display:grid;gap:10px;grid-template-columns:repeat(2,1fr); + margin-bottom:30px} +@media(min-width:640px){.nums{grid-template-columns:repeat(4,1fr)}} +.num{background:var(--surface);border:1px solid var(--border); + border-radius:12px;padding:15px 16px} +.num .v{font-size:25px;font-weight:700;letter-spacing:-.7px; + line-height:1.1;font-variant-numeric:tabular-nums} +.num .k{color:var(--muted);font-size:12px;margin-top:3px} +.num.live{background:#f0fdf4;border-color:#bbf7d0} +.num.live .v{color:#15803d} +.num.busy{background:#fffbeb;border-color:#fde68a} +.num.busy .v{color:#b45309} + +/* ---------- 区块标题 ---------- */ +.sec{display:flex;align-items:center;gap:12px;margin-bottom:12px} +.sec h3{font-size:15px;font-weight:650;letter-spacing:-.2px} +.sec .right{margin-left:auto;display:flex;align-items:center;gap:12px} + +/* ---------- 表格 ---------- */ +.card{background:#fff;border:1px solid var(--border);border-radius:13px; + overflow:hidden} +table{width:100%%;border-collapse:collapse;font-size:13px} +thead th{background:var(--surface);text-align:left;font-weight:600; + color:var(--muted);font-size:11.5px;letter-spacing:.3px; + padding:11px 14px;border-bottom:1px solid var(--border); + white-space:nowrap} +tbody td{padding:13px 14px;border-bottom:1px solid var(--border-soft); + vertical-align:middle} +tbody tr:last-child td{border-bottom:none} +tbody tr:hover td{background:#fbfcfd} +tbody tr.picked td{background:#eff6ff} +.dot{width:7px;height:7px;border-radius:50%%;display:inline-block; + margin-right:7px;flex:none} +.dot.on{background:#22c55e} +.dot.off{background:#d1d5db} +.devname{display:flex;align-items:center;font-weight:600} +.devid{color:var(--faint);font-size:10.5px;margin-top:2px; + font-family:ui-monospace,Consolas,monospace; + overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:260px} +code{font:600 13px/1 ui-monospace,Consolas,monospace; + background:var(--surface);border:1px solid var(--border); + border-radius:6px;padding:4px 8px;letter-spacing:.5px} +.pill{padding:3px 9px;border-radius:999px;font-size:11px;font-weight:600} +.pill.on{background:#dcfce7;color:#15803d} +.pill.off{background:#f3f4f6;color:#6b7280} +.mono{font-family:ui-monospace,Consolas,monospace;font-size:12px; + color:var(--muted)} +.ops{text-align:right} +.empty{padding:56px 20px;text-align:center} +.empty .big{font-size:15px;font-weight:600;margin-bottom:8px} +.empty p{color:var(--muted);font-size:13px;max-width:340px;margin:0 auto} +.empty .ico{font-size:30px;margin-bottom:14px;opacity:.35} + +/* ---------- 按钮 ---------- */ +button{font-family:inherit;font-size:13px;cursor:pointer; + border-radius:9px;padding:8px 15px;border:1px solid var(--border); + background:#fff;color:var(--text);transition:.14s; + font-weight:500} +button:hover:not(:disabled){background:var(--surface); + border-color:#d1d5db} +button:active:not(:disabled){transform:translateY(1px)} +button:disabled{opacity:.42;cursor:not-allowed} +button.primary{background:var(--accent);border-color:var(--accent); + color:#fff;font-weight:600} +button.primary:hover:not(:disabled){background:#1d4ed8; + border-color:#1d4ed8} +button.danger{color:#b91c1c;border-color:#fecaca;background:#fff} +button.danger:hover:not(:disabled){background:#fef2f2; + border-color:#fca5a5} +button.sm{padding:6px 12px;font-size:12px} + +/* ---------- 工具卡 ---------- */ +.tools{display:grid;gap:14px;margin-top:30px} +@media(min-width:720px){.tools{grid-template-columns:1fr 1fr}} +.tool{border:1px solid var(--border);border-radius:13px;padding:18px 18px} +.tool h4{font-size:13.5px;font-weight:650;margin-bottom:4px} +.tool .d{color:var(--muted);font-size:12.5px;margin-bottom:14px} +.tool .out{color:var(--muted);font-size:12.5px;line-height:1.85; + word-break:break-all} +.tool .out b{color:var(--fg);font-weight:600} +.tool .out .good{color:#15803d;font-weight:600} +.tool .out .bad{color:#b91c1c;font-weight:600} +.dlrow{display:flex;align-items:center;gap:10px;padding:7px 0; + border-bottom:1px solid var(--border-soft);font-size:12.5px} +.dlrow:last-child{border-bottom:none} +.dlrow .nm{flex:1;overflow:hidden;text-overflow:ellipsis; + white-space:nowrap;font-family:ui-monospace,Consolas,monospace; + font-size:11.5px} +.dlrow .sz{color:var(--faint);font-variant-numeric:tabular-nums} + +/* ---------- 底部说明 ---------- */ +.foot{margin-top:36px;padding-top:20px;border-top:1px solid var(--border); + color:var(--faint);font-size:11.5px;line-height:1.9} +.foot b{color:var(--muted);font-weight:600} + +/* ---------- 提示条 ---------- */ +#toast{position:fixed;left:50%%;bottom:30px;transform:translateX(-50%%) + translateY(20px);background:#1a1d21;color:#fff;padding:11px 20px; + border-radius:10px;font-size:13px;opacity:0;pointer-events:none; + transition:.22s;z-index:99;max-width:86vw;text-align:center} +#toast.show{opacity:1;transform:translateX(-50%%) translateY(0)} +#toast.ok{background:#15803d} +#toast.err{background:#b91c1c} + +/* ---------- 窄屏:表格变卡片 ---------- */ +@media(max-width:700px){ + thead{display:none} + tbody tr{display:block;border-bottom:1px solid var(--border); + padding:12px 14px} + tbody td{display:flex;justify-content:space-between;gap:14px; + padding:5px 0;border:none} + tbody td::before{content:attr(data-l);color:var(--faint);font-size:11.5px; + flex:none} + td.pickcell::before, + td.ops::before{display:none} + td.pickcell{padding-bottom:8px} + td.ops{justify-content:flex-end;padding-top:10px; + border-top:1px solid var(--border-soft)} + td.empty{display:block;text-align:center} + td.empty::before{display:none} + .devid{max-width:200px} +} +""" % {} + + +def _esc(value) -> str: + """HTML 转义。""" + text = "" if value is None else str(value) + return (text.replace("&", "&").replace("<", "<") + .replace(">", ">").replace('"', """)) + + +def _uptime(seconds) -> str: + try: + total = int(seconds) + except (TypeError, ValueError): + return "—" + if total < 60: + return "%d 秒" % total + if total < 3600: + return "%d 分" % (total // 60) + if total < 86400: + return "%d 小时 %d 分" % (total // 3600, (total % 3600) // 60) + return "%d 天 %d 小时" % (total // 86400, (total % 86400) // 3600) + + +def _kb(value) -> str: + try: + n = float(value) + except (TypeError, ValueError): + return "0" + if n < 1024: + return "%d B" % n + if n < 1024 * 1024: + return "%.0f KB" % (n / 1024) + return "%.1f MB" % (n / 1024 / 1024) + + +def _device_rows(devices: list) -> str: + if not devices: + # 图标用内联 SVG 而不是 emoji —— + # emoji 依赖系统字体,无头浏览器/精简系统里会渲染成方框。 + return ('' + '' + '' + '' + '
还没有电脑连上来
' + '

在教室那台电脑上双击 ykxmz.exe 设好 6 位配对码,' + '手表上输这 6 位数字就能连上。

' + '') + + out = [] + for device in devices: + online = device["online"] + name = _esc(device["name"]) + did = _esc(device["deviceId"]) + code = _esc(device["code"]) + out.append( + '' + '' + '' + '' + '
' + '%s
' + '
%s
' + '%s' + '%s' + '%ss' + '%d / %d' + '%s / %s' + '' + '' % ( + did, name, + "on" if online else "off", name, did, + code, + "on" if online else "off", "在线" if online else "离线", + device["idleSeconds"], + device["queuedToClient"], device["queuedToAgent"], + _kb(device["bytesIn"]), _kb(device["bytesOut"]), + did, name, code) + ) + return "".join(out) + + +ADMIN_JS = """ +const toast = (msg, kind) => { + const el = document.getElementById('toast'); + el.textContent = msg; + el.className = kind ? ('show ' + kind) : 'show'; + clearTimeout(el._t); + el._t = setTimeout(() => { el.className = ''; }, 2800); +}; + +const post = async (path, payload) => { + const r = await fetch(path, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(payload || {}) + }); + return r.json(); +}; + +// ---------------- 单台删除 ---------------- +document.querySelectorAll('button.danger[data-device]').forEach(b => { + b.onclick = async () => { + const name = b.dataset.name, code = b.dataset.code; + if (!confirm('删除设备「' + name + '」(配对码 ' + code + ')?\\n\\n' + + '这会:\\n 1. 断开它现在的手表连接\\n' + + ' 2. 从服务器删掉这台设备\\n' + + ' 3. 清掉它的配对码绑定')) return; + try { + const j = await post('/v1/admin/delete', {deviceId: b.dataset.device}); + if (j.ok) { + toast('已删除「' + (j.name || name) + '」,清掉配对码 ' + + (j.codesCleared || []).length + ' 条', 'ok'); + setTimeout(() => location.reload(), 700); + } else { + toast('删除失败:' + (j.error || '未知错误'), 'err'); + } + } catch (e) { + toast('请求出错:' + e, 'err'); + } + }; +}); + +// ---------------- 批量选择 ---------------- +const picks = () => Array.from(document.querySelectorAll('input.pick')); +const picked = () => picks().filter(c => c.checked).map(c => c.value); + +function refreshPickState() { + const chosen = picked().length; + const btn = document.getElementById('delPicked'); + if (btn) { + btn.disabled = chosen === 0; + btn.textContent = chosen ? ('删除选中(' + chosen + ')') : '删除选中'; + } + picks().forEach(c => { + const tr = c.closest('tr'); + if (tr) tr.classList.toggle('picked', c.checked); + }); + const all = document.getElementById('pickAll'); + if (all) { + const total = picks().length; + all.checked = total > 0 && chosen === total; + all.indeterminate = chosen > 0 && chosen < total; + } +} + +picks().forEach(c => c.addEventListener('change', refreshPickState)); +const pickAll = document.getElementById('pickAll'); +if (pickAll) pickAll.addEventListener('change', () => { + picks().forEach(c => { c.checked = pickAll.checked; }); + refreshPickState(); +}); +refreshPickState(); + +// ---------------- 批量删除 ---------------- +const delPicked = document.getElementById('delPicked'); +if (delPicked) delPicked.onclick = async () => { + const ids = picked(); + if (!ids.length) return; + if (!confirm('删除选中的 ' + ids.length + ' 台设备?\\n\\n' + + '会断开它们现在的手表连接,并清掉它们占的配对码。')) return; + delPicked.disabled = true; + try { + const j = await post('/v1/admin/delete_many', {deviceIds: ids}); + if (j.ok) { + toast('已删除 ' + j.deleted + ' 台设备' + + (j.failed ? (',' + j.failed + ' 台失败') : ''), 'ok'); + setTimeout(() => location.reload(), 700); + } else { + toast('删除失败:' + (j.error || '未知错误'), 'err'); + delPicked.disabled = false; + } + } catch (e) { + toast('请求出错:' + e, 'err'); + delPicked.disabled = false; + } +}; + +// ---------------- 删除全部 ---------------- +const delAll = document.getElementById('delAll'); +if (delAll) delAll.onclick = async () => { + if (!confirm('删除全部设备(连同所有配对码)?\\n\\n' + + '清场用,删完所有电脑都要重新设码。')) return; + try { + const j = await post('/v1/admin/delete_all'); + toast('已删除 ' + (j.deleted || 0) + ' 台设备', 'ok'); + setTimeout(() => location.reload(), 700); + } catch (e) { + toast('请求出错:' + e, 'err'); + } +}; + +// ---------------- 复制公网地址 ---------------- +const copyUrl = document.getElementById('copyUrl'); +if (copyUrl) copyUrl.onclick = async () => { + const text = document.getElementById('urlBox').textContent.trim(); + try { + await navigator.clipboard.writeText(text); + toast('地址已复制', 'ok'); + } catch (e) { + // 非安全上下文(http 且不是 localhost)没有 clipboard API, + // 退回到老办法:选中 + execCommand + const range = document.createRange(); + range.selectNodeContents(document.getElementById('urlBox')); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + try { document.execCommand('copy'); toast('地址已复制', 'ok'); } + catch (e2) { toast('复制失败,请手动选中', 'err'); } + } +}; + +// ---------------- 网络体检 ---------------- +const netBtn = document.getElementById('netBtn'); +if (netBtn) netBtn.onclick = async () => { + const body = document.getElementById('netBody'); + body.textContent = '正在测…'; + try { + const r = await fetch('/v1/admin/netcheck'); + const j = await r.json(); + if (!j.ok) { + body.innerHTML = '读不到:' + + (j.error || '未知') + ''; + return; + } + const rows = []; + if (j.tunnelMetrics) { + const conns = j.tunnelConnections || 0; + const cls = conns >= 2 ? 'good' : 'bad'; + rows.push('隧道连接数:' + conns + '' + + (conns === 0 ? '(断了,看门狗会自动重启)' : '')); + rows.push('隧道累计请求:' + (j.tunnelRequests || 0) + ''); + } else { + rows.push('读不到隧道指标' + + (j.tunnelError ? '(' + j.tunnelError + ')' : '') + ''); + } + rows.push('登记设备:' + j.devices + '(在线 ' + j.online + + ')'); + rows.push('手表会话:' + j.sessions + ''); + rows.push('中转内存:' + Math.round(j.memoryKB / 1024) + ' MB'); + rows.push('已运行:' + Math.round(j.uptimeSeconds / 60) + ' 分钟'); + if (j.publicUrl) { + rows.push('公网地址:' + j.publicUrl + ''); + } + body.innerHTML = rows.map(t => '
' + t + '
').join(''); + // 顺便测浏览器到中转的往返 + const t0 = performance.now(); + try { + await fetch('/v1/health?t=' + Date.now(), {cache: 'no-store'}); + const ms = Math.round(performance.now() - t0); + body.innerHTML += '
浏览器到中转往返:' + ms + ' ms' + + (ms < 300 ? '(很顺)' : (ms < 1000 ? '(一般)' : '(有点慢)')) + + '
'; + } catch (e) { + body.innerHTML += '
浏览器到中转:失败
'; + } + } catch (e) { + body.textContent = '请求出错:' + e; + } +}; + +// ---------------- 自动刷新 ---------------- +const autoRef = document.getElementById('autoRef'); +let autoTimer = null; +function setupAuto() { + clearInterval(autoTimer); + if (autoRef && autoRef.checked) { + autoTimer = setInterval(() => { + // 有人正在勾选/删除时别刷新,免得打断操作 + if (picked().length === 0 && !document.hidden) location.reload(); + }, 10000); + } +} +if (autoRef) { + autoRef.addEventListener('change', () => { + try { + localStorage.setItem('lr_autoref', autoRef.checked ? '1' : '0'); + } catch (e) {} + setupAuto(); + }); + try { + if (localStorage.getItem('lr_autoref') === '0') autoRef.checked = false; + } catch (e) {} + setupAuto(); +} +""" + + +def render_admin(snap: dict, public: str, admin_path: str, codes: int, + downloads_fn) -> str: + """渲染管理后台。 + + snap RELAY.snapshot() 的结果 + public 公网地址(空串表示隧道没起来) + admin_path 管理页路径(用于退出登录链接) + codes 当前占用的配对码数 + downloads_fn 取下载列表的函数(在 relay.py 里) + """ + devices = snap.get("devices", []) + online_count = sum(1 for d in devices if d.get("online")) + url_text = public or "" + url_class = "url empty" if not url_text else "url" + url_display = _esc(url_text) if url_text else "还没有地址 —— 隧道可能没起来" + sessions = snap.get("sessions", 0) + + # 下载列表 + try: + files = downloads_fn() + except Exception: # noqa: BLE001 + files = [] + if files: + dl_rows = "".join( + '
%s' + '%s
' + % (_esc(f["name"]), _kb(f["size"])) + for f in files[:8]) + else: + dl_rows = ('
还没有上传任何安装包。' + '把文件放到中转的 downloads 目录即可。
') + + return """ + + + + +%(title)s + + + +
+ +
+ ykxmz 中转站 + 跨网络中转 · 管理后台 +
+ 运行中 +
+
版本 %(ver)s · 已运行 %(up)s · 内存 %(mem)s MB
+
累计请求 %(req)s · 退出登录
+
+
+ +
+ +
+

公网地址 —— 手表「中转设置」里填这个

+
+
%(url)s
+ +
+
手表和电脑不在同一个网络时,两边都通过这个地址中转。
+
+ +
+
%(ndev)s
登记设备
+
%(non)s
在线
+
%(nses)s
手表会话
+
%(ncodes)s
配对码占用
+
+ +
+

设备

+
+ + + +
+
+ +
+ + + + + + + +%(rows)s + +
设备配对码状态空闲待发 表/机流量 入/出
+
+ +
+
+

网络体检

+
看看这台机器是走局域网还是走隧道。
+
还没测过。
+
+
+
+

下载区

+
官网下载按钮指向这里的文件。
+
%(dl)s
+
+
+ +
+ 隐私说明 —— 数据来自 /v1/health,只显示配对码和流量数字, + 看不到任何传输内容。中转只转发字节,不解析、不存储会话内容。 +
+
+ +
+ +""" % { + "title": TITLE, "css": ADMIN_CSS, "js": ADMIN_JS, + "ver": _esc(snap.get("version", "?")), + "up": _uptime(snap.get("uptimeSeconds", 0)), + "mem": int(snap.get("memoryKB", 0)) // 1024, + "req": snap.get("totalRequests", 0), + "admin": _esc(admin_path), + "urlclass": url_class, "url": url_display, + "dis": "" if url_text else " disabled", + "ndev": len(devices), "non": online_count, + "nses": sessions, "ncodes": codes, + "oncls": "live" if online_count else "", + "sescls": "busy" if sessions else "", + "rows": _device_rows(devices), + "dl": dl_rows, + } diff --git a/relay/landing.py b/relay/landing.py index 58883bd..cb83c5c 100644 --- a/relay/landing.py +++ b/relay/landing.py @@ -20,6 +20,8 @@ from __future__ import annotations import re from urllib.parse import quote +import showcase + # ---------------- 设计令牌 ---------------- BG = "#ffffff" SURFACE = "#fafbfc" @@ -334,6 +336,13 @@ footer{padding:52px 0 64px;border-top:1px solid %(BORDER)s; """ % {"BG": BG, "SURFACE": SURFACE, "BORDER": BORDER, "TEXT": TEXT, "MUTED": MUTED, "FAINT": FAINT, "ACCENT": ACCENT} +# 产品展示那段的样式在 showcase.py 里,它自己已经格式化好了, +# 这里直接拼 —— **不能再套一层 %**。 +# (踩过的坑:写成了 `""" + showcase.SHOWCASE_CSS % {...}`, +# % 优先级高于 +,于是把已经格式化过的 CSS 又格式化一遍, +# 报 "unsupported format character ';'" 直接把官网打挂。) +SITE_CSS = SITE_CSS + showcase.SHOWCASE_CSS + def render_site(files: list) -> str: """渲染官网首页。files 是 _list_downloads() 的结果。""" @@ -359,6 +368,8 @@ def render_site(files: list) -> str:
+%(showcase)s +

下载

@@ -432,6 +445,7 @@ document.querySelectorAll('a[href^="#"]').forEach(function (a) { "badge": '
%s
' % badge if badge else "", "feats": _feature_cards(), "steps": _step_cards(), + "showcase": showcase.render_showcase(), "dls": _download_cards(files), "faq": _faq_items(), "_dlsub": ("当前版本 " + version) if version diff --git a/relay/relay.py b/relay/relay.py index 934eaff..da3bd32 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -186,6 +186,15 @@ def _list_downloads() -> list: ADMIN_SESSIONS: dict = {} ADMIN_SESSION_SECONDS = 12 * 3600 +# 管理后台的路径。 +# 用户要求官网放 /(公开),管理后台另开一个不那么好猜的路径。 +# +# 之前这个常量其实**从来没定义过** —— 路由里写的是字面量 +# "/zhongzhuanzhan",而新写的 _admin_page 引用了 ADMIN_PATH, +# 结果一访问管理页就 NameError(页面 000)。 +# 现在统一定义,路由和页面都引用它,避免再次不一致。 +ADMIN_PATH = "/zhongzhuanzhan" + def admin_password() -> str: global ADMIN_PASSWORD @@ -671,6 +680,16 @@ class Relay: if s.device_id == did]: self.sessions.pop(sid, None) + # 配对码映射也要一起清。 + # 它和 devices 是一对孪生 dict,只清一个的话, + # codes 里会攒下一堆指向已删设备的孤儿条目 —— + # 实测管理页显示"配对码占用 131",实际一台设备都没有。 + # 指向已不存在设备的码 = 废码,直接扔。 + orphan = [code for code, owner in self.codes.items() + if owner not in self.devices] + for code in orphan: + self.codes.pop(code, None) + devices = [] for device in self.devices.values(): sessions = [s for s in self.sessions.values() @@ -1071,6 +1090,30 @@ class Handler(BaseHTTPRequestHandler): self._json(200, {"ok": True, "files": _list_downloads()}) return + # 官网用的产品截图。 + # 单独一条路由(不放进下载区),只允许 png/jpg/webp。 + if path.startswith("/shot/"): + name = path[len("/shot/"):] + if (not name or ".." in name or "/" in name or "\\" in name + or not name.lower().endswith((".png", ".jpg", ".jpeg", + ".webp"))): + self._json(404, {"ok": False, "error": "没有这张图"}) + return + target = Path(__file__).resolve().parent / "site_shots" / name + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这张图"}) + return + body = target.read_bytes() + ctype = ("image/png" if name.lower().endswith(".png") + else "image/jpeg") + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "public, max-age=86400") + self.end_headers() + self.wfile.write(body) + return + if path.startswith("/download/"): wanted = urllib.parse.unquote(path[len("/download/"):]) safe = _safe_download_name(wanted) @@ -1218,10 +1261,10 @@ class Handler(BaseHTTPRequestHandler): # 挪到 /zhongzhuanzhan(用户要求)。 # /admin 保留成跳转,免得存过书签的人打不开。 if path == "/admin": - self._redirect("/zhongzhuanzhan") + self._redirect(ADMIN_PATH) return - if path == "/zhongzhuanzhan": + if path == ADMIN_PATH: # 管理页要登录 —— 上面会显示配对码,不能谁都能看 if not self._admin_gate(): self._send(200, _login_page( @@ -1340,12 +1383,12 @@ class Handler(BaseHTTPRequestHandler): if not token: log("管理页登录失败(%s),来自 %s" % (username or "未填账号", self._client_ip())) - self._redirect("/zhongzhuanzhan?bad=1") + self._redirect(ADMIN_PATH + "?bad=1") return log("管理页登录成功(%s),来自 %s" % (username, self._client_ip())) self.send_response(302) - self.send_header("Location", "/zhongzhuanzhan") + self.send_header("Location", ADMIN_PATH) self.send_header( "Set-Cookie", f"lr_admin={token}; Path=/; HttpOnly; SameSite=Lax; " @@ -1462,552 +1505,32 @@ class Handler(BaseHTTPRequestHandler): # ---------------- 管理页 ---------------- def _admin_page(self) -> None: + """管理后台。 + + 实际渲染在 relay/admin.py —— 原来几百行 HTML/CSS/JS + 内联在这里的 f-string 里,改一处样式要在一堆转义花括号里翻, + 没法维护。拆出去之后这里只负责把数据递过去。 + """ + from admin import render_admin snap = RELAY.snapshot() - rows = [] - for device in snap["devices"]: - online = device["online"] - badge = ("在线" if online - else "离线") - rows.append( - "" - "" - f'' - "" - # data-l 是给窄屏用的:表格变成卡片时, - # 每个值前面用 ::before 显示这个标签(见 CSS 的 @media) - f'' - f'' - f'{_esc(device["name"])}' - f'
{_esc(device["deviceId"])}
' - f'' - f'{_esc(device["code"])}' - f'{badge}' - f'' - f'{device["idleSeconds"]}s' - f'' - f'{device["queuedToClient"]} / ' - f'{device["queuedToAgent"]}' - f'' - f'{_kb(device["bytesIn"])} / {_kb(device["bytesOut"])}' - f'' - '' - "") + self._send(200, render_admin( + snap=snap, + public=public_url() or "", + admin_path=ADMIN_PATH, + codes=len(RELAY.codes), + downloads_fn=_list_downloads, + ).encode("utf-8")) - online_count = sum(1 for d in snap["devices"] if d["online"]) - url_text = public_url() or "" - url_display = _esc(url_text) if url_text else "(还没有地址,隧道可能没起来)" - empty = ('' - '
还没有电脑连上来
' - '
在被控端双击 ykxmz.exe 设好配对码,' - '手表上输那 6 位数字就能连上。
') - html = f""" - - - -ykxmz 中转服务器 - - -
-
- -
-

ykxmz 中转服务器

-
远程控制 · 跨网络中转
-
-
-
版本 {_esc(snap['version'])} · 已运行 {_uptime(snap['uptimeSeconds'])}
-
本进程内存 {snap['memoryKB'] // 1024} MB · 累计请求 {snap['totalRequests']} - · 退出登录
-
-
- -
-
当前公网地址 —— 手表「中转设置」里填这个
-
-
{url_display}
- -
-
- -
-
网络体检 —— 走的是局域网还是隧道?
-
点下面按钮测一下
-
- -
-
- -
-
下载最新版
-
正在读…
-
- -
-
{len(snap['devices'])}
登记设备
-
{online_count}
在线
-
{snap['sessions']}
手表会话
-
{len(RELAY.codes)}
配对码占用
-
- -
- - -
- -
-
- 删除会连配对码一起清掉,那台设备下次可以重新设码。 -
- -
- - - - - - - - {''.join(rows) or empty} - -
设备配对码状态空闲待发(表/机)流量(入/出)
-
- -
- 数据来自 /v1/health · 只显示配对码与流量,看不到任何传输内容
- 中转只是转发字节,不解析、不存储会话内容。 -
-
- -
- - -""" - self._send(200, html.encode("utf-8"), "text/html; charset=utf-8") +# ====================================================================== +# 下面这几个是页面/启动辅助(重做管理页时被误删,从 git 恢复) +# ====================================================================== def _esc(text) -> str: return (str(text).replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """)) - def _kb(value: int) -> str: if value < 1024: return f"{value} B" @@ -2015,7 +1538,6 @@ def _kb(value: int) -> str: return f"{value / 1024:.1f} KB" return f"{value / 1024 / 1024:.1f} MB" - def _uptime(seconds: int) -> str: days, rest = divmod(int(seconds), 86400) hours, rest = divmod(rest, 3600) @@ -2036,7 +1558,6 @@ class Server(ThreadingHTTPServer): allow_reuse_address = True request_queue_size = 64 - def main() -> int: DATA_DIR.mkdir(parents=True, exist_ok=True) try: diff --git a/relay/showcase.py b/relay/showcase.py new file mode 100644 index 0000000..a559fbc --- /dev/null +++ b/relay/showcase.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +r'''官网的"产品展示"部分:手表界面 mockup + 连接方式示意。 + +为什么用 HTML/CSS 画手表界面,而不是放截图: + * 手表是安卓设备,没有真机/模拟器就拍不到界面 + * CSS 画的图在任何屏幕上都是清晰的(截图放大会糊) + * 用的颜色和布局**直接从 MainActivity.java 抄**, + 所以不是"想象的样子",是真实界面 + +手表真实参数(android/.../MainActivity.java): + C_BG = 0xFFF5F7FA 页面底色(浅灰) + C_FG = 0xFF1F2328 主文字(近黑) + C_ACCENT = 0xFF185CE8 强调蓝 + C_DIM = 0xFF656D76 次要文字 + 十个分页:媒体 屏幕 文件 语音 电脑 网络 系统 教室 效率 娱乐 +''' +from __future__ import annotations + +# 和手表代码一致的颜色 +W_BG = "#F5F7FA" +W_FG = "#1F2328" +W_ACCENT = "#185CE8" +W_DIM = "#656D76" +W_CARD = "#FFFFFF" +W_BORDER = "#E3E7ED" + +TABS = ["媒体", "屏幕", "文件", "语音", "电脑", + "网络", "系统", "教室", "效率", "娱乐"] + +# "媒体"分页的真实操作(从 TabDef 抄的) +MEDIA_ACTIONS = [ + ("放音频", "🔊"), ("放视频", "▶"), ("暂停", "⏸"), + ("继续", "⏵"), ("音量 +", "+"), ("音量 −", "−"), + ("静音", "🔇"), ("下一首", "⏭"), ("投图到屏幕", "🖼"), +] + +# "教室"分页(老师的核心场景) +CLASSROOM_ACTIONS = [ + ("上课:开投影", "📽"), ("打开课件", "📂"), ("课堂专注", "🎯"), + ("恢复桌面", "↩"), ("看屏幕", "👁"), ("下课关机", "⏻"), +] + +TAB_ACTIONS = {"媒体": MEDIA_ACTIONS, "教室": CLASSROOM_ACTIONS} + + +def _watch_card(active_tab: str) -> str: + """画一个手表界面。active_tab 决定显示哪个分页的内容。""" + actions = TAB_ACTIONS.get(active_tab, MEDIA_ACTIONS) + + # 顶部标签条 + tabs = [] + for name in TABS: + on = name == active_tab + tabs.append( + '%s' % (" on" if on else "", name)) + tabs_html = "".join(tabs) + + # 操作网格 + buttons = [] + for label, icon in actions: + buttons.append( + '
%s' + '%s
' % (icon, label)) + grid_html = "".join(buttons) + + return ( + '
' + '
' + '
ykxmz' + '● 局域网 3 ms
' + '
%s
' + '
' + '
%s
' + '
%s
' + '
' + '
被控端:教室电脑 · 192.168.2.64
' + '
' % (tabs_html, active_tab, grid_html) + ) + + +def _pc_card() -> str: + """电脑端 —— 用真实截图(_build/site_shots/pc-main.png)。""" + return ( + '
' + 'ykxmz 电脑端首次设置界面' + '
电脑端:装完填个 6 位配对码就行
' + '
' + ) + + +def _flow_diagram() -> str: + """连接方式示意:局域网直连 vs 跨网络走中转。""" + return ''' +
+
+
同一个 WiFi
+
+
⌚手表
+ +
🖥教室电脑
+
+

直连,不经过任何服务器。几十毫秒,画面基本跟手。

+
+ +
+
不在同一个网络
+
+
⌚手表
+ +
☁中转
+ +
🖥电脑
+
+

两边都主动连到中转,绕过 NAT。办公室也能管教室。

+
+
+''' + + +def render_showcase() -> str: + """整个"产品展示"区块。""" + return ''' +
+
+

长什么样

+

手表上十个分页,常用操作都在第一屏

+
+
+ %(watch)s + %(pc)s +
+
+ +
+
+

怎么连上的

+

同一个 WiFi 就走局域网直连,不同网络自动切中转

+
+ %(flow)s +
+''' % {"watch": _watch_card("媒体"), "pc": _pc_card(), + "flow": _flow_diagram()} + + +SHOWCASE_CSS = """ +/* ---------- 产品展示 ---------- */ +.showcase{display:grid;gap:34px;align-items:start; + justify-items:center} +@media(min-width:820px){.showcase{grid-template-columns:1fr 1fr}} + +/* 手表 mockup。尺寸按真实手表屏幕比例 */ +.watch{width:300px;padding:10px;border-radius:34px; + background:linear-gradient(160deg,#2b3038,#14171c); + box-shadow:0 18px 40px rgba(16,24,40,.18); + position:relative} +.watch::after{content:'';position:absolute;right:-3px;top:110px; + width:5px;height:44px;border-radius:3px;background:#3a4048} +.w-screen{background:%(W_BG)s;border-radius:26px;overflow:hidden; + font-size:11px;color:%(W_FG)s} +.w-status{display:flex;justify-content:space-between;align-items:center; + padding:11px 12px 7px;font-size:9px;font-weight:600; + gap:6px;white-space:nowrap} +.w-conn{color:#16a34a;font-weight:600} +.w-tabs{display:flex;gap:4px;overflow:hidden;padding:0 11px 8px; + border-bottom:1px solid %(W_BORDER)s} +.w-tab{flex:none;padding:3px 8px;border-radius:999px;color:%(W_DIM)s; + font-size:10px;white-space:nowrap} +.w-tab.on{background:%(W_ACCENT)s;color:#fff;font-weight:600} +.w-body{padding:12px 13px 14px} +.w-head{font-size:12px;font-weight:700;margin-bottom:10px} +.w-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:7px} +.w-btn{background:%(W_CARD)s;border:1px solid %(W_BORDER)s; + border-radius:11px;padding:11px 4px 9px;text-align:center; + display:flex;flex-direction:column;align-items:center;gap:5px} +.w-ic{font-size:15px;line-height:1} +.w-lb{font-size:9px;color:%(W_FG)s;white-space:nowrap} +.w-foot{padding:9px 13px 12px;color:%(W_DIM)s;font-size:9px; + border-top:1px solid %(W_BORDER)s} + +/* 电脑端截图 */ +.pcshot{margin:0;text-align:center} +.pcshot img{width:100%%;max-width:352px;height:auto; + border-radius:10px;display:block;margin:0 auto; + box-shadow:0 18px 40px rgba(16,24,40,.16)} +.pcshot{padding:14px;background:#f2f4f7;border-radius:16px; + border:1px solid %(W_BORDER)s} +.pcshot figcaption{margin-top:14px;color:%(W_DIM)s;font-size:13px} + +/* 连接方式示意 */ +.flow{display:grid;gap:18px} +@media(min-width:760px){.flow{grid-template-columns:1fr 1fr}} +.flow-col{border:1px solid #e6e8eb;border-radius:14px;padding:24px 20px; + background:#fafbfc} +.flow-tag{display:inline-block;padding:3px 11px;border-radius:999px; + font-size:12px;font-weight:600;margin-bottom:20px} +.flow-tag.ok{background:#dcfce7;color:#15803d} +.flow-tag.warn{background:#fef3c7;color:#a16207} +.flow-row{display:flex;align-items:center;justify-content:center; + gap:2px;margin-bottom:16px;flex-wrap:nowrap} +.node{display:flex;flex-direction:column;align-items:center;gap:6px; + font-size:26px;flex:none;width:64px} +.node span{font-size:12px;color:#4b5563;font-weight:500} +.node.relay{font-size:24px} +.link{flex:1;min-width:16px;position:relative;display:flex; + align-items:center;justify-content:center} +.link .line{width:100%%;height:2px;background:#c7cdd6; + border-radius:2px} +.link .ms{position:absolute;top:-15px;font-size:10px;color:#15803d; + font-weight:600;white-space:nowrap} +.flow-col p{color:#6b7280;font-size:13px;line-height:1.7;margin:0} +""" % {"W_BG": W_BG, "W_FG": W_FG, "W_ACCENT": W_ACCENT, "W_DIM": W_DIM, + "W_CARD": W_CARD, "W_BORDER": W_BORDER}