Files
ykxmz/_build/package_remote.py
T
xmz7891 fd8be87209 2.1.8:修 Win7 语音播报 + 快捷栏按钮做大且4格全能换
用户这一轮报了两个问题,都修了。

== 一、Win7 上语音播报不能用 ==

用户原话:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"

**原来只有一条路**:

    powershell -NoProfile -WindowStyle Hidden -Command
        "Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"

这在 Win7 上有三个坑,任何一个都能让它彻底不出声:

  1. **`powershell` 只按名字找**
     Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
     精简版系统、或从服务/计划任务拉起时 PATH 里可能没有 ——
     CreateProcess 按名字找不到时是**静默失败**的。

  2. **Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`**
     PS 3.0 起才按 UTF-16 处理。中文经 `-Command` 传进去容易乱码 ——
     表现是念出来是乱码,或者干脆不念。

  3. **`System.Speech` 需要 .NET Framework 3.0+**
     而系统自带的 **SAPI5** 从 Windows XP 就有,**根本不需要 .NET**。

**改成多方案降级**,按"依赖越少越优先"排:

    1. cscript + VBScript + SAPI5      <- Win98 起就有,不用装任何东西
    2. wscript + VBScript + SAPI5      <- 上一条被安全软件拦时的备选
    3. powershell + SAPI5 (COM)        <- 绕开 .NET
    4. powershell + System.Speech      <- 原来的做法,改用绝对路径
    5. powershell(PATH) + System.Speech <- 最原始的,兜底

一条通了**记住它**,以后直接用(不用每次从头试)。

**顺带修掉一个"骗人"的行为**:原来 `say()` 用 `Popen` 不等进程结束,
失败了也返回"已播报" —— 明明没出声却报成功。
现在等退出码,**全失败会明确报错并列出试过哪几条**。
代价是同步等待(念多久等多久),但"报成功其实没出声"比"慢一点"糟糕得多。

还加了 `_build/diag_tts.py`:在一台机器上把 5 条方案全试一遍并报告
哪条能用 —— 下次再有"某台机器上不行",跑一下就知道。

VBScript 的细节:**必须用 ANSI(mbcs) 写**,写成 UTF-8 的话
cscript 读出来中文是乱码(VBScript 的老规矩,很隐蔽)。
长文本还要分句 —— VBScript 单行有长度上限。

== 二、快捷栏按钮太小 + 4 格都要能换 ==

用户原话:"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点,
           然后呢,这个每每个四格算换一下吧,第一个第二个第三个
           第四个都能换东西"

  * **按钮做大**:30dp -> 46dp(接近 Android 建议的最小可点区域 48),
    字号 11 -> 14。30dp 在表盘上手指按上去一半在栏外。
    **栏高做成常量 QUICK_BAR_DP,栏高 / 面板 topMargin / addView
    三处用同一个值** —— 分三处写的话改一个忘一个,
    表现是"内容被盖住"或"上面空一条"。

  * **4 格全部可自定义**。用户最早说"其中有一个按钮不能改,
    功能就是一键返回桌面",第一版就按"第 4 格写死 desktop"做的。
    现在他改主意了 —— 所以 desktop 变成**普通可选动作**,
    默认仍放第 4 格(习惯不用改),但想换也能换。
    可选动作从 7 个变成 8 个。

  **教训(写进测试注释了)**:别把"用户当时的一句话"当成永久约束
  焊进结构里;真是约束的话,注释里要写清是什么时候的要求,
  下次他改主意时才知道该动哪儿。

== 三、修一个"测试自己没擦干净"造成的假 bug ==

全量回归报:

    [FAIL] 配对码正确写入  实际 '(旧的残留值)'

而 `(旧的残留值)` 是**上一次测试遗留**的配对码 —— 数据目录
`C:\ProgramData\ykxmz\data` 在卸载时**故意不删**(那里存着用户的
中转地址、配对码等,卸载不该删),所以机器上会留着上一轮的
settings.json。测试装的时候虽然传了 `/CODE=517284`,但环境没擦干净时
就可能读到旧值 —— 表现成"安装包有 bug"。

**修法**:`test_installer_cycle.py` 加一步"装前先清残留"
(正规卸载 -> 杀进程 -> 删安装目录 -> **删数据目录**)。
数据目录那一步是关键 —— 卸载器不删它。

== 四、其他 ==

  * `docs/项目规矩.md` 第一节改成**按预案执行**:
    预案一 = 做完测试没问题就上传;预案二 = 做完测试但不上传,直接给用户。
    用户没说预案几时默认按预案一,并在交付时说明。
  * 顺手把那份文档里的老域名 `relay.xxmxxs.top` 改成现在的
    `yk.xxmxxs.top`(老服务器早就退役了)。
  * 修掉自己造的重复定义:往 actions.py 插降级链时,
    `list_voices` / `say` 各插了两份(pyflakes 报 redefinition)。
  * 一次危险的误操作:`Path.write_text(..., newline='\n')` 会**先截断文件
    再校验参数**,参数非法时文件已经空了 —— 我用它把
    `_tests/test_quickbar.py` 清空了。已重写。
    **以后不给 write_text 传 newline。**

== 验证 ==

    全量回归(21 关)全部 PASS
    新增关卡:3a13 手表快捷栏 / 3a14 语音播报降级
    安装包周期 23 项全过(装 -> 验 -> 卸 -> 再验)
    Java 编译通过(android-34, --release 8)
    本机实测语音播报:cscript 那条通了(2.4 秒)
    全部产物重建,Win7 兼容校验通过
2026-10-09 23:30:05 +08:00

685 lines
26 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 = "2.1.8"
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=... 或者直接用管理密码登录
管理密码:<改从环境变量读>(可在服务器上改)
能看:哪些设备在线、配对码、流量、会话数
能做:踢掉某个设备的会话、抹掉某个配对码绑定
注意:中转服务器**只是管道**,它不认识传输内容。
配对、令牌校验、权限判断全部由电脑端自己做 ——
所以就算中转机被攻破,也进不了你的电脑。
【每台设备分别看哪个说明】
电脑: 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 的输出>
管理密码: <改从环境变量读>(存在 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 # 只跑 HTTP/2(停掉 QUIC 那半边)
bash deploy.sh --protocol quic # 只跑 QUIC
bash deploy.sh # 不传 = 两条一起跑(推荐)
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 / .ico / .service / .timer 都带上。
# 后缀白名单:
# .py 服务本体
# .sh 部署/运维脚本
# .ico 管理页图标(漏了的话新设备上管理页是默认样式)
# .service /
# .timer systemd 单元。**这两个是后加的** ——
# 加监测单元的时候才发现白名单里没有它们,
# 于是 monitor.sh 进了包、拉它的 timer 却没进,
# 装到新机器上监测根本不会启动(而且不会报错,只是静默不跑)。
wanted = sorted(
[f for f in RELAY_SRC.iterdir()
if f.suffix in (".py", ".sh", ".ico", ".service", ".timer")
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())