Files
ykxmz/_build/package_remote.py
T
xmz7891 8880e7033d 修「经中转几次后永久哑掉」——「按一下没反应」的真因
== 现象 ==

用户报:
  * 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
  * 走中转「已配对」,但点按钮没反应

局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。

== 证据链(插桩复现出来的)==

被控端日志里直接抛了未捕获异常,线程当场死:

    Exception in thread client-relay:
      server.py:407  session.send(reply)
      relay.py:166   self._link.send_to_client(data)
      relay.py:357   self._post("/v1/agent/send", ...)
      relay.py:766   raise RelayError(f"请求失败: {last_error}")
    lanremote.relay.RelayError: 请求失败: Idle

之后的状态:
  * client-relay 线程没了 —— 电脑再也不往手表回任何东西
  * relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
  * **手表看到的就是「连上了,但按一下毫无反应」**

写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。

== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==

_post() 里锁只包住「取连接」那一段:

    with self._http_lock:
        connection = self._http      # 锁到这里就放了
    try:
        connection.request("POST", ...)   # 锁外发请求

于是两个线程会同时用同一条 connection:
  * relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
  * client-relay 线程同时 POST /v1/agent/send

HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。

_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。

修法:
  * **发命令用独立连接**(_post 加 dedicated 参数)。
    长轮询那条常年被占着,发命令不该去挤它,各走各的。
  * 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
    不再只锁取连接那一下。
  * 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
  * _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
    (否则那条连接会被永久标记为忙)。

== 根因 2:回包失败会打死整个会话线程 ==

server.py 的 _client_loop 里:

    if reply is not None:
        session.send(reply)      # <- 不在任何 try 里
    ...
    except OSError:              # <- 外层只捕获 OSError
        pass

而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。

修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。

== 修复效果 ==

    修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
    修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常

_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**

这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。

== 同批修掉验证阶段找到的其它问题 ==

