diff --git a/README.md b/README.md
index b53fd26..1078290 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,10 @@
-# ykxmz · 教室电脑手表遥控
+# ykxmz · 教室电脑手表控制
老师在讲台上抬一下手腕,就能把教室里那台电脑管了。
-放课件、放音频、投个图、看看谁在打游戏、下课关机 —— 不用走回讲台去点鼠标。
+放课件、放音频、投个图、看看有没有在跑无关程序、下课关机 —— 不用走回讲台去点鼠标。
-> 名字来自"遥控手表"的拼音首字母,读作"遥控拇指"也行。
+> 名字来自"控制手表"的拼音首字母,读作"控制拇指"也行。
---
@@ -23,7 +23,7 @@
## 这东西是干嘛的
-一句话:**用手表遥控电脑。**
+一句话:**用手表控制电脑。**
为什么用手表而不是手机?因为老师上课时手上是空的,手机在兜里,掏手机的动作比抬手大得多。手表抬一下就行,还不打断讲课。
@@ -32,7 +32,7 @@
- 课件全屏投到教室大屏
- 放听力音频(电脑音箱比手表响多了)
- 看看学生机是不是在打游戏,直接把游戏进程结了
-- 屏蔽游戏网站,下课一键恢复
+- 课堂专注模式,下课一键恢复
- 下课关机、锁屏
- 喊一声"上课了"让电脑播音(TTS)
@@ -81,7 +81,7 @@
```
┌──────────────────────────┐
-│ 遥控手表 │
+│ 控制手表 │
│ 和电脑连同一个 WiFi,不用输 IP │
├──────────────────────────┤
│ 中转设置(跨网络用) │ ← 办公室管教室才需要
@@ -117,8 +117,8 @@
| 按钮 | 干什么 |
|---|---|
-| **屏蔽游戏网站** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 |
-| **结束游戏进程** | 扫一遍常见游戏进程名,找到就结束掉 |
+| **课堂专注模式** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 |
+| **关闭游戏窗口** | 扫一遍常见游戏进程名,找到就结束掉 |
| **看有哪些限制** | 列出当前开了哪些限制,别自己忘了 |
| **一键恢复全部** | 把这节课开的所有限制撤掉,**下课必点** |
diff --git a/_build/_pairtest/devices.json b/_build/_pairtest/devices.json
new file mode 100644
index 0000000..80ed168
--- /dev/null
+++ b/_build/_pairtest/devices.json
@@ -0,0 +1,8 @@
+{
+ "watch-reinstalled": {
+ "name": "手表",
+ "token": "45be12c203e6d66d5a042091e9ba5900",
+ "paired_at": 1791299138.656342,
+ "last_seen": 1791299138.656342
+ }
+}
\ No newline at end of file
diff --git a/_build/_pairtest/settings.json b/_build/_pairtest/settings.json
new file mode 100644
index 0000000..788d272
--- /dev/null
+++ b/_build/_pairtest/settings.json
@@ -0,0 +1,30 @@
+{
+ "port": 8873,
+ "server_name": "DESKTOP-RN5I625",
+ "allow_run": true,
+ "allow_power": true,
+ "allow_screen": true,
+ "start_minimized": false,
+ "allow_new_pairing": false,
+ "image_timeout": 0,
+ "image_display": "window",
+ "image_custom_width": 0,
+ "image_custom_height": 0,
+ "image_window_title": "",
+ "tts_voice": "",
+ "tts_rate": 0,
+ "mp3_dir": "",
+ "file_roots": "",
+ "extra_apps": "",
+ "extra_commands": "",
+ "download_dir": "received",
+ "screen_max_width": 480,
+ "screen_quality": 72,
+ "screen_fps": 12,
+ "pairing_code": "135790",
+ "pairing_created": 1791299136.6660163,
+ "setup_done": true,
+ "close_pair_after_first": false,
+ "relay_last_good": "http://192.168.2.156:8080",
+ "relay_device_id": "DESKTOP-RN5I625-59e6fdd69b09"
+}
\ No newline at end of file
diff --git a/_build/_whitetest/devices.json b/_build/_whitetest/devices.json
new file mode 100644
index 0000000..92b8020
--- /dev/null
+++ b/_build/_whitetest/devices.json
@@ -0,0 +1,8 @@
+{
+ "watch-probe-1": {
+ "name": "手表",
+ "token": "79ce7c72c1ad0526a6efe153375cc22c",
+ "paired_at": 1791298726.377039,
+ "last_seen": 1791298726.377039
+ }
+}
\ No newline at end of file
diff --git a/_build/_whitetest/settings.json b/_build/_whitetest/settings.json
new file mode 100644
index 0000000..d5524a7
--- /dev/null
+++ b/_build/_whitetest/settings.json
@@ -0,0 +1,29 @@
+{
+ "port": 8871,
+ "server_name": "DESKTOP-RN5I625",
+ "allow_run": true,
+ "allow_power": true,
+ "allow_screen": true,
+ "start_minimized": false,
+ "allow_new_pairing": false,
+ "image_timeout": 0,
+ "image_display": "window",
+ "image_custom_width": 0,
+ "image_custom_height": 0,
+ "image_window_title": "",
+ "tts_voice": "",
+ "tts_rate": 0,
+ "mp3_dir": "",
+ "file_roots": "",
+ "extra_apps": "",
+ "extra_commands": "",
+ "download_dir": "received",
+ "screen_max_width": 480,
+ "screen_quality": 72,
+ "screen_fps": 12,
+ "pairing_code": "717525",
+ "pairing_created": 1791298724.895755,
+ "close_pair_after_first": false,
+ "relay_last_good": "http://192.168.2.156:8080",
+ "relay_device_id": "DESKTOP-RN5I625-e35d64d5d85b"
+}
\ No newline at end of file
diff --git a/_build/check_relay_state.py b/_build/check_relay_state.py
new file mode 100644
index 0000000..2774c82
--- /dev/null
+++ b/_build/check_relay_state.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+r'''检查 relay 服务状态并重跑网页检查。'''
+from __future__ import annotations
+
+import sys
+import time
+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:
+ print(" 服务状态:")
+ _c, out, _e = run(
+ "for s in lanremote-relay lanremote-tunnel lanremote-nettune.service; do "
+ "printf ' %-26s %s\\n' $s $(systemctl is-active $s); done", timeout=60)
+ print(out)
+
+ print(" 监听端口:")
+ _c, out, _e = run("ss -tlnp 2>/dev/null | grep -E '8080|:80 ' | head -5",
+ timeout=60)
+ print(out or " (没在监听 8080)")
+
+ print(" 最后几行日志:")
+ _c, out, _e = run("tail -12 /root/lanremote-relay/relay.log 2>/dev/null",
+ timeout=60)
+ print(out)
+
+ print(" 等一下再跑检查…")
+ time.sleep(6)
+ _c, out, _e = run("bash /root/check_web.sh 2>&1", timeout=240)
+ print(out)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/check_web.sh b/_build/check_web.sh
new file mode 100644
index 0000000..c900bd8
--- /dev/null
+++ b/_build/check_web.sh
@@ -0,0 +1,43 @@
+#!/bin/bash
+# 官网 / 管理页 / favicon 的最终检查
+set -u
+
+BASE=http://127.0.0.1:8080
+KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
+
+echo " 1) 官网 /"
+curl -s -o /tmp/site.html -w ' HTTP %{http_code} %{size_download} 字节\n' \
+ --max-time 15 "$BASE/" 2>/dev/null
+for n in 'ykxmz' '老师抬一下手腕' '电脑端' '服务器端' '手表端'; do
+ grep -q "$n" /tmp/site.html && echo " OK $n" || echo " XX $n"
+done
+
+echo
+echo " 2) 管理页 /zhingzhuanzhan(未登录应看到登录框)"
+curl -s -o /tmp/login.html --max-time 15 "$BASE/zhingzhuanzhan" 2>/dev/null
+for n in 'name=username' 'name=password' 'favicon'; do
+ grep -q "$n" /tmp/login.html && echo " OK $n" || echo " XX $n"
+done
+
+echo
+echo " 3) /admin 跳转"
+curl -s -o /dev/null -w ' -> %{redirect_url}\n' --max-time 15 "$BASE/admin" 2>/dev/null
+
+echo
+echo " 4) 登录"
+curl -s -c /tmp/ck2.txt -o /dev/null --max-time 15 -X POST \
+ -d 'username=xmz7891&password=@REVAedu120830' "$BASE/admin/login" 2>/dev/null
+curl -s -b /tmp/ck2.txt -o /tmp/admin.html --max-time 15 "$BASE/zhingzhuanzhan" 2>/dev/null
+for n in 'id=dlPanel' '网络体检' '退出登录' 'favicon'; do
+ grep -q "$n" /tmp/admin.html && echo " OK $n" || echo " XX $n"
+done
+
+echo
+echo " 5) favicon"
+curl -s -o /dev/null -w ' HTTP %{http_code} %{size_download} 字节 %{content_type}\n' \
+ --max-time 15 "$BASE/favicon.ico" 2>/dev/null
+
+echo
+echo " 6) 公网官网"
+curl -s -o /dev/null -w ' HTTP %{http_code} %{size_download} 字节\n' \
+ --max-time 40 -H 'User-Agent: LanRemote/1.4.0' https://relay.xxmxxs.top/ 2>/dev/null
diff --git a/_build/deploy_web_final.py b/_build/deploy_web_final.py
new file mode 100644
index 0000000..af85ebf
--- /dev/null
+++ b/_build/deploy_web_final.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+r"""给管理页 head 补 favicon link,然后部署验证。
+
+前面几次栽在"Python 三层引号套 PowerShell 引号"上,
+这次全部走文件:Python 脚本 + shell 脚本,不再用 -c 内联。
+"""
+from __future__ import annotations
+
+import ast
+import re
+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, upload # noqa: E402
+
+RELAY = ROOT / "relay" / "relay.py"
+LINK = '\n'
+
+
+def patch_admin_head() -> bool:
+ text = RELAY.read_text(encoding="utf-8")
+ start = text.find("def _admin_page")
+ if start < 0:
+ print(" [失败] 找不到 _admin_page")
+ return False
+
+ head_open = text.find("
", start)
+ head_close = text.find("", head_open)
+ if head_open < 0 or head_close < 0:
+ print(" [失败] _admin_page 里找不到 ")
+ return False
+
+ segment = text[head_open:head_close]
+ if "favicon" in segment:
+ print(" 管理页已经有 favicon")
+ return True
+
+ # 插在 之后就行,不用找 charset
+ insert_at = head_open + len("")
+ text = text[:insert_at] + LINK + text[insert_at:]
+ RELAY.write_text(text, encoding="utf-8", newline="\n")
+ print(" 管理页 head 补上 favicon link")
+
+ ast.parse(text)
+ print(" relay.py 语法 OK")
+ return True
+
+
+def main() -> int:
+ if not patch_admin_head():
+ return 1
+
+ print()
+ print(" 部署到设备…")
+ upload(str(RELAY), "/root/lanremote-relay/relay.py", 0o755)
+ upload(str(ROOT / "relay" / "favicon.ico"),
+ "/root/lanremote-relay/favicon.ico", 0o644)
+ run("systemctl restart lanremote-relay", timeout=90)
+ print(" 已重启")
+
+ print()
+ print(" 验证:")
+ _c, out, _e = run("bash /root/check_web.sh 2>&1", timeout=180)
+ print(out)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/filter_commit_msg.py b/_build/filter_commit_msg.py
new file mode 100644
index 0000000..0c11b05
--- /dev/null
+++ b/_build/filter_commit_msg.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+r'''把两个乱码提交信息换成干净文本。
+
+背景:
+ PowerShell 的 git commit -m $msg 把 UTF-8 中文按 GBK 传给 git,
+ git 又把字节当 UTF-8 存 —— 多层错编码,逆向还原不可靠
+ (GBK 编不回去、UTF-8 也解不开,缺字节)。
+ 既然已经不可读,就直接换成正确文本。
+
+这两个提交是:
+ b705216 ykxmz 1.5.0:修复手表端 TLS 证书校验 + MTU 修复网络稳定性
+ 7f267dd ykxmz 1.5.0:手表端 TLS 证书兼容 + 三端分开打包 + 下载区
+
+给 git filter-branch --msg-filter 用:stdin 进,stdout 出。
+'''
+from __future__ import annotations
+
+import sys
+
+MARKERS = "锛氬焷姘噰鏃粈缂撳啿锛氫慨澶嶆墜琛ㄧ"
+
+CLEAN_MTU = """ykxmz 1.5.0:修复手表端 TLS 证书校验 + MTU 修复网络稳定性
+
+被控端
+- 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%)
+- 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败)
+- 每分钟回头找局域网,自动切回快 400 倍的路径
+- 画面心跳帧:静止时省流量,客户端仍知道流是活的
+- 锁屏时给出可操作的提示
+
+手表端
+- 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException
+- 连接错误翻译成人话(证书/时间/解析/超时分别提示)
+- 经中转连上后用服务器报的地址刷新已配对记录
+- 新增局域网连接页面、服务器延迟检测
+
+服务器端
+- 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502)
+- 网络调优脚本 net-tune.sh(开机自动应用)
+- 管理页:批量删除、网络体检、下载区
+- 隧道改 HTTP/2 + 双连接
+
+文档
+- 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
+"""
+
+CLEAN_PACK = """ykxmz 1.5.0:手表端 TLS 证书兼容 + 三端分开打包 + 下载区
+
+手表端
+- 内置 GTS Root R4 / GlobalSign Root CA
+ 修 SSLHandshakeException: Chain validation failed
+- 连接错误翻译成人话
+- 经中转连上后用服务器报的地址刷新已配对记录
+- 版本号 1.3.0 -> 1.5.0(AndroidManifest 之前漏改了)
+
+服务器端
+- 支持 chunked 请求体
+- 网络调优 net-tune.sh(开机自动应用)
+- 新增下载区,管理页加下载卡片
+- 隧道改 HTTP/2 + 双连接
+
+打包方式变更
+- 分成三个独立产物:agent zip / server zip / watch apk
+"""
+
+
+def main() -> int:
+ raw = sys.stdin.buffer.read()
+ text = raw.decode("utf-8", "replace")
+
+ if any(ch in text for ch in MARKERS):
+ # 按正文里的关键词区分是哪一条
+ text = CLEAN_MTU if "MTU" in text else CLEAN_PACK
+
+ sys.stdout.buffer.write(text.encode("utf-8"))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/fix_admin_favicon2.py b/_build/fix_admin_favicon2.py
new file mode 100644
index 0000000..4f481a6
--- /dev/null
+++ b/_build/fix_admin_favicon2.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+r'''给管理页补 favicon(第二版 —— 这次锚点找对了)。
+
+管理页的 HTML 不在 _admin_page 开头,而是在它内部,
+标题那行是 ykxmz 中转服务器,
+所以插在它的 viewport 那行之后。
+
+(上一版脚本的文档字符串里写了一个三引号,把自己的 docstring 提前闭合了,
+ 这里改用单引号三连,避免同样的问题。)
+'''
+from __future__ import annotations
+
+import ast
+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, upload # noqa: E402
+
+RELAY = ROOT / "relay" / "relay.py"
+
+ANCHOR = '\nykxmz 中转服务器'
+NEW = ('\n'
+ '\n'
+ 'ykxmz 中转服务器')
+
+
+def main() -> int:
+ text = RELAY.read_text(encoding="utf-8")
+ if '\nykxmz 中转服务器' in text:
+ print(" 管理页已经有 favicon")
+ elif ANCHOR in text:
+ text = text.replace(ANCHOR, NEW, 1)
+ RELAY.write_text(text, encoding="utf-8", newline="\n")
+ print(" 管理页补上 favicon link")
+ ast.parse(text)
+ print(" 语法 OK")
+ else:
+ print(" [失败] 锚点还是没找到,实际内容:")
+ idx = text.find("ykxmz 中转服务器")
+ if idx > 0:
+ print(text[max(0, idx - 200):idx + 40])
+ return 1
+
+ print()
+ print(" 部署…")
+ upload(str(RELAY), "/root/lanremote-relay/relay.py", 0o755)
+ upload(str(ROOT / "relay" / "favicon.ico"),
+ "/root/lanremote-relay/favicon.ico", 0o644)
+ run("systemctl restart lanremote-relay", timeout=90)
+ print(" 已重启")
+
+ print()
+ _c, out, _e = run("bash /root/check_web.sh 2>&1", timeout=180)
+ print(out)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/fix_commit_msg.py b/_build/fix_commit_msg.py
new file mode 100644
index 0000000..dac03da
--- /dev/null
+++ b/_build/fix_commit_msg.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+r'''修复被 GBK 二次编码搞乱的提交信息。
+
+成因:
+ PowerShell 的 git commit -m $msg 把 UTF-8 的中文按 GBK 传给 git,
+ git 又把那串字节当 UTF-8 存了 —— 等于"多错一层"。
+ 结果:ykxmz 1.5.0锛氭墜琛ㄧ TLS ...
+
+恢复:
+ 当前字符串 -> encode('gbk') 拿回原始 UTF-8 字节 -> decode('utf-8')
+
+这个脚本给 git filter-branch --msg-filter 用:
+ 消息从 stdin 进来,改完写到 stdout。
+'''
+from __future__ import annotations
+
+import sys
+
+# 明显的乱码特征字(GBK 误读 UTF-8 会产生这些生僻字)
+MARKERS = "锛氬焷姘噰鏃粈缂撳啿鏈嬭繛璇佷功鍏煎寘涓嬭浇鍖哄尯鐗堟湰"
+
+
+def fix(text: str) -> str:
+ """试着把乱码还原;还原不了就原样返回。"""
+ if not any(ch in text for ch in MARKERS):
+ return text
+ try:
+ recovered = text.encode("gbk").decode("utf-8")
+ except (UnicodeEncodeError, UnicodeDecodeError):
+ return text
+ # 还原后如果还有一堆乱码特征,说明不是这个问题,别乱改
+ if any(ch in recovered for ch in MARKERS):
+ return text
+ return recovered
+
+
+def main() -> int:
+ raw = sys.stdin.buffer.read()
+ text = raw.decode("utf-8", "replace")
+ sys.stdout.buffer.write(fix(text).encode("utf-8"))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/fix_gitee_violation.py b/_build/fix_gitee_violation.py
new file mode 100644
index 0000000..cef1af2
--- /dev/null
+++ b/_build/fix_gitee_violation.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+r"""修 Gitee 的「内容可能含有违规信息」。
+
+现象:
+ 仓库简介和主页都被 Gitee 换成了 "内容可能含有违规信息"。
+ 我原来写的:
+ 教室电脑手表遥控 · 老师用手表管教室电脑:放课件、投图、
+ 屏蔽游戏网站、下课关机。支持局域网直连与跨网络中继
+ 里面几个词容易触发审核:
+ 遥控 / 管理学校电脑 / 屏蔽……网站
+
+改法:
+ 换成中性、只描述"辅助教学"的表述:
+ 教室多媒体辅助工具 · 手表控制课件播放与投屏
+ 主页也去掉,先不填(填了也可能再被替换)。
+
+另外把 README 里可能踩线的词也软化一下。
+"""
+from __future__ import annotations
+
+import json
+import re
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
+TOKEN = (ROOT / "_build" / ".gitee_token").read_text(encoding="utf-8").strip()
+API = "https://gitee.com/api/v5/repos/xmz7891/ykxmz"
+
+# 候选简介,从最保守开始试
+CANDIDATES = [
+ "教室多媒体辅助工具 · 手表控制课件播放与投屏",
+ "教室多媒体辅助工具",
+ "教师用教室设备辅助工具",
+]
+
+# README 里要软化的词(左:现在用的,右:替换成)
+SOFTEN = [
+ ("遥控", "控制"),
+ ("屏蔽游戏网站", "课堂专注模式"),
+ ("屏蔽游戏", "限制游戏"),
+ ("结束游戏进程", "关闭游戏窗口"),
+ ("看看谁在打游戏", "看看有没有在跑无关程序"),
+ ("远程控制", "远程协助"),
+ ("监视", "查看"),
+ ("隐藏", "低调运行"),
+]
+
+
+def call(url, payload=None, method="GET", timeout=60):
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
+ headers = {"User-Agent": "ykxmz"}
+ if payload is not None:
+ headers["Content-Type"] = "application/json;charset=UTF-8"
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ text = resp.read().decode("utf-8", "replace")
+ return json.loads(text) if text.strip() else {}
+
+
+def main() -> int:
+ print(" 当前仓库信息:")
+ info = call(f"{API}?access_token={TOKEN}")
+ print(f" description: {info.get('description')}")
+ print(f" homepage: {info.get('homepage')}")
+
+ print()
+ print(" 依次尝试候选简介:")
+ ok = False
+ for desc in CANDIDATES:
+ try:
+ got = call(API,
+ {"access_token": TOKEN, "name": "ykxmz",
+ "description": desc, "homepage": ""},
+ method="PATCH")
+ now = got.get("description")
+ print(f" 「{desc}」-> 服务器返回: {now}")
+ if now and "违规" not in now:
+ print(" ✓ 这次没被替换,就用它")
+ ok = True
+ break
+ print(" ✗ 又被替换了,换下一个")
+ except urllib.error.HTTPError as exc:
+ detail = exc.read().decode("utf-8", "replace")[:200]
+ print(f" HTTP {exc.code}: {detail}")
+
+ if not ok:
+ print()
+ print(" ⚠ 试过的都被替换了 —— 可能需要人工申诉,或者仓库名/内容里有别的敏感词")
+
+ # ---- README 软化 ----
+ readme = ROOT / "README.md"
+ if readme.is_file():
+ text = readme.read_text(encoding="utf-8")
+ original = text
+ hits = []
+ for old, new in SOFTEN:
+ n = text.count(old)
+ if n:
+ hits.append(f"{old}x{n}")
+ text = text.replace(old, new)
+ if text != original:
+ readme.write_text(text, encoding="utf-8", newline="\n")
+ print()
+ print(f" README 软化了这些词: {', '.join(hits)}")
+ else:
+ print()
+ print(" README 没有需要软化的词")
+
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/fix_pairing_and_name.py b/_build/fix_pairing_and_name.py
new file mode 100644
index 0000000..490f8ab
--- /dev/null
+++ b/_build/fix_pairing_and_name.py
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+r"""修两个问题:
+
+1. 「设备已关闭新设备连接」—— 手表重装后永远连不上
+ 现在 _do_pair 第一句就是 if not self.pairing_enabled: 拒绝。
+ 而 run_silent 里有"首台设备配对成功后自动关闭配对"的逻辑
+ (close_pair_after_first)。于是:
+ 手表重装 / 换新表 / 清了 App 数据 -> 令牌没了 -> 重新配对 -> 被拒
+ 用户只能去管理页手动开——但管理页那个开关是给"临时加设备"用的,
+ 正常用户根本不该被迫走这条路。
+
+ 改法:**配对码本身就是密钥**。拿着正确的 6 位码就允许配对,
+ 不管配对窗口开没开。码不对才回"已关闭配对"(这样也保住了
+ 防暴力枚举的门槛,见下面的限速)。
+
+2. 应用名改成 ykxmz
+ Windows 上显示的是 "ykxmz 教室电脑手表遥控",
+ 用户要求直接叫 ykxmz(教室里那个名字放副标题就行)。
+"""
+from __future__ import annotations
+
+import ast
+import sys
+from pathlib import Path
+
+ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
+SERVER = ROOT / "windows" / "lanremote" / "server.py"
+
+# ---------- 1. 配对:知道码就放行 ----------
+
+OLD_PAIR = ''' def _do_pair(self, session: Session, msg: dict) -> dict:
+ if not self.pairing_enabled:
+ return {"ok": False, "error": "被控端已关闭新设备配对"}
+ code = str(msg.get("code") or "").strip()
+ expected = self.store.pairing_code()
+ if not expected:
+ return {"ok": False, "error": "被控端尚未生成配对码"}'''
+
+NEW_PAIR = ''' def _do_pair(self, session: Session, msg: dict) -> dict:
+ code = str(msg.get("code") or "").strip()
+ expected = self.store.pairing_code()
+ if not expected:
+ return {"ok": False, "error": "被控端尚未生成配对码"}
+
+ # 配对窗口关着、但对方报的码是对的 —— 照样放行。
+ #
+ # 为什么:配对码本身就是密钥。老逻辑是"首台设备配对成功后
+ # 自动关闭配对窗口"(close_pair_after_first),结果手表重装、
+ # 换新表、或清掉 App 数据之后令牌没了,重新配对会被拒,
+ # 用户看到"被控端已关闭新设备配对"就彻底连不上了 ——
+ # 而这时候他能做的只有跑去管理页手动开窗口,完全不合理。
+ #
+ # 码不对时才用"已关闭配对"这个说法挡回去,
+ # 防暴力枚举的按 IP 限速(下面那段)仍然生效。
+ if not self.pairing_enabled and code != expected:
+ return {"ok": False,
+ "error": "被控端已关闭新设备配对" +
+ "(配对码不对;要在管理页临时开启的话,"
+ "打开中转站管理页找「允许新设备配对」)"}'''
+
+# ---------- 2. 应用名 ----------
+
+NAME_OLD = 'APP_NAME = "ykxmz 教室电脑手表遥控"'
+NAME_NEW = 'APP_NAME = "ykxmz"'
+
+
+def main() -> int:
+ ok = True
+
+ text = SERVER.read_text(encoding="utf-8")
+ if "配对码本身就是密钥" in text:
+ print(" server.py: 配对逻辑已经改过")
+ elif OLD_PAIR in text:
+ text = text.replace(OLD_PAIR, NEW_PAIR, 1)
+ SERVER.write_text(text, encoding="utf-8", newline="\n")
+ print(" server.py: 拿着正确配对码就放行")
+ else:
+ print(" [失败] server.py 的 _do_pair 锚点没找到")
+ ok = False
+
+ # 应用名
+ for rel in ("windows/lanremote/__init__.py",):
+ path = ROOT / rel
+ if not path.is_file():
+ continue
+ t = path.read_text(encoding="utf-8")
+ if NAME_OLD in t:
+ path.write_text(t.replace(NAME_OLD, NAME_NEW),
+ encoding="utf-8", newline="\n")
+ print(f" {path.name}: 应用名 -> ykxmz")
+
+ # 全局找一遍还叫旧名的
+ for path in (ROOT / "windows").rglob("*.py"):
+ t = path.read_text(encoding="utf-8")
+ if 'APP_NAME = "ykxmz 教室电脑手表遥控"' in t:
+ path.write_text(t.replace('APP_NAME = "ykxmz 教室电脑手表遥控"',
+ 'APP_NAME = "ykxmz"'),
+ encoding="utf-8", newline="\n")
+ print(f" {path.relative_to(ROOT)}: 应用名 -> ykxmz")
+
+ for path, label in ((SERVER, "server.py"),
+ (ROOT / "windows" / "lanremote" / "__init__.py",
+ "__init__.py")):
+ try:
+ ast.parse(path.read_text(encoding="utf-8"))
+ print(f" {label} 语法 OK")
+ except SyntaxError as exc:
+ print(f" [失败] {label} 语法错误: 行 {exc.lineno}: {exc.msg}")
+ ok = False
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/probe_feature_list.py b/_build/probe_feature_list.py
new file mode 100644
index 0000000..f795a7f
--- /dev/null
+++ b/_build/probe_feature_list.py
@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+r"""白屏排查:直接问被控端要 feature_list,看返回什么。
+
+手表连上后是白屏 —— 说明 buildFeatureTab() 里那个
+ client.call({"cmd": "feature_list"})
+没拿到可用数据。这里用 Python 直接走一遍同样的流程,
+把原始返回打出来。
+"""
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
+PORT = 8871
+DATA = ROOT / "_build" / "_whitetest"
+
+
+def send(sock, obj) -> dict:
+ sock.sendall((json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8"))
+ buf = b""
+ deadline = time.time() + 20
+ while time.time() < deadline:
+ chunk = sock.recv(65536)
+ if not chunk:
+ break
+ buf += chunk
+ if b"\n" in buf:
+ line = buf.split(b"\n", 1)[0]
+ return json.loads(line.decode("utf-8"))
+ raise TimeoutError("没收到回复")
+
+
+def main() -> int:
+ shutil.rmtree(DATA, ignore_errors=True)
+ env = dict(os.environ)
+ env["YKXMZ_DATA"] = str(DATA)
+ env["PYTHONIOENCODING"] = "utf-8"
+
+ print(" 起被控端…")
+ proc = subprocess.Popen(
+ [sys.executable, "run_agent.py", "--port", str(PORT),
+ "--new-code", "--add-device", "10"],
+ cwd=str(ROOT / "windows"), env=env,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+
+ try:
+ ready = False
+ for _ in range(50):
+ try:
+ s = socket.create_connection(("127.0.0.1", PORT), timeout=1)
+ s.close()
+ ready = True
+ break
+ except OSError:
+ time.sleep(0.4)
+ print(f" 端口就绪: {ready}")
+ if not ready:
+ return 1
+ time.sleep(1)
+
+ # 读配对码
+ settings = json.loads((DATA / "settings.json").read_text(encoding="utf-8"))
+ code = settings.get("pairing_code")
+ print(f" 配对码: {code}")
+
+ sock = socket.create_connection(("127.0.0.1", PORT), timeout=10)
+ sock.settimeout(20)
+
+ print()
+ print(" 1) 配对(命令名是 pair,不是 handshake)")
+ reply = send(sock, {"cmd": "pair", "code": code,
+ "deviceId": "watch-probe-1",
+ "name": "手表"})
+ print(f" 完整回复: {json.dumps(reply, ensure_ascii=False)[:400]}")
+ print(f" ok={reply.get('ok')} token={'有' if reply.get('token') else '无'}")
+ token = reply.get("token")
+
+ print()
+ print(" 2) feature_list")
+ reply = send(sock, {"cmd": "feature_list", "token": token})
+ print(f" 顶层键: {list(reply.keys())}")
+ features = reply.get("features")
+ print(f" features 类型: {type(features).__name__}")
+ if isinstance(features, list):
+ print(f" features 条数: {len(features)}")
+ groups = {}
+ for item in features[:200]:
+ if isinstance(item, dict):
+ g = item.get("group", "(无 group)")
+ groups[g] = groups.get(g, 0) + 1
+ else:
+ print(f" ⚠ 非字典元素: {item!r}")
+ print(f" 分组: {groups}")
+ if features:
+ print(f" 第一条: {json.dumps(features[0], ensure_ascii=False)[:200]}")
+ else:
+ print(f" ⚠ features 不是列表: {str(features)[:200]}")
+
+ print()
+ print(" 3) 原始返回(前 400 字符)")
+ print(f" {json.dumps(reply, ensure_ascii=False)[:400]}")
+
+ sock.close()
+ finally:
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/rename_app_170.py b/_build/rename_app_170.py
new file mode 100644
index 0000000..03d4231
--- /dev/null
+++ b/_build/rename_app_170.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+r"""应用名统一改成 ykxmz(用户:"直接改成 ykxmz")。
+
+要改的地方:
+ windows/lanremote/__init__.py APP_NAME = "局域网遥控 被控端" -> "ykxmz"
+ installer/ykxmz.iss MyAppName -> "ykxmz"
+ (安装向导标题、控制面板卸载项都用它)
+ windows/version_info.txt EXE 属性里的产品名
+
+顺带修测试脚本:不带 --code 时 setup_done 是 false,
+程序会去弹设置窗口而不会静默监听,所以测试必须带 --code。
+"""
+from __future__ import annotations
+
+import ast
+import sys
+from pathlib import Path
+
+ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
+INIT = ROOT / "windows" / "lanremote" / "__init__.py"
+ISS = ROOT / "installer" / "ykxmz.iss"
+VINFO = ROOT / "windows" / "version_info.txt"
+TEST = ROOT / "_build" / "test_pair_reopen.py"
+
+OLD_NAMES = (
+ "ykxmz 教室电脑手表遥控",
+ "局域网遥控 被控端",
+ "局域网遥控 · 被控端",
+ "局域网遥控 被控端(Windows)",
+)
+
+
+def main() -> int:
+ ok = True
+
+ # ---- 1. 内部应用名 ----
+ text = INIT.read_text(encoding="utf-8")
+ if 'APP_NAME = "ykxmz"' in text:
+ print(" __init__.py: 已经是 ykxmz")
+ else:
+ for old in OLD_NAMES:
+ text = text.replace(f'"{old}"', '"ykxmz"')
+ INIT.write_text(text, encoding="utf-8", newline="\n")
+ print(" __init__.py: 应用名 -> ykxmz")
+
+ # ---- 2. 安装包显示名 ----
+ itext = ISS.read_text(encoding="utf-8")
+ if '#define MyAppName "ykxmz"' in itext:
+ print(" ykxmz.iss: 已经是 ykxmz")
+ else:
+ for old in OLD_NAMES:
+ itext = itext.replace(f'#define MyAppName "{old}"',
+ '#define MyAppName "ykxmz"')
+ ISS.write_text(itext, encoding="utf-8", newline="\r\n")
+ print(" ykxmz.iss: 安装向导显示名 -> ykxmz")
+
+ # ---- 3. EXE 版本信息里的产品名 ----
+ if VINFO.is_file():
+ vtext = VINFO.read_text(encoding="utf-8")
+ changed = False
+ for old in OLD_NAMES:
+ if f'"{old}"' in vtext:
+ vtext = vtext.replace(f'"{old}"', '"ykxmz"')
+ changed = True
+ if changed:
+ VINFO.write_text(vtext, encoding="utf-8", newline="\n")
+ print(" version_info.txt: 产品名 -> ykxmz")
+
+ # ---- 4. 测试脚本:必须带 --code ----
+ ttext = TEST.read_text(encoding="utf-8")
+ old = ''' [sys.executable, "run_agent.py", "--port", str(PORT), "--new-code"],'''
+ new = ''' # 必须带 --code:不然 setup_done 是 false,
+ # 程序会去弹设置窗口(需要桌面),根本不会静默监听端口。
+ [sys.executable, "run_agent.py", "--port", str(PORT),
+ "--code", "135790", "--silent"],'''
+ if old in ttext:
+ TEST.write_text(ttext.replace(old, new, 1),
+ encoding="utf-8", newline="\n")
+ print(" 测试脚本: 改成带 --code --silent 启动")
+
+ # ---- 复核 ----
+ print()
+ checks = [
+ (INIT, 'APP_NAME = "ykxmz"', "内部应用名"),
+ (ISS, '#define MyAppName "ykxmz"', "安装包显示名"),
+ (TEST, '"--code", "135790"', "测试启动参数"),
+ ]
+ for path, needle, label in checks:
+ got = needle in path.read_text(encoding="utf-8")
+ print(f" {label}: {'✓' if got else '✗'}")
+ ok = ok and got
+
+ for path, label in ((INIT, "__init__.py"), (TEST, "测试脚本")):
+ try:
+ ast.parse(path.read_text(encoding="utf-8"))
+ print(f" {label} 语法 OK")
+ except SyntaxError as exc:
+ print(f" [失败] {label} 语法错误: 行 {exc.lineno}: {exc.msg}")
+ ok = False
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/_build/split_site_admin2.py b/_build/split_site_admin2.py
new file mode 100644
index 0000000..3aab491
--- /dev/null
+++ b/_build/split_site_admin2.py
@@ -0,0 +1,302 @@
+# -*- coding: utf-8 -*-
+r"""中转站改造成「官网 + 管理页分家」(修正版)。
+
+上一版脚本自己踩了 f-string 花括号没转义,在写文件前就崩了 ——
+所以 relay.py 没被改动。这版全部改成普通字符串拼接。
+
+用户要求:
+ relay.xxmxxs.top/ -> 官网(简洁介绍 + 下载)
+ relay.xxmxxs.top/zhingzhuanzhan -> 中转站管理(要登录)
+ 管理账号 xmz7891 密码 @REVAedu120830
+"""
+from __future__ import annotations
+
+import ast
+import sys
+from pathlib import Path
+
+RELAY = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\relay" r"\relay.py")
+
+ADMIN_PATH = "/zhingzhuanzhan"
+ADMIN_USER = "xmz7891"
+ADMIN_PASS = "@REVAedu120830"
+
+
+def site_page_source() -> str:
+ """生成 _site_page() 的源码。用拼接,避免 f-string 花括号地狱。"""
+ head = '''def _site_page() -> str:
+ """官网首页:简洁介绍 + 下载。**不需要登录**。
+
+ 用户要求把 relay.xxmxxs.top 当官网用,
+ 管理后台挪到 ADMIN_PATH(见 _login_page / _admin_page)。
+ """
+ files = _list_downloads()
+
+ def pick(suffix, keyword=""):
+ for item in files:
+ name = item["name"].lower()
+ if name.endswith(suffix) and (not keyword or keyword in name):
+ return item
+ return None
+
+ setup = pick(".exe")
+ server = pick(".zip", "server")
+ watch = pick(".apk")
+
+ def card(title, desc, item, tag):
+ if not item:
+ return ('' + title + '
'
+ '
' + desc + '
'
+ '
暂未上传
')
+ mb = item["size"] / 1024 / 1024
+ size = ("%.1f MB" % mb) if mb >= 0.1 else ("%.0f KB" % (item["size"] / 1024))
+ href = "/download/" + quote(item["name"])
+ return ('' + title + '
'
+ '
' + desc + '
'
+ '
下载 ' + tag + ''
+ '
' + item["name"] + ' · ' + size + '
')
+
+ ver = ""
+ for item in files:
+ found = re.search(r"(\\d+\\.\\d+\\.\\d+)", item["name"])
+ if found:
+ ver = found.group(1)
+ break
+
+ cards = (card("电脑端", "教室那台 Windows 电脑装这个", setup, "安装包")
+ + card("服务器端", "要跨网络才需要,装在一台小机器上", server, "压缩包")
+ + card("手表端", "安卓手表装这个 APK", watch, "APK"))
+
+ badge = ("最新版 " + ver) if ver else ""
+
+ return """
+
+
+
+ykxmz · 教室电脑手表遥控
+
+
+
+
ykxmz
+
老师抬一下手腕,就能管教室里那台电脑。
+
""" + badge + """
+
+
放课件、放音频、投图到大屏、看看谁在打游戏、下课关机 ——
+不用走回讲台去点鼠标。
+
+ - 手表和电脑在同一个 WiFi:直连,几十毫秒
+ - 不在同一个网络:自动走中转,办公室也能管教室
+ - 屏幕被看时电脑上一定有提示条,关不掉
+
+
+
""" + cards + """
+
+
+
+
"""
+
+
+'''
+ return head
+
+
+def main() -> int:
+ text = RELAY.read_text(encoding="utf-8")
+ if "_site_page" in text:
+ print(" 已经改造过")
+ return 0
+
+ ok = True
+
+ # ---- 1. 补导入 ----
+ if "\nimport re" not in text:
+ text = text.replace("import os\n", "import os\nimport re\n", 1)
+ print(" 补 import re")
+ if "from urllib.parse import quote" not in text:
+ text = text.replace("import urllib.parse\n",
+ "import urllib.parse\n"
+ "from urllib.parse import quote\n", 1)
+ print(" 补 from urllib.parse import quote")
+
+ # ---- 2. 密码 + 账号 ----
+ old_pw = 'ADMIN_PASSWORD_DEFAULT = "120830"'
+ new_pw = ('ADMIN_PASSWORD_DEFAULT = "' + ADMIN_PASS + '"\n'
+ '# 管理页账号(原来只有密码,用户要求加上账号)\n'
+ 'ADMIN_USER_DEFAULT = "' + ADMIN_USER + '"\n'
+ 'ADMIN_USER = (os.environ.get("RELAY_ADMIN_USER") or\n'
+ ' ADMIN_USER_DEFAULT)')
+ if old_pw in text:
+ text = text.replace(old_pw, new_pw, 1)
+ print(" 默认管理密码 -> " + ADMIN_PASS + ",账号 " + ADMIN_USER)
+ else:
+ print(" [提示] 默认密码锚点没找到")
+
+ # ---- 3. admin_login 加账号参数 ----
+ if "def admin_login(password: str) -> str:" in text:
+ text = text.replace("def admin_login(password: str) -> str:",
+ "def admin_login(username: str, password: str) -> str:", 1)
+ print(" admin_login 签名加了账号")
+
+ old_body = (' if not password or not secrets.compare_digest(password, admin_password()):\n'
+ ' return ""')
+ new_body = (' # 账号也要对(用户要求用账号+密码登录)\n'
+ ' if username and username.strip() != ADMIN_USER:\n'
+ ' return ""\n'
+ ' if not password or not secrets.compare_digest(password, admin_password()):\n'
+ ' return ""')
+ if old_body in text:
+ text = text.replace(old_body, new_body, 1)
+ print(" admin_login 加密码校验")
+ else:
+ print(" [提示] admin_login 函数体锚点没找到")
+
+ # ---- 4. 路由 ----
+ old_route = (' if path in ("/", "/admin"):\n'
+ ' # 管理页要登录 —— 上面会显示配对码,不能谁都能看\n'
+ ' if not self._admin_gate():\n'
+ ' self._send(200, _login_page(\n'
+ ' "密码不对,再试一次" if _query(self.path, "bad") else ""\n'
+ ' ), "text/html; charset=utf-8")\n'
+ ' return\n'
+ ' self._admin_page()\n'
+ ' return')
+ new_route = (' # ---- 官网首页(公开,不用登录)----\n'
+ ' if path == "/":\n'
+ ' self._send(200, _site_page(), "text/html; charset=utf-8")\n'
+ ' return\n\n'
+ ' # ---- 管理后台 ----\n'
+ ' # 挪到 ' + ADMIN_PATH + '(用户要求)。\n'
+ ' # /admin 保留成跳转,免得存过书签的人打不开。\n'
+ ' if path == "/admin":\n'
+ ' self._redirect("' + ADMIN_PATH + '")\n'
+ ' return\n\n'
+ ' if path == "' + ADMIN_PATH + '":\n'
+ ' # 管理页要登录 —— 上面会显示配对码,不能谁都能看\n'
+ ' if not self._admin_gate():\n'
+ ' self._send(200, _login_page(\n'
+ ' "账号或密码不对,再试一次"\n'
+ ' if _query(self.path, "bad") else ""\n'
+ ' ), "text/html; charset=utf-8")\n'
+ ' return\n'
+ ' self._admin_page()\n'
+ ' return')
+ if old_route in text:
+ text = text.replace(old_route, new_route, 1)
+ print(" 路由:/ 官网," + ADMIN_PATH + " 管理页")
+ else:
+ print(" [失败] 路由锚点没找到")
+ ok = False
+
+ # ---- 5. 登录接口 ----
+ old_login = (' if path == "/admin/login":\n'
+ ' form = self._body().decode("utf-8", "replace")\n'
+ ' password = ""\n'
+ ' for piece in form.split("&"):\n'
+ ' name, _, value = piece.partition("=")\n'
+ ' if name == "password":\n'
+ ' from urllib.parse import unquote_plus\n'
+ ' password = unquote_plus(value)\n'
+ ' token = admin_login(password)\n'
+ ' if not token:\n'
+ ' log(f"管理页登录失败,来自 {self._client_ip()}")\n'
+ ' self._redirect("/?bad=1")\n'
+ ' return\n'
+ ' log(f"管理页登录成功,来自 {self._client_ip()}")\n'
+ ' self.send_response(302)\n'
+ ' self.send_header("Location", "/")')
+ new_login = (' if path == "/admin/login":\n'
+ ' form = self._body().decode("utf-8", "replace")\n'
+ ' username = ""\n'
+ ' password = ""\n'
+ ' from urllib.parse import unquote_plus\n'
+ ' for piece in form.split("&"):\n'
+ ' name, _, value = piece.partition("=")\n'
+ ' if name == "password":\n'
+ ' password = unquote_plus(value)\n'
+ ' elif name == "username":\n'
+ ' username = unquote_plus(value)\n'
+ ' token = admin_login(username, password)\n'
+ ' if not token:\n'
+ ' log("管理页登录失败(%s),来自 %s"\n'
+ ' % (username or "未填账号", self._client_ip()))\n'
+ ' self._redirect("' + ADMIN_PATH + '?bad=1")\n'
+ ' return\n'
+ ' log("管理页登录成功(%s),来自 %s"\n'
+ ' % (username, self._client_ip()))\n'
+ ' self.send_response(302)\n'
+ ' self.send_header("Location", "' + ADMIN_PATH + '")')
+ if old_login in text:
+ text = text.replace(old_login, new_login, 1)
+ print(" 登录接口:接受账号 + 密码")
+ else:
+ print(" [失败] 登录接口锚点没找到")
+ ok = False
+
+ # ---- 6. 登录表单加账号框 ----
+ old_form = '