1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
   register() 用 `device_id in self.devices` 判断老设备,但超时清理
   把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
   第二天开机就会被自己的开关挡住,而且**重启也救不回来**
   (清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
   管理员在页面上还看不到这台设备,不知道该放开谁。
   修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
   register 改查这份历史;清理后补 _save();delete_device 才真正忘记。

2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
   admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
   `\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
   整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
   元凶是我加改码功能那次提交。改成 `\\n`。

3. **--relay / --relay-status 崩溃**(严重)
   RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
   run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
   1 required positional argument」(用户以为地址填错了),
   一处直接报栈。两处都补上 url。

4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
   字母数字 + - _ . :)

5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
   返回值反映真实运行状态;不再用「对象建过没有」判断)

6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节

== 局域网全面优化(task-2,另一个智能体完成)==

4 个真问题:
  * **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
    被判成不同网络,明明能直连却去走中转还费流量。
    改成按网卡真实掩码比较。
  * **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
  * **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
  * **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
    新增定期回切(先便宜地 TCP ping,通了才动真格,
    不破坏正在工作的中转连接)。

实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
  现已修复。)

顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。

== 服务端一键部署脚本(task-1,另一个智能体完成)==

relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
  * 自动检查并安装 cloudflared
  * 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
  * **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
    不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
    不对外开放,绝不能把私人域名烧成默认值)
  * 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动

顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。

== 默认中转地址改成空白 ==

用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。

== 文档同步(task-5,另一个智能体完成)==

README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
  * 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
  * 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
  * 删掉「地址已经内置好了」(现在是错的)
  * AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
  * RELAY_README 原来写「两条隧道同时跑会互相争抢」,
    和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
2026-10-07 17:59:39 +08:00

675 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""打「跨网络版」的三件套压缩包。
用户要求:压缩包里三个东西
1. 被控端(Windows 电脑)
2. 服务器端(随身 WiFi / Linux)
3. 控制端(手表 APK)
产物:
dist/LanRemote-跨网络版-1.5.0.zip
"""
from __future__ import annotations
import shutil
import sys
import zipfile
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERSION = "1.8.2"
NAME = f"ykxmz-{VERSION}"
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
# 三份正式文档(许可协议 / 安装前必读 / 安装后须知)
DOCS = ROOT / "docs"
RELAY_SRC = ROOT / "relay"
APK_SRC = ROOT / "dist" / f"ykxmz-watch-{VERSION}.apk"
# 被控端目录里带的 .bat 工具
TOOLS = ("fxfhq.bat", "wlzj.bat", "xffhq.bat")
TOP_README = f"""========================================================
ykxmz {VERSION} · 远程管理工具
========================================================
作者:小麦子
先看这三份:
LICENSE.txt 许可协议(安装前必看,有使用红线)
BEFORE-INSTALL.txt 安装前必读(前信息)
AFTER-INSTALL.txt 安装后须知(后信息)
下面是这个包里有哪三个东西、怎么装。
这个包里三个东西,分别装在三台设备上:
1-被控端(电脑) 装在你**要控制的那台 Windows 电脑**上。
双击 ykxmz.exe 就行,不用装 Python。
2-服务器端(随身WiFi) 装在一台能上外网的 Linux 小机器上(你的随身 WiFi)。
它负责把"不在同一个网络"的电脑和手表接起来。
3-控制端(手表) 装在你的安卓手表上。一个 APK,直接装。
【怎么用】四步
**先说最要紧的一条:本软件不带任何中转地址,也不提供官方服务器。**
只在同一个 WiFi 下用(手表 + 电脑连同一个路由器)的话,
跳过第一步和第四步,装完就能连。
要跨网络(在办公室管教室),必须自己搭一台中转站。
第一步:架服务器端(只做一次,**跨网络才需要**)
把「2-服务器端」整个文件夹拷到那台能上外网的 Linux 小机器上,执行:
bash deploy.sh
它会装好中转服务 + 网络调优 + Cloudflare 隧道,并打印一个
**属于你自己的公网地址**。记下这个地址。
(形如 https://你的域名 —— 域名是你自己的,脚本里没有默认域名。)
小提示:忘了地址就在那台机器上执行 lanremote-url,或者浏览器打开
http://<那台机器的IP>:8080/url —— 直接返回当前地址。
第二步:装被控端(电脑上,只做一次)
双击安装包
→ 输入 6 位配对码(自己定,比如 789113)
→ 勾上"开机自启"和"放行防火墙"
→ 点确定,会弹一次管理员确认,之后窗口全消失,后台静默运行
第三步:装控制端(手表上)
装 APK
→ 点「自动搜索电脑」(同一个 WiFi 下用这个,不用填地址)
→ 或者点「输入配对码连接」填电脑上那个 6 位码
第四步:跨网络的话,把中转地址填上(**自己搭的那个**)
电脑: ykxmz.exe --relay https://你的域名
手表: 首页点「中转设置」→ 填同一个地址
然后回首页点「输入配对码连接」,输那 6 位数字,连上。
【关于配对码:单设备单配对码】
一台电脑第一次设的配对码会被服务器记住。
以后同一台电脑再报别的码,服务器会**改回它原来那个**。
好处:你不会因为重装/误操作把配对码搞乱,手表永远认得出这台电脑。
一个配对码也不能同时属于两台电脑(服务器会拒绝)。
【服务器端能管什么】
浏览器打开: <中转地址>/?key=... 或者直接用管理密码登录
管理密码:120830(可在服务器上改)
能看:哪些设备在线、配对码、流量、会话数
能做:踢掉某个设备的会话、抹掉某个配对码绑定
注意:中转服务器**只是管道**,它不认识传输内容。
配对、令牌校验、权限判断全部由电脑端自己做 ——
所以就算中转机被攻破,也进不了你的电脑。
【每台设备分别看哪个说明】
电脑: 1-agent/README-agent.txt
随身 WiFi:2-server/README-server.txt
手表: 装完打开 App,首页有「连不上?点这里诊断」
"""
AGENT_README = f"""========================================================
被控端(Windows 电脑) 版本 {VERSION}
========================================================
*** 不需要安装 Python ***
ykxmz.exe 是编译好的独立程序,
拷到任何一台 Windows 电脑上双击就能用。
【装】
1) 双击 ykxmz.exe
2) 弹出窗口,输入 6 位配对码(自己定个记得住的,比如 789113)
两个勾:
[x] 设置开机自启
[x] 顺便放行防火墙(要管理员确认一次)
3) 点「确定,开始使用」
→ 弹一次 UAC,点「是」
→ 黑终端一闪,在修防火墙
→ 窗口全消失,转入后台静默运行
(没有托盘图标、没有窗口、没有终端)
【开箱即用:一个地址都不用填(同一个 WiFi 下)】
手表和这台电脑连**同一个路由器**时,直接就能连上,
配对码对上就行 —— 不需要任何服务器、不需要填任何地址。
程序会自己往局域网广播找电脑,所以电脑上装了几块虚拟网卡、
IP 是多少,都不用管。
【跨网络:必须自己搭一台中转站,然后填地址】
⚠ **本软件没有内置任何中转地址,作者也不对外提供中转服务。**
要跨网络用(比如在办公室管教室),你得自己部署一台中转站:
服务端压缩包里有 deploy.sh,一条命令装好,它会打印一个
属于你自己的公网地址。然后把这个地址填给被控端:
ykxmz.exe --relay https://你的域名
只在同一个 WiFi 下用的话,这一节完全不用管。
【支持多个地址,按顺序试】
用分号隔开,第一个通的就用它。推荐把局域网地址也写进去:
ykxmz.exe --relay "192.168.1.50:8080;https://你的域名"
为什么带上局域网地址?
* 快:局域网几十毫秒,绕隧道要一秒多
* 稳:不依赖隧道,隧道抽风也不影响
* 那个地址永远不变,不用管公网地址变没变
地址**不用写 http:// 或 https://**,程序会自己判断:
192.168.x.x / 10.x.x.x / *.local -> 按 http 连(局域网是明文)
域名 -> 按 https 连
看看当前状态(会显示用了哪个地址、有几个候选):
ykxmz.exe --relay-status
关掉中转(只走局域网直连):
ykxmz.exe --relay ""
【忘了中转地址?】
如果那台小机器在你身边(同一个网络),浏览器打开:
http://<它的IP>:8080/url
它会直接返回当前的公网地址(纯文本,不用登录)。
或者在那台机器上执行:
lanremote-url
小技巧:把局域网地址也填进 --relay,就永远不用管公网地址变没变:
ykxmz.exe --relay "192.168.1.50:8080;https://你的域名"
【配对码被别人占了会弹通知】
每台电脑第一次设的配对码会存到**中转服务器**上。
如果另一台电脑也想用同一个码:
1. 服务器拒绝它
2. 那台电脑上**弹通知**:
「该配对码已被使用,请换一个配对码」
3. 它停下等你换码(不会反复弹、不会刷屏)
4. 你在设置里换个新码,它自动接着连上
所以一个配对码同时只能有一台电脑用。
【配对码:单设备单配对码】
这台电脑第一次设的配对码会被服务器记住。
以后就算你重设成别的,服务器也会改回原来那个 ——
手表永远认得出这台电脑。
相关:
--new-code 换一组随机配对码(服务器会按上面的规则处理)
--reset-devices 清空已配对的手表
【目录里这几个文件】
ykxmz.exe 主程序
wlzj.bat 网络自检(连不上时跑它)
xffhq.bat 一键修复防火墙(右键以管理员运行)
fxfhq.bat 放行脚本(程序自动生成的)
【其它命令行】
--setup 重新打开设置窗口
--silent 直接静默运行
--add-device 10 静默运行 + 开放 10 分钟配对
--autostart on|off 开关开机自启
--status 看当前状态
--netcheck 90 网络自检(同 wlzj.bat)
--fix-firewall 提权修复防火墙
【连不上?按这个顺序】
1) 任务管理器里能看到 ykxmz.exe 吗?没有就 --setup
2) 右键 xffhq.bat → 以管理员身份运行
3) 双击 wlzj.bat 做网络自检
4) 手表上点「连不上?点这里诊断」
5) 跨网络的话,确认 --relay-status 显示在线
"""
RELAY_README = f"""========================================================
服务器端(随身 WiFi / Linux) 版本 {VERSION}
========================================================
作用:让**不在同一个网络**的电脑和手表连上。
手表 ──HTTPS──┐
├──> Cloudflare ──> 本机 :8080
电脑 ──HTTPS──┘
本程序是**纯标准库**的 Python,不装任何第三方包。
内存占用约 20MB,加 cloudflared 共约 60MB —— 512MB 的小机器跑得动。
【装:一条命令,全自动】
把本文件夹拷到设备上,然后:
bash deploy.sh
它会一口气做完这些,不用你一步步敲:
1. 检查并安装 cloudflared(已经装了就用现成的)
2. 把中转程序装到 /root/lanremote-relay/
3. 网络调优(keepalive / MSS / MTU / 开机加速)
4. 注册 systemd 服务并设成开机自启
(中转、网络调优、两份隧道、看门狗)
5. 配好 Cloudflare 隧道
6. 自检,然后把**公网地址**打印出来
── 三种常见情形 ──
① 机器上已经有隧道凭据(/root/.cloudflared/*.json)
直接: bash deploy.sh
② 有 Cloudflare 给的隧道 Token
TUNNEL_TOKEN=eyJhIjoi... bash deploy.sh
(用环境变量而不是 --token,Token 就不会留在 shell 历史里)
③ 换域名 / 只装中转不要隧道
bash deploy.sh --hostname relay.你的域名
bash deploy.sh --skip-tunnel
看全部参数: bash deploy.sh --help
**重跑 deploy.sh = 升级**,不会把已有配置冲掉:
设备数据、配对码、管理密码、隧道凭据、手工改过的 config.yml
都会原样保留。
【常用命令】
lanremote-status 看整体状态(服务 / 端口 / 公网 / 内存)
lanremote-url 打印当前公网地址
lanremote-key 打印管理钥匙
systemctl status lanremote-relay
systemctl status lanremote-tunnel
journalctl -u lanremote-relay -f
【关于 install.sh(旧版脚本,仍在包里)】
装匿名快速隧道,公网地址每次重启都变。
新机器建议用 deploy.sh(具名隧道,地址固定)。
install.sh 保留是为了兼容已经用它装过的机器。
【地址老变怎么办?记住一个局域网地址就行】
匿名隧道的公网地址每次重启都会变。查当前地址:
浏览器打开 http://<随身WiFi的IP>:8080/url
它会直接返回当前的公网地址,纯文本,不用登录。
具名隧道(deploy.sh 装的)地址是固定的,不用管这个。
【管理】
官网: <公网地址>/
管理页: <公网地址>/?key=<lanremote-key 的输出>
管理密码: 120830(存在 data/admin_password.txt,可改)
能看到:设备列表、在线状态、配对码、流量、会话数
能操作:踢会话、抹掉配对码绑定
【下载区:静态文件走 Cloudflare 边缘缓存】
官网页面上的三个文件(安装包 / 服务端 zip / APK)和产品截图,
都由 Cloudflare 的边缘节点**直接发送**,不用回源到这台小机器。
这不是默认行为 —— relay.py 给这些路径发了
`Cache-Control: public, max-age=604800, immutable`
(下载文件名带版本号,同一名字内容永不改变,所以能长缓存)。
为什么重要:这台机器通常在 4G 上,回源吞吐实测只有 3~67 KB/s,
15 MB 的安装包根本下不动。走边缘之后实测 695 KB/s,快四十多倍,
而且**不消耗这台机器的流量**。
换了新版本的安装包怎么办?
文件名里带版本号(ykxmz-setup-1.8.2.exe),新文件是新 URL,
自然就是新的缓存条目,不会拿到旧的。
旧版本的条目到期(7 天)后自动消失。
【网络调优:这些参数都是实测调出来的】
deploy-tune.sh 会做这五件事,改动之前先看它开头的说明:
① TCP keepalive 30/10/3
这条 4G 线路会**静默丢连接**(不发明文 FIN/RST)。
Linux 默认要 7200 秒才发现死链,表现就是"过几分钟突然 502"。
改成 30 秒首发探测之后,最快 60 秒就能发现并重建。
② tcp_mtu_probing = 1
接口写着 MTU 1500,但实际路径扛不住(1472 通、1500 全丢)。
不打开探测的话,新建 TLS 连接约 30% 失败。
③ MSS 钳制到 1360
只限制 TCP 段,不动链路层。
实测:不夹 87%,夹到 1360/1400 都是 100%。
④ 接口 MTU 1400
**这一条是专门给 QUIC 隧道的**:MSS 钳制走 iptables 的
TCPMSS 目标,只对 TCP 生效;QUIC 跑 UDP,完全不受它保护。
QUIC 的包超过路径 MTU 就会被丢,而 UDP 丢包不回 ICMP,
只能干等握手超时(日志里那句
"failed to dial to edge with quic: timeout: no recent network activity")。
实测把 MTU 从 1500 降到 1400:建齐 6 条连接 28 秒 -> 13 秒。
⑤ mask 掉 systemd-networkd-wait-online
这台机器 wlan0 归 NetworkManager 管,
systemd-networkd-wait-online 傻等一个不归它的网络,
干等 2 分钟超时,把 relay / tunnel 全拖住。
实测:掉电重启后公网恢复 2 分 10 秒 -> 24 秒。
【隧道:两种协议同时在跑(这是故意的)】
deploy.sh 会装**两份**隧道服务,而且**两份都启用**:
lanremote-tunnel QUIC
lanremote-tunnel-h2 HTTP/2
它们跑的是**同一个隧道 ID**,Cloudflare 会把请求分摊到两个实例
注册上来的全部连接上(合计 10 条,横跨两种协议)。
为什么要这么干 —— 两种协议各有各的病,实测:
QUIC 尾部延迟好(P90 约 2.5 秒),
但建连会 "no recent network activity" 超时,偶发失败
HTTP/2 建连可靠(走 TCP,受 MSS 钳制保护),
但尾部延迟差(P90 约 14 秒)——
TCP 队头阻塞:一条连接上丢一个包,所有流一起卡
两个同时跑 = 协议级故障隔离。QUIC 那半边犯病时 HTTP/2 还在服务,
反过来也一样。实测连续 25 分钟可用率 100%(单协议时是 96~98%)。
所以**不要**为了"省内存"随手停掉其中一个 —— 停了就退化成单协议了。
真想只留一种(比如这台机器的线路对 UDP 特别不友好,
日志里大量 "Failed to dial a quic connection"):
systemctl disable --now lanremote-tunnel-h2 # 只留 QUIC
systemctl disable --now lanremote-tunnel # 只留 HTTP/2
【改端口 / 改协议 / 换域名】
重跑 deploy.sh,带上参数就行(幂等,不会冲掉已有数据):
bash deploy.sh --protocol http2 # 主实例换成 http2
bash deploy.sh --port 9090 # 换监听端口
bash deploy.sh --hostname relay.你的域名
【省内存】
默认给服务设了 MemoryMax(relay 128M / tunnel 160M)。
要再省就把用不上的服务停掉:
systemctl stop mariadb php8.2-fpm apache2
systemctl disable mariadb php8.2-fpm apache2
"""
WATCH_README = f"""========================================================
控制端(安卓手表) 版本 {VERSION}
========================================================
【装】
把 ykxmz-watch-{VERSION}.apk 传到手表上安装。
手表如果提示"未知来源",在设置里允许一下。
【连】
1) 打开 App,首页有四个东西:
中转设置(跨网络用) **填你自己搭的那台中转站的地址**
局域网连接(同一个WiFi)同一个 WiFi 时走这条,最快
输入配对码连接 经中转连
服务器:还没测 点右边「检查服务器」看延迟
2) 电脑和手表在同一个 WiFi -> 点「局域网连接」,自动搜到电脑,点一下就连
电脑在外面 -> 先点「中转设置」填地址(见下),
再点「输入配对码连接」,填电脑上那个 6 位码
3) 连上后是 10 个标签页:
媒体 屏幕 文件 语音 电脑 网络 系统 教室 效率 娱乐
【中转地址要自己填】
⚠ **App 里没有内置任何中转地址,作者也不对外提供中转服务。**
只在同一个 WiFi 下用的话,不用填任何地址 —— 点「局域网连接」就行。
要跨网络,你必须先自己部署一台中转站(服务端 zip 里有 deploy.sh
一键脚本),拿到它打印的公网地址,填进「中转设置」。
电脑那边也要填同一个地址:ykxmz.exe --relay https://你的域名
两个地址填得不一样是连不上的 —— 这是最常见的坑。
【连不上时看这个】
App 会把错误翻译成人话:
"证书校验失败" -> 手表系统太旧或网络被劫持
"证书时间不对" -> 把手表的日期时间调成自动获取
"连接超时" -> 网络慢或被防火墙挡
"电脑拒绝连接" -> 电脑上 ykxmz 没开
"域名解析不了" -> 手表没联网
首页的「检查服务器」能测出到中转的延迟(快/一般/慢三档配色)。
【回退】
系统页最上面三个按钮:
回退上一步 撤销刚才那一步
回退全部 把能撤的都撤了
看能回退什么 列出还能撤哪些
能撤的:所有开关、所有画面(全屏大字/投图/通知等)
撤不了的:关机、重启、删文件、杀进程 —— 这些**不会**出现在回退列表里,
免得你以为什么都能退回去。
"""
def write_readme(path: Path, text: str) -> None:
"""说明文档统一写成 UTF-8 with BOM —— Windows 记事本不加 BOM 会认成 GBK。"""
path.write_bytes(b"\xef\xbb\xbf" + text.replace("\n", "\r\n").encode("utf-8"))
def _zip_dir(zip_path: Path, source: Path, arc_root: str) -> int:
"""把一个目录打成一个 zip(内部带一层 arc_root 目录名)。"""
if zip_path.exists():
zip_path.unlink()
count = 0
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED,
compresslevel=6) as archive:
for item in sorted(source.rglob("*")):
if item.is_file():
archive.write(item, Path(arc_root) / item.relative_to(source))
count += 1
return count
def _copy_docs(target: Path) -> int:
"""把三份正式文档拷到目标目录(UTF-8 BOM + CRLF)。"""
done = 0
for name in ("LICENSE.txt", "BEFORE-INSTALL.txt", "AFTER-INSTALL.txt"):
src = DOCS / name
if src.is_file():
write_readme(target / name, src.read_text(encoding="utf-8"))
done += 1
else:
print(f" [警告] 缺少文档 {src}")
return done
def main() -> int:
if not AGENT_SRC.is_dir():
print(f" [失败] 找不到被控端: {AGENT_SRC}")
return 1
if not APK_SRC.is_file():
print(f" [失败] 找不到 APK: {APK_SRC}")
return 1
dist = ROOT / "dist"
dist.mkdir(parents=True, exist_ok=True)
work = dist / "_build_tmp"
if work.exists():
shutil.rmtree(work, ignore_errors=True)
results = []
# ================= 1. 被控端 =================
print(" [1/3] 被控端(Windows)")
agent_name = f"ykxmz-agent-{VERSION}"
agent_dir = work / agent_name
shutil.copytree(AGENT_SRC, agent_dir,
ignore=shutil.ignore_patterns("data", "received",
"*.idsig", "*.log"))
for tool in TOOLS:
if not (agent_dir / tool).is_file():
src = ROOT / "tools" / tool
if src.is_file():
shutil.copy2(src, agent_dir / tool)
write_readme(agent_dir / "README-agent.txt", AGENT_README)
for junk in ("先看这个.txt", "使用说明.txt"):
target = agent_dir / junk
if target.is_file():
target.unlink()
_copy_docs(agent_dir)
if not (agent_dir / "ykxmz.exe").is_file():
print(" [失败] 被控端里没有 ykxmz.exe")
return 1
agent_zip = dist / f"{agent_name}.zip"
n = _zip_dir(agent_zip, agent_dir, agent_name)
results.append(("被控端", agent_zip, n))
# ================= 2. 服务器端 =================
print(" [2/3] 服务器端(随身 WiFi / Linux)")
server_name = f"ykxmz-server-{VERSION}"
server_dir = work / server_name
server_dir.mkdir(parents=True)
# 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。
# 改成:relay 目录里所有 .py / .sh 都带上。
# 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。
# 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。
wanted = sorted(
[f for f in RELAY_SRC.iterdir()
if f.suffix in (".py", ".sh", ".ico") and f.is_file()],
key=lambda f: f.name)
for src in wanted:
shutil.copy2(src, server_dir / src.name)
# ⚠ 产品截图是个**目录**,上面那个 iterdir() 只收文件、而且不递归,
# 所以 site_shots/ 一直没被打进服务端包 ——
# 装到新机器上官网的截图全是 404。
# 这个是拿「从 zip 解压出来真跑一遍」的端到端测试才发现的。
shots_src = RELAY_SRC / "site_shots"
if shots_src.is_dir():
shutil.copytree(shots_src, server_dir / "site_shots")
else:
print(" [警告] relay/site_shots 不存在,官网截图会缺失")
write_readme(server_dir / "README-server.txt", RELAY_README)
_copy_docs(server_dir)
for must in ("relay.py", "install.sh", "watchdog.sh", "net-tune.sh"):
if not (server_dir / must).is_file():
print(f" [警告] 服务器端少了 {must}")
# 一键部署脚本必须进包 —— 用户拿到的 zip 里要有它才能双击/一条命令跑起来
for must in ("deploy.sh", "deploy-tune.sh"):
if not (server_dir / must).is_file():
print(f" [失败] 服务器端少了一键部署脚本 {must}")
return 1
server_zip = dist / f"{server_name}.zip"
n = _zip_dir(server_zip, server_dir, server_name)
results.append(("服务器端", server_zip, n))
# ================= 3. 控制端(直接给 APK)=================
print(" [3/3] 控制端(手表)—— 不打 zip,直接给 APK")
# 顺带把说明也放一份在旁边,方便看
watch_note = dist / f"ykxmz-watch-{VERSION}-说明.txt"
write_readme(watch_note, WATCH_README)
results.append(("控制端", APK_SRC, 1))
shutil.rmtree(work, ignore_errors=True)
# ================= 汇总 =================
print()
print(" ================ 产物 ================")
ok = True
for label, path, count in results:
if not path.is_file():
print(f" {label}: ✗ 缺失!")
ok = False
continue
size = path.stat().st_size / 1024 / 1024
extra = f"({count} 个文件)" if count > 1 else ""
print(f" {label:6} {path.name:34} {size:6.2f} MB {extra}")
if not ok:
return 1
# ================= 验证 =================
print()
print(" 验证压缩包内容:")
for label, path, _count in results:
if path.suffix != ".zip":
continue
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
bad = archive.testzip()
if bad:
print(f" [失败] {path.name} 里有坏文件: {bad}")
return 1
print(f" {path.name}: {len(names)} 个条目,完整")
with zipfile.ZipFile(results[0][1]) as archive:
names = archive.namelist()
if not any("ykxmz.exe" in n for n in names):
print(" [失败] 被控端包里没有 ykxmz.exe")
return 1
with zipfile.ZipFile(results[1][1]) as archive:
names = archive.namelist()
for must in ("relay.py", "install.sh", "watchdog.sh", "net-tune.sh",
"deploy.sh", "deploy-tune.sh"):
if not any(must in n for n in names):
print(f" [失败] 服务器端包里没有 {must}")
return 1
print()
print(" 三个产物齐全")
return 0
if __name__ == "__main__":
sys.exit(main())