diff --git a/_build/CHANGELOG.md b/_build/CHANGELOG.md new file mode 100644 index 0000000..445199d --- /dev/null +++ b/_build/CHANGELOG.md @@ -0,0 +1,10 @@ +## 1.7.0 + +- 电脑端改成 **Windows 安装包**(Inno Setup),双击安装,不再需要解压 zip +- 安装向导里填 6 位配对码,装完手表直接连,不用再跑一次设置 +- 安装包支持静默批量部署:`ykxmz-setup-1.7.0.exe /VERYSILENT /CODE=123456` +- 三份说明文档合并进安装向导,必须接受才能继续 +- 修配置分裂问题:管理员装的配对码,普通用户账号看不到(数据目录改成按安装位置判断) +- 修装到 Program Files 后报「拒绝访问」:ProgramData 数据目录自动授权 +- 修安装卡死:`--code` 命令处理完不返回 +- 中转管理页加上网站图标 diff --git a/_build/add_code_arg.py b/_build/add_code_arg.py new file mode 100644 index 0000000..ddc54dd --- /dev/null +++ b/_build/add_code_arg.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +r"""给被控端加 --code / --name,供安装包调用。 + +为什么需要: + 安装向导里用户填了 6 位配对码,安装程序必须把它传给被控端。 + 但命令行原来只有 --new-code(随机换一个),**没有"设成指定值"** —— + 所以用户填的码会被忽略,装完还得再跑一次设置窗口。 + +加了之后安装程序可以这样一条命令搞定: + ykxmz.exe --code 789113 --name 三年二班 --autostart on --silent + +--code 同时会标记 setup_done,这样就不会再弹设置窗口。 +""" +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +RUN = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows" + r"\run_agent.py")) + +# ---------- 1. 参数声明 ---------- + +OLD_ARG = ''' parser.add_argument("--new-code", action="store_true", help="换一组配对码")''' + +NEW_ARG = ''' parser.add_argument("--new-code", action="store_true", help="换一组配对码") + parser.add_argument("--code", default=None, metavar="6位数字", + help="把配对码设成指定值(必须是 6 位数字)。" + "安装包用这个把用户在安装向导里填的码写进去。") + parser.add_argument("--name", default=None, metavar="名字", + help="设置这台电脑在手表上显示的名字" + "(默认用 Windows 的计算机名)")''' + +# ---------- 2. 处理逻辑 ---------- + +OLD_USE = ''' if args.reset_devices: + store.devices = {} + store.save_devices() + if args.new_code or not store.pairing_code(): + store.new_pairing_code()''' + +NEW_USE = ''' if args.reset_devices: + store.devices = {} + store.save_devices() + + # --code:把配对码设成安装向导里填的那个。 + # 严格要 6 位数字 —— 和设置窗口里的规则一致, + # 免得用户装了之后发现手表上少输一位连不上。 + if args.code is not None: + digits = "".join(ch for ch in str(args.code) if ch.isdigit()) + if len(digits) != 6: + print(f"配对码要 6 位数字,收到的是 {args.code!r}") + return 2 + try: + store.set_pairing_code(digits) + except ValueError as exc: + print(f"设置配对码失败:{exc}") + return 2 + # 装完就是"设置好了",不要再弹设置窗口 + store.set("setup_done", True) + store.set("allow_new_pairing", True) + print(f"配对码已设为 {digits}") + + if args.name: + store.set("name", str(args.name).strip()[:32]) + + if args.new_code or not store.pairing_code(): + store.new_pairing_code()''' + +# ---------- 3. 帮助文本 ---------- + +OLD_HELP = ''' --new-code 换一组配对码''' + +NEW_HELP = ''' --new-code 换一组配对码 + --code 123456 把配对码设成指定值(安装包用) + --name "三年二班" 设置电脑在手表上显示的名字''' + + +def main() -> int: + text = RUN.read_text(encoding="utf-8") + ok = True + + if '"--code"' in text: + print(" 已经改过") + return 0 + + for old, new, label in ((OLD_ARG, NEW_ARG, "参数声明"), + (OLD_USE, NEW_USE, "处理逻辑"), + (OLD_HELP, NEW_HELP, "帮助文本")): + if old not in text: + print(f" [失败] {label} 锚点没找到") + ok = False + continue + text = text.replace(old, new, 1) + print(f" {label}") + + if not ok: + return 1 + + RUN.write_text(text, encoding="utf-8", newline="\n") + try: + ast.parse(text) + print(" run_agent.py 语法 OK") + except SyntaxError as exc: + print(f" 语法错误: 行 {exc.lineno}: {exc.msg}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/add_favicon.py b/_build/add_favicon.py new file mode 100644 index 0000000..ee7db7c --- /dev/null +++ b/_build/add_favicon.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +r"""给中转管理页加 favicon(用项目的 app.ico)。 + +用户提过:"那个中转管理端那个,网页图标没有啊,图标就是我之前给你那个图标" + +做法: + 1. 把 windows/app.ico 拷一份到 relay/favicon.ico(跟 relay.py 同目录) + 2. relay.py 加一条 GET /favicon.ico 路由直接给这个文件 + 3. 管理页
里加 + +为什么不做成 base64 内嵌: + ico 有 147KB,内嵌到 relay.py 里会让源码从 78KB 涨到 270KB, + 而且每次改图标都要动代码。放成独立文件干净得多。 +""" +from __future__ import annotations + +import ast +import shutil +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +RELAY = ROOT / "relay" / "relay.py" +ICON_SRC = ROOT / "windows" / "app.ico" +ICON_DST = ROOT / "relay" / "favicon.ico" + +# ---------- 1. 路由 ---------- + +ANCHOR_ROUTE = ''' # ---- 成品下载(挂在管理页上给人下 APK / 整包)----''' + +NEW_ROUTE = ''' # ---- 网站图标 ---- + if path in ("/favicon.ico", "/apple-touch-icon.png"): + icon = Path(__file__).resolve().parent / "favicon.ico" + if icon.is_file(): + try: + blob = icon.read_bytes() + except OSError: + blob = b"" + if blob: + self.send_response(200) + self.send_header("Content-Type", "image/x-icon") + self.send_header("Content-Length", str(len(blob))) + # 图标不常变,让浏览器缓存一天 + self.send_header("Cache-Control", "public, max-age=86400") + self.end_headers() + self.wfile.write(blob) + return + # 没有图标文件就 404,别让请求打到后面的逻辑 + self.send_response(404) + self.send_header("Content-Length", "0") + self.end_headers() + return + +''' + ANCHOR_ROUTE + +# ---------- 2. 页面里声明 ---------- + +ANCHOR_HEAD = '''''' + +NEW_HEAD = (''' + +''') + + +def main() -> int: + ok = True + + # ---- 图标文件 ---- + if not ICON_SRC.is_file(): + print(f" [失败] 找不到 {ICON_SRC}") + return 1 + shutil.copy2(ICON_SRC, ICON_DST) + print(f" 拷贝图标 -> relay/favicon.ico({ICON_DST.stat().st_size} 字节)") + + text = RELAY.read_text(encoding="utf-8") + + if "favicon.ico" in text: + print(" relay.py 里已经有 favicon 路由") + else: + for old, new, label in ((ANCHOR_ROUTE, NEW_ROUTE, "favicon 路由"), + (ANCHOR_HEAD, NEW_HEAD, "页面里的 link 声明")): + if old not in text: + print(f" [失败] {label} —— 锚点没找到") + ok = False + continue + text = text.replace(old, new, 1) + print(f" {label}") + + if ok: + RELAY.write_text(text, encoding="utf-8", newline="\n") + + if not ok: + return 1 + + try: + ast.parse(RELAY.read_text(encoding="utf-8")) + print(" relay.py 语法 OK") + except SyntaxError as exc: + print(f" 语法错误: 行 {exc.lineno}: {exc.msg}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/data_dir_fallback.py b/_build/data_dir_fallback.py new file mode 100644 index 0000000..12d64df --- /dev/null +++ b/_build/data_dir_fallback.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +r"""数据目录自动回退:写不进程序目录就转 ProgramData。 + +背景: + 原来的 data_dir() 就是 app_dir()/data —— 数据存在 exe 旁边。 + 绿色版没问题,但**做成安装包装到 Program Files 之后**, + 普通用户对那个目录没有写权限,配置会存不下去。 + +用户选的做法: + "能写 exe 旁边就写,不能就转 ProgramData" + + 这样两种情况都对: + * 绿色版 / 装在 D 盘 / 便携 U 盘 -> 还是存在旁边,拔走就带走配置 + * 装在 Program Files -> 自动转 C:\ProgramData\ykxmz, + 所有用户共用一份(配对码不会因为换 Windows 账号而丢) + +顺带加一个 YKXMZ_DATA 环境变量,测试和特殊部署可以强制指定。 +""" +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +CONFIG = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows" + r"\lanremote\config.py")) + +OLD = '''def app_dir() -> Path: + """程序所在目录(兼容 PyInstaller 冻结环境)。""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent.parent + + +def data_dir() -> Path: + return app_dir() / "data"''' + +NEW = '''def app_dir() -> Path: + """程序所在目录(兼容 PyInstaller 冻结环境)。""" + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve().parent + return Path(__file__).resolve().parent.parent + + +# 数据目录只算一次,之后缓存 —— 每个命令都去试写一次太浪费 +_DATA_DIR_CACHE: Path | None = None + + +def _can_write(folder: Path) -> bool: + """这个目录能不能写(不存在就先建)。""" + try: + folder.mkdir(parents=True, exist_ok=True) + probe = folder / ".write-test" + probe.write_text("x", encoding="utf-8") + probe.unlink() + return True + except OSError: + return False + + +def data_dir() -> Path: + """配置 / 令牌 / 接收文件的存放目录。 + + 优先级: + 1. 环境变量 YKXMZ_DATA(测试和特殊部署用) + 2. 程序目录下的 data/ —— 绿色版、便携 U 盘,拔走就带走配置 + 3. %PROGRAMDATA%\\ykxmz\\data —— 装到 Program Files 时用这个 + + 为什么要有第 3 条: + 安装包默认装到 C:\\Program Files\\ykxmz, + 普通用户对那里**没有写权限**(Windows 的硬规矩,不是 bug)。 + 不转走的话,配对码和令牌会存不下去,用户会莫名其妙连不上。 + ProgramData 是所有用户可写的公共位置,换 Windows 账号也不丢配置。 + """ + global _DATA_DIR_CACHE + if _DATA_DIR_CACHE is not None: + return _DATA_DIR_CACHE + + override = os.environ.get("YKXMZ_DATA") + if override: + _DATA_DIR_CACHE = Path(override) + return _DATA_DIR_CACHE + + beside = app_dir() / "data" + if _can_write(beside): + _DATA_DIR_CACHE = beside + return _DATA_DIR_CACHE + + base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData" + common = Path(base) / "ykxmz" / "data" + try: + common.mkdir(parents=True, exist_ok=True) + except OSError: + # 兜底:连 ProgramData 都写不了(极罕见),退回用户目录 + common = (Path(os.environ.get("LOCALAPPDATA") + or Path.home()) / "ykxmz" / "data") + common.mkdir(parents=True, exist_ok=True) + _DATA_DIR_CACHE = common + return _DATA_DIR_CACHE''' + + +def main() -> int: + text = CONFIG.read_text(encoding="utf-8") + + if "YKXMZ_DATA" in text: + print(" 已经改过") + return 0 + if OLD not in text: + print(" [失败] 锚点没找到") + return 1 + + text = text.replace(OLD, NEW, 1) + + # 确认 os 已经 import 了 + if "\nimport os" not in text: + text = text.replace("import sys\n", "import os\nimport sys\n", 1) + print(" 补了 import os") + + CONFIG.write_text(text, encoding="utf-8", newline="\n") + print(" data_dir 加了自动回退(ProgramData)") + + try: + ast.parse(text) + print(" config.py 语法 OK") + except SyntaxError as exc: + print(f" 语法错误: 行 {exc.lineno}: {exc.msg}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/final_accept.sh b/_build/final_accept.sh new file mode 100644 index 0000000..a3ac63e --- /dev/null +++ b/_build/final_accept.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# 最终验收:下载区 + 服务状态 +set -u + +BASE=http://127.0.0.1:8080 +PUB=https://relay.xxmxxs.top +UA='LanRemote/1.4.0' +KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null) + +echo "===== 1. 管理页下载区 =====" +page=$(curl -s --max-time 10 -H "X-Admin: $KEY" "$BASE/" 2>/dev/null) +for n in 'id=dlPanel' '下载最新版' '/v1/downloads'; do + if echo "$page" | grep -q "$n"; then echo " ✓ $n"; else echo " ✗ $n"; fi +done + +echo +echo "===== 2. 下载区文件列表 =====" +curl -s --max-time 15 "$BASE/v1/downloads" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +files = d.get("files", []) +pick = None +for f in files: + if "agent" in f["name"].lower(): + pick = f + break +print(" 会挑:", pick["name"] if pick else "(无)") +for f in files: + print(" %-32s %6.2f MB" % (f["name"], f["size"] / 1024 / 1024)) +' + +echo +echo "===== 3. 公网下载三个文件 =====" +for f in ykxmz-watch-1.6.2.apk ykxmz-server-1.6.2.zip; do + info=$(curl -s --max-time 120 -o /dev/null \ + -w '%{http_code} %{size_download}' \ + -H "User-Agent: $UA" "$PUB/download/$f" 2>/dev/null) + printf " %-30s %s\n" "$f" "$info" +done + +echo +echo "===== 4. 服务状态 =====" +for s in lanremote-nettune.service lanremote-relay lanremote-tunnel \ + lanremote-watchdog.timer; do + printf " %-30s %-9s %s\n" "$s" \ + "$(systemctl is-active "$s" 2>/dev/null)" \ + "$(systemctl is-enabled "$s" 2>/dev/null)" +done + +echo +echo "===== 5. 网络调优(重启后还在吗)=====" +echo " tcp_mtu_probing = $(cat /proc/sys/net/ipv4/tcp_mtu_probing)" +echo " MSS 规则 $(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条" +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 " 中转版本 = $(curl -s --max-time 8 $BASE/v1/health | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version"))' 2>/dev/null)" + +echo +echo "===== 6. 可用率(20 次)=====" +ok=0 +for i in $(seq 1 20); do + c=$(timeout 12 curl -4 -s -o /dev/null -w '%{http_code}' \ + -H "User-Agent: $UA" "$PUB/v1/health" 2>/dev/null) + [ "$c" = "200" ] && ok=$((ok+1)) + sleep 1 +done +echo " 公网 $ok/20 = $((ok * 5))%" diff --git a/_build/fix_code_oneshot.py b/_build/fix_code_oneshot.py new file mode 100644 index 0000000..149db6e --- /dev/null +++ b/_build/fix_code_oneshot.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +r"""修:--code / --name 处理完不退出,害得安装程序卡死。 + +现象: + 装完之后配对码没写进去,而且安装程序在 CurStepChanged 里 + 用 ewWaitUntilTerminated 等 ykxmz.exe --code ... --silent, + **永远等不到** —— 因为 --silent 是常驻守护进程,不会返回。 + +根因: + main() 里 --code 那段处理完之后没有 return, + 继续往下走到 run_silent(),于是变成"启动一个常驻服务"。 + +改法: + --code / --name 做成**一次性命令**:写完配置、打印、立刻退出。 + 想同时启动服务就显式加 --silent(那时才走守护进程)。 + +这样安装程序的分工就干净了: + CurStepChanged ykxmz.exe --code 123456 <- 秒退,只写配置 + [Run] postinstall ykxmz.exe --silent <- 启动,nowait 不等 +""" +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +RUN = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows" + r"\run_agent.py")) + +OLD = ''' if args.code is not None or args.name: + store = Store(data_dir()) + if args.code is not None: + digits = "".join(ch for ch in str(args.code) if ch.isdigit()) + if len(digits) != 6: + print(f"配对码要 6 位数字,收到的是 {args.code!r}") + return 2 + try: + store.set_pairing_code(digits) + except ValueError as exc: + print(f"设置配对码失败:{exc}") + return 2 + # 装完就算"设置好了",不要再弹设置窗口 + store.set("setup_done", True) + store.set("allow_new_pairing", True) + print(f"配对码已设为 {digits}") + if args.name: + store.set("name", str(args.name).strip()[:32]) + print(f"电脑名已设为 {store.get('name')}") + + if args.autostart:''' + +NEW = ''' if args.code is not None or args.name: + store = Store(data_dir()) + wrote = False + if args.code is not None: + digits = "".join(ch for ch in str(args.code) if ch.isdigit()) + if len(digits) != 6: + print(f"配对码要 6 位数字,收到的是 {args.code!r}") + return 2 + try: + store.set_pairing_code(digits) + except ValueError as exc: + print(f"设置配对码失败:{exc}") + return 2 + # 装完就算"设置好了",不要再弹设置窗口 + store.set("setup_done", True) + store.set("allow_new_pairing", True) + print(f"配对码已设为 {digits}") + wrote = True + if args.name: + store.set("name", str(args.name).strip()[:32]) + print(f"电脑名已设为 {store.get('name')}") + wrote = True + + # 只写了配置就结束 —— 这是一次性命令。 + # 不加这个 return 的话会继续走到 run_silent(), + # 变成常驻进程永不返回,调用方(安装程序)会一直等下去。 + # 想顺便启动服务,显式加 --silent。 + if wrote and not (args.silent or args.add_device is not None + or args.setup): + return 0 + + if args.autostart:''' + + +def main() -> int: + text = RUN.read_text(encoding="utf-8") + if "只写了配置就结束" in text: + print(" 已经改过") + return 0 + if OLD not in text: + print(" [失败] 锚点没找到") + return 1 + + text = text.replace(OLD, NEW, 1) + RUN.write_text(text, encoding="utf-8", newline="\n") + print(" --code / --name 改成一次性命令(写完就退出)") + + try: + ast.parse(text) + print(" run_agent.py 语法 OK") + except SyntaxError as exc: + print(f" 语法错误: 行 {exc.lineno}: {exc.msg}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_datadir_deterministic.py b/_build/fix_datadir_deterministic.py new file mode 100644 index 0000000..2b87777 --- /dev/null +++ b/_build/fix_datadir_deterministic.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +r"""修:按"能不能写"判断数据目录导致安装版配置分裂。 + +现象: + 安装时(管理员)把 settings.json 写到了 + C:\Program Files\ykxmz\data\settings.json + 之后普通用户运行时,_can_write() 对那个目录返回 False, + 于是转到 + C:\ProgramData\ykxmz\data + 目录是空的 —— 安装时设的配对码普通用户根本看不到。 + +根因: + 用"当前用户能不能写"来决定数据目录,这个判断**不稳定**: + 同一个程序,管理员跑和普通用户跑会得出不同结论, + 结果就是配置分裂成两份,谁也看不见谁。 + +改法: + 改成**确定性**判断 —— 看程序装在哪,不看当前是谁在跑: + * 装在 Program Files 下 -> 安装版,数据一律放 ProgramData + * 其它位置(绿色版/D盘/U盘)-> 能写 exe 旁边就写旁边 + 这样管理员和普通用户看到的是同一份配置。 +""" +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +CONFIG = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows" + r"\lanremote\config.py")) + +# ---------- 1. 加"是不是安装版"的判断 ---------- + +OLD_MARK = '''def data_dir() -> Path:''' + +NEW_MARK = '''def _is_installed_copy() -> bool: + """程序是不是装在 Program Files 下(也就是"安装版")。 + + 这个判断只跟**程序装在哪**有关,跟"当前是谁在跑"无关 —— + 这正是关键:数据目录必须对所有用户是同一个, + 否则管理员装的配置普通用户看不见(实测踩过)。 + """ + try: + here = str(app_dir()).lower().replace("/", "\\\\") + except OSError: + return False + for name in ("ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): + base = os.environ.get(name) + if not base: + continue + prefix = str(base).lower().replace("/", "\\\\").rstrip("\\\\") + if here == prefix or here.startswith(prefix + "\\\\"): + return True + return False + + +def data_dir() -> Path:''' + +# ---------- 2. 用它 ---------- + +OLD_PICK = ''' beside = app_dir() / "data" + if _can_write(beside): + _DATA_DIR_CACHE = beside + return _DATA_DIR_CACHE + + # ProgramData:所有用户共用一份配置(换 Windows 账号也不丢) + base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData" + common = Path(base) / "ykxmz" / "data" + if _can_write(common): + _DATA_DIR_CACHE = common + return _DATA_DIR_CACHE''' + +NEW_PICK = ''' # ── 安装版(装在 Program Files)一律用 ProgramData ── + # 不能按"我能不能写 exe 旁边"来判断: + # 安装时是管理员跑的,写得进去;之后普通用户跑就写不进去, + # 于是悄悄换了目录 —— 安装时设的配对码就找不着了(实测踩过)。 + # 按"装在哪"判断,管理员和普通用户看到的就是同一份配置。 + in_program_files = _is_installed_copy() + + beside = app_dir() / "data" + if not in_program_files and _can_write(beside): + _DATA_DIR_CACHE = beside + return _DATA_DIR_CACHE + + # ProgramData:所有用户共用一份配置(换 Windows 账号也不丢) + base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData" + common = Path(base) / "ykxmz" / "data" + if _can_write(common): + _DATA_DIR_CACHE = common + return _DATA_DIR_CACHE''' + + +def main() -> int: + text = CONFIG.read_text(encoding="utf-8") + ok = True + + if "_is_installed_copy" in text: + print(" 已经改过") + return 0 + + for old, new, label in ((OLD_MARK, NEW_MARK, "加 _is_installed_copy"), + (OLD_PICK, NEW_PICK, "数据目录改成确定性判断")): + if old not in text: + print(f" [失败] {label} —— 锚点没找到") + ok = False + continue + text = text.replace(old, new, 1) + print(f" {label}") + + if not ok: + return 1 + + CONFIG.write_text(text, encoding="utf-8", newline="\n") + try: + ast.parse(text) + print(" config.py 语法 OK") + except SyntaxError as exc: + print(f" 语法错误: 行 {exc.lineno}: {exc.msg}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_programdata_perm.py b/_build/fix_programdata_perm.py new file mode 100644 index 0000000..dfcbb0c --- /dev/null +++ b/_build/fix_programdata_perm.py @@ -0,0 +1,207 @@ +# -*- coding: utf-8 -*- +r"""修 ProgramData 权限问题(用户装完报 WinError 5 拒绝访问)。 + +现象: + PermissionError: [WinError 5] 拒绝访问。 + 'C:\ProgramData\_ykxmz_test\data\settings.json.tmp' -> + 'C:\ProgramData\_ykxmz_test\data\settings.json' + +原因(Windows 的老规矩,不是 bug): + 安装包是**管理员**跑的,它在 ProgramData 下建的 settings.json + 属主是管理员。之后普通用户跑被控端时: + * 能**新建**文件(ProgramData 默认允许 Users 新建) + * 但**不能覆盖/改名**别人建的文件(os.replace 要删目标) + 于是"写 .tmp -> 原子替换"这一步就炸了。 + +而我原来的 _can_write() 只测了"能不能建文件",没有测**替换**, +所以误判成可写,跑到一半才报错。 + +三层修: + ① _write() 替换失败就降级:直接写目标文件 -> 再不行去掉只读位 + ② _can_write() 改成测完整周期(建 -> 改名 -> 删) + ③ 目录选择:ProgramData 不行就退到 LOCALAPPDATA(每人一份,永远可写) + ④ 安装包给数据目录授权 users-modify(治本) +""" +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +CONFIG = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows" + r"\lanremote\config.py")) + +# ---------------- ① 写入降级 ---------------- + +OLD_WRITE = ''' @staticmethod + def _write(path: Path, data) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + os.replace(tmp, path)''' + +NEW_WRITE = ''' @staticmethod + def _write(path: Path, data) -> None: + """写 JSON。优先"临时文件 + 原子替换",失败就降级。 + + 为什么要降级: + 装到 Program Files / ProgramData 时,配置是**管理员**建的那一份, + 属主是管理员。普通用户跑的时候: + 能新建文件,但不能覆盖/改名别人建的文件。 + 于是 os.replace 报 WinError 5。 + 这时候退成"直接写目标文件" —— 牺牲原子性,换能存下配置。 + """ + tmp = path.with_suffix(path.suffix + ".tmp") + payload = json.dumps(data, ensure_ascii=False, indent=2) + + # 1) 正常路径:临时文件 + 原子替换 + try: + with open(tmp, "w", encoding="utf-8") as fh: + fh.write(payload) + os.replace(tmp, path) + return + except OSError: + pass + + # 2) 降级:直接覆盖目标文件 + try: + with open(path, "w", encoding="utf-8") as fh: + fh.write(payload) + Store._cleanup(tmp) + return + except OSError: + pass + + # 3) 再降级:目标被设成只读就摘掉只读位再写 + try: + os.chmod(path, 0o666) + except OSError: + pass + try: + with open(path, "w", encoding="utf-8") as fh: + fh.write(payload) + Store._cleanup(tmp) + return + except OSError: + Store._cleanup(tmp) + raise + + @staticmethod + def _cleanup(tmp: Path) -> None: + try: + if tmp.exists(): + tmp.unlink() + except OSError: + pass''' + +# ---------------- ② 可写检测测完整周期 ---------------- + +OLD_CANWRITE = '''def _can_write(folder: Path) -> bool: + """这个目录能不能写(不存在就先建)。""" + try: + folder.mkdir(parents=True, exist_ok=True) + probe = folder / ".write-test" + probe.write_text("x", encoding="utf-8") + probe.unlink() + return True + except OSError: + return False''' + +NEW_CANWRITE = '''def _can_write(folder: Path) -> bool: + """这个目录能不能**真正**用来存配置。 + + 注意不能只测"能新建文件" —— 那只过一半。 + 实际写配置用的是"临时文件 + 原子替换"(os.replace), + 它需要**覆盖已存在文件**的权限。 + 装到 ProgramData 时,管理员建的那份普通用户改不了, + 只测新建会误判成可写,然后跑起来才报 WinError 5。 + 所以这里把整个周期都跑一遍:建 -> 改名覆盖 -> 删。 + """ + try: + folder.mkdir(parents=True, exist_ok=True) + probe = folder / ".write-test" + probe.write_text("x", encoding="utf-8") + + # 关键:模拟真实写入的"替换"这一步 + target = folder / ".write-test-target" + try: + if target.exists(): + target.unlink() + probe.write_text("a", encoding="utf-8") + os.replace(probe, target) # <- 真正的门槛在这 + target.write_text("b", encoding="utf-8") # 覆盖已存在的 + target.unlink() + except OSError: + # 建得了新文件但替换不了 -> 不能当数据目录 + try: + probe.unlink() + except OSError: + pass + return False + return True + except OSError: + return False''' + +# ---------------- ③ 目录选择加 LOCALAPPDATA 兜底 ---------------- + +OLD_DIR = ''' base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData" + common = Path(base) / "ykxmz" / "data" + try: + common.mkdir(parents=True, exist_ok=True) + except OSError: + # 兜底:连 ProgramData 都写不了(极罕见),退回用户目录 + common = (Path(os.environ.get("LOCALAPPDATA") + or Path.home()) / "ykxmz" / "data") + common.mkdir(parents=True, exist_ok=True) + _DATA_DIR_CACHE = common + return _DATA_DIR_CACHE''' + +NEW_DIR = ''' # ProgramData:所有用户共用一份配置(换 Windows 账号也不丢) + base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData" + common = Path(base) / "ykxmz" / "data" + if _can_write(common): + _DATA_DIR_CACHE = common + return _DATA_DIR_CACHE + + # 兜底:ProgramData 写不了(ACL 没配好 / 权限被收紧)就退到用户目录。 + # 每人一份配置,但**永远能写** —— 总比跑起来报错强。 + local = (Path(os.environ.get("LOCALAPPDATA") + or Path.home()) / "ykxmz" / "data") + local.mkdir(parents=True, exist_ok=True) + _DATA_DIR_CACHE = local + return _DATA_DIR_CACHE''' + + +def main() -> int: + text = CONFIG.read_text(encoding="utf-8") + ok = True + + if "_cleanup" in text: + print(" 已经改过") + return 0 + + for old, new, label in ((OLD_WRITE, NEW_WRITE, "写入降级"), + (OLD_CANWRITE, NEW_CANWRITE, "可写检测测完整周期"), + (OLD_DIR, NEW_DIR, "LOCALAPPDATA 兜底")): + if old not in text: + print(f" [失败] {label} —— 锚点没找到") + ok = False + continue + text = text.replace(old, new, 1) + print(f" {label}") + + if not ok: + return 1 + + CONFIG.write_text(text, encoding="utf-8", newline="\n") + try: + ast.parse(text) + print(" config.py 语法 OK") + except SyntaxError as exc: + print(f" 语法错误: 行 {exc.lineno}: {exc.msg}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/gitee_release.py b/_build/gitee_release.py index 8cef3a1..c88a001 100644 --- a/_build/gitee_release.py +++ b/_build/gitee_release.py @@ -79,8 +79,28 @@ def main() -> int: version = sys.argv[2] if len(sys.argv) > 2 else "1.6.0" tag = f"v{version}" + # 更新内容从哪来: + # 1) 命令行第三个参数(直接给一段话) + # 2) _build/CHANGELOG.md 里最新一节 + changelog = sys.argv[3] if len(sys.argv) > 3 else '' + if not changelog: + note = ROOT / "_build" / "CHANGELOG.md" + if note.is_file(): + text = note.read_text(encoding="utf-8") + blocks = [b.strip() for b in text.split(chr(10) + "## ") if b.strip()] + for b in blocks: + if b.startswith(version) or b.startswith("v" + version): + changelog = b + break + if not changelog and blocks: + changelog = blocks[0] + if not changelog: + changelog = f"版本 {version}" + + # 电脑端给**安装包**(不是 zip 了,用户要求); + # 服务器端给 zip;手表端直接给 APK。 files = [ - ROOT / "dist" / f"ykxmz-agent-{version}.zip", + ROOT / "dist" / f"ykxmz-setup-{version}.exe", ROOT / "dist" / f"ykxmz-server-{version}.zip", ROOT / "dist" / f"ykxmz-watch-{version}.apk", ] @@ -93,36 +113,14 @@ def main() -> int: print(f" 标签: {tag}") print() - # ---- 1. 建发行版 ---- - body = f"""## ykxmz {version} + # 发行版说明**只放更新内容**(用户要求)。 + # 不放下载表格、不放用法说明 —— 那些在 README 里, + # 这里堆一堆反而看不清这版改了什么。 + body = f"""## 更新内容 -教室电脑手表遥控。 - -老师抬一下手腕就能管教室里那台电脑 —— 放课件、放音频、投图、 -看看谁在打游戏、下课关机,不用走回讲台。 - -### 这次下载哪个 - -| 你是 | 下这个 | -|---|---| -| 教室电脑装被控端 | `ykxmz-agent-{version}.zip` | -| 要跨网络(办公室管教室) | `ykxmz-server-{version}.zip` | -| 手表装控制端 | `ykxmz-watch-{version}.apk` | - -只在一个教室用的话,第三件(中转服务器)可以先不装, -手表和电脑连同一个 WiFi 就能直连。 - -### 怎么用 - -装完看包里的 `README.txt`,或者仓库首页的说明。 - -### 这一版改了什么 - -- 重写说明文档,按老师 / 电教 / 开发者 / 普通用户分开讲 -- 手表端内置根证书,修部分手表连中转时的证书校验失败 -- 中转站加下载区 -- 网络稳定性:修 MTU 问题,公网可用率从 48% 提到 95% 以上 +{changelog} """ + print(" 建发行版…") try: status, reply = api( diff --git a/_build/pack_include_ico.py b/_build/pack_include_ico.py new file mode 100644 index 0000000..2e100c3 --- /dev/null +++ b/_build/pack_include_ico.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +r"""服务器端打包时带上 favicon.ico。 + +package_remote.py 原来只收 relay 目录下的 .py / .sh, +favicon.ico 会被漏掉 —— 结果装到新设备上管理页又没图标了。 +""" +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +PKG = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\_build" + r"\package_remote.py")) + +OLD = ''' wanted = sorted( + [f for f in RELAY_SRC.iterdir() + if f.suffix in (".py", ".sh") and f.is_file()], + key=lambda f: f.name)''' + +NEW = ''' # 后缀白名单:.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)''' + + +def main() -> int: + text = PKG.read_text(encoding="utf-8") + if '.sh", ".ico"' in text: + print(" 已经改过") + return 0 + if OLD not in text: + print(" [失败] 锚点没找到") + return 1 + + text = text.replace(OLD, NEW, 1) + PKG.write_text(text, encoding="utf-8", newline="\n") + print(" 服务器端打包现在会带上 .ico") + + try: + ast.parse(text) + print(" 语法 OK") + except SyntaxError as exc: + print(f" 语法错误: 行 {exc.lineno}: {exc.msg}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/package_remote.py b/_build/package_remote.py index ee992b0..6acdc2e 100644 --- a/_build/package_remote.py +++ b/_build/package_remote.py @@ -17,7 +17,7 @@ import zipfile from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") -VERSION = "1.6.2" +VERSION = "1.7.0" NAME = f"ykxmz-{VERSION}" AGENT_SRC = ROOT / "dist_exe" / "ykxmz" @@ -482,9 +482,11 @@ def main() -> int: 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") and f.is_file()], + 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) diff --git a/_build/release.py b/_build/release.py index f7d02e3..492ad46 100644 --- a/_build/release.py +++ b/_build/release.py @@ -105,13 +105,22 @@ def main() -> int: print((proc.stderr or "")[-600:]) return 1 - # ---- 3. 打包 ---- - step(3, "打三个成品") + # ---- 3. 打成品 ---- + step(3, "打成品(安装包 + 服务器 zip + APK)") proc = sh([sys.executable, "_build/package_remote.py"]) - print(proc.stdout[-1400:]) + print(proc.stdout[-1200:]) if proc.returncode != 0: return 1 + # 电脑端对外发的是**安装包**,不是 zip + proc = sh([sys.executable, "installer/build_installer.py"], timeout=1800) + for line in (proc.stdout or "").splitlines(): + if "安装包" in line or "失败" in line: + print(f" {line.strip()}") + if proc.returncode != 0: + print((proc.stdout or "")[-1200:]) + return 1 + # ---- 4. 关键测试 ---- step(4, "跑关键测试") for test in ("audit_encoding.py", "test_net_tune.py", "test_chunked_body.py", @@ -128,7 +137,7 @@ def main() -> int: # 打包测试要起真的中转 + agent,前面刚跑过几个测试, # 偶尔会撞上端口/隧道还没恢复 —— 重试一次就好 ok_pkg = False - for attempt in range(2): + for attempt in range(3): proc = sh([sys.executable, "-u", "_tests/test_package_remote.py"], timeout=1800) out = proc.stdout or "" @@ -147,7 +156,7 @@ def main() -> int: "$_.CommandLine -match 'relay.py' } | " "ForEach-Object { Stop-Process -Id $_.ProcessId -Force " "-ErrorAction SilentlyContinue }"], timeout=120) - time.sleep(4) + time.sleep(8) if not ok_pkg: return 1 diff --git a/_build/switch_release_to_setup.py b/_build/switch_release_to_setup.py new file mode 100644 index 0000000..bf60d90 --- /dev/null +++ b/_build/switch_release_to_setup.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +r"""把"电脑端发 zip"改成"电脑端发安装包 exe"。 + +用户要求:发行版里电脑端要给**安装包**,不是 zip。 + +改两个地方: + _build/gitee_release.py 上传列表里 agent zip -> setup exe + _build/release.py 打包之后多跑一步编译安装包 +""" +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +GITEE = ROOT / "_build" / "gitee_release.py" +RELEASE = ROOT / "_build" / "release.py" + +# ---------- 1. gitee_release.py 的上传列表 ---------- + +G_OLD = ''' files = [ + ROOT / "dist" / f"ykxmz-agent-{version}.zip", + ROOT / "dist" / f"ykxmz-server-{version}.zip", + ROOT / "dist" / f"ykxmz-watch-{version}.apk", + ]''' + +G_NEW = ''' # 电脑端给**安装包**(不是 zip 了,用户要求); + # 服务器端给 zip;手表端直接给 APK。 + files = [ + ROOT / "dist" / f"ykxmz-setup-{version}.exe", + ROOT / "dist" / f"ykxmz-server-{version}.zip", + ROOT / "dist" / f"ykxmz-watch-{version}.apk", + ]''' + +# ---------- 2. release.py 多编译一步安装包 ---------- + +R_OLD = ''' # ---- 3. 打包 ---- + step(3, "打三个成品") + proc = sh([sys.executable, "_build/package_remote.py"]) + print(proc.stdout[-1400:]) + if proc.returncode != 0: + return 1''' + +R_NEW = ''' # ---- 3. 打成品 ---- + step(3, "打成品(安装包 + 服务器 zip + APK)") + proc = sh([sys.executable, "_build/package_remote.py"]) + print(proc.stdout[-1200:]) + if proc.returncode != 0: + return 1 + + # 电脑端对外发的是**安装包**,不是 zip + proc = sh([sys.executable, "installer/build_installer.py"], timeout=1800) + for line in (proc.stdout or "").splitlines(): + if "安装包" in line or "失败" in line: + print(f" {line.strip()}") + if proc.returncode != 0: + print((proc.stdout or "")[-1200:]) + return 1''' + +R_OLD2 = ''' step(3, "打三个成品")''' +R_NEW2 = ''' step(3, "打成品(安装包 + 服务器 zip + APK)")''' + + +def patch(path: Path, pairs, label: str) -> bool: + text = path.read_text(encoding="utf-8") + changed = False + for old, new in pairs: + if new.strip() and new in text: + continue + if old not in text: + print(f" [提示] {path.name}: 找不到「{label}」的锚点(可能已改)") + continue + text = text.replace(old, new, 1) + changed = True + if changed: + path.write_text(text, encoding="utf-8", newline="\n") + print(f" {path.name}: 已更新") + else: + print(f" {path.name}: 无需改动") + try: + ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError as exc: + print(f" [失败] {path.name} 语法错误: 行 {exc.lineno}: {exc.msg}") + return False + return True + + +def main() -> int: + ok = True + if not patch(GITEE, [(G_OLD, G_NEW)], "上传列表"): + ok = False + if not patch(RELEASE, [(R_OLD, R_NEW)], "打包步骤"): + ok = False + + print() + print(" 复核:") + g = GITEE.read_text(encoding="utf-8") + r = RELEASE.read_text(encoding="utf-8") + print(f" gitee_release.py 用 setup exe: " + f"{'✓' if 'ykxmz-setup-{version}.exe' in g else '✗'}") + print(f" release.py 会编译安装包: " + f"{'✓' if 'build_installer.py' in r else '✗'}") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/sync_device_161.py b/_build/sync_device_161.py index faf36bf..850c1f9 100644 --- a/_build/sync_device_161.py +++ b/_build/sync_device_161.py @@ -1,7 +1,11 @@ # -*- coding: utf-8 -*- -r"""设备回来了:把 1.6.1 传到下载区,并部署最新 relay。""" +r"""设备回来了:把成品传到下载区,并部署最新 relay。 + +版本号从 _build/package_remote.py 里读,不用手改。 +""" from __future__ import annotations +import re import sys from pathlib import Path @@ -10,7 +14,14 @@ sys.path.insert(0, str(ROOT / "_build")) from devssh import run, upload # noqa: E402 -VER = "1.6.1" + +def current_version() -> str: + text = (ROOT / "_build" / "package_remote.py").read_text(encoding="utf-8") + match = re.search(r'VERSION\s*=\s*"([^"]+)"', text) + return match.group(1) if match else "0.0.0" + + +VER = current_version() REMOTE_DIR = "/root/lanremote-relay/downloads" UPLOADS = [ diff --git a/_build/test_install_fresh.ps1 b/_build/test_install_fresh.ps1 new file mode 100644 index 0000000..6a1016e --- /dev/null +++ b/_build/test_install_fresh.ps1 @@ -0,0 +1,84 @@ +# 全新安装验证:清干净 -> 装到默认路径 -> 跑一遍 -> 检查权限 +# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File _build\test_install_fresh.ps1 + +$ErrorActionPreference = "Continue" +$root = "C:\Users\shu\Desktop\deepseekagent\LanRemote" +$setup = Join-Path $root "dist\ykxmz-setup-1.6.2.exe" +$appId = "{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1" + +Write-Host "" +Write-Host "===== 1. 彻底清理上一次的安装 =====" -ForegroundColor Cyan +Get-Process ykxmz, unins000 -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue +Start-Sleep -Seconds 2 + +foreach ($dir in @("C:\Program Files\ykxmz", "C:\ProgramData\_ykxmz_test", + "C:\ProgramData\ykxmz")) { + if (Test-Path "$dir\unins000.exe") { + Start-Process "$dir\unins000.exe" ` + -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES" -Wait ` + -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } + Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue +} +# 删掉 Inno 记住的目录,否则下次还会装到老地方 +foreach ($hive in @("HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall")) { + Remove-Item "$hive\$appId" -Recurse -Force -ErrorAction SilentlyContinue +} +Write-Host " 清理完成" + +Write-Host "" +Write-Host "===== 2. 全新安装(不带 /DIR,看默认去哪)=====" -ForegroundColor Cyan +$log = Join-Path $root "_build\install_fresh.log" +Remove-Item $log -Force -ErrorAction SilentlyContinue +$proc = Start-Process -FilePath $setup ` + -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", + "/LOG=$log", "/CODE=654321" -PassThru -Wait +Write-Host " 安装 exit=$($proc.ExitCode)" + +Write-Host "" +Write-Host "===== 3. 安装位置 =====" -ForegroundColor Cyan +$exe = "C:\Program Files\ykxmz\ykxmz.exe" +Write-Host " $exe : $(Test-Path $exe)" +Write-Host " _internal : $((Get-ChildItem 'C:\Program Files\ykxmz\_internal' -ErrorAction SilentlyContinue | Measure-Object).Count) 项" +Write-Host " docs : $((Get-ChildItem 'C:\Program Files\ykxmz\docs' -ErrorAction SilentlyContinue | Measure-Object).Count) 个" + +Write-Host "" +Write-Host "===== 4. 数据目录权限 =====" -ForegroundColor Cyan +$dataDir = "C:\ProgramData\ykxmz\data" +if (Test-Path $dataDir) { + Write-Host " $dataDir 存在" + (Get-Acl $dataDir).Access | + Where-Object { $_.IdentityReference -match "Users|用户" } | + ForEach-Object { Write-Host " $($_.IdentityReference) : $($_.FileSystemRights)" } +} else { + Write-Host " $dataDir 不存在(还没运行过,正常)" +} + +Write-Host "" +Write-Host "===== 5. 跑起来(这一步以前报 WinError 5)=====" -ForegroundColor Cyan +if (Test-Path $exe) { + & $exe --status 2>&1 | + Select-String -Pattern "配对码|数据目录|开机自启" | + ForEach-Object { Write-Host " $($_.Line.Trim())" } + + Write-Host "" + Write-Host " 写一次配置(模拟实际运行):" + $out = & $exe --name "三年二班" 2>&1 + $out | ForEach-Object { Write-Host " $_" } + Write-Host " exit=$LASTEXITCODE" + + Write-Host "" + Write-Host " 再看状态:" + & $exe --status 2>&1 | + Select-String -Pattern "配对码|数据目录" | + ForEach-Object { Write-Host " $($_.Line.Trim())" } +} else { + Write-Host " ✗ 找不到 exe,安装失败了" -ForegroundColor Red + if (Test-Path $log) { + Get-Content $log -Encoding UTF8 | Select-Object -Last 15 | + ForEach-Object { Write-Host " $_" } + } +} diff --git a/_build/zip_project.py b/_build/zip_project.py new file mode 100644 index 0000000..f7f680f --- /dev/null +++ b/_build/zip_project.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +r"""把整个项目打成压缩包,交给用户。 + +包含:全部源码(三端 + 安装包脚本 + 测试 + 构建脚本)+ 三份文档 + + 已构建好的成品(安装包 / APK / 两个 zip) + +排除: + * .git 版本库(几十 MB,而且推上去过) + * dist_exe 40MB PyInstaller 产物,一条命令能重建 + * _build/pyinstaller_work 同上 + * __pycache__ / *.pyc + * _tests/out_* / _tests/received 测试临时数据 + * _build/jc* 编译中间产物 + * **_build/.gitee_token** ← Gitee 令牌,**绝对不能进包** +""" +from __future__ import annotations + +import re +import sys +import zipfile +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +OUT = ROOT / "dist" / "ykxmz-项目完整源码.zip" + +# 目录级排除(按相对路径前缀) +EXCLUDE_DIRS = ( + ".git", + "dist_exe", + "_build/pyinstaller_work", + "_build/jc_all", + "_build/jc_tls", + "_build/jc_tls2", + "_build/jc_v15", + "_build/out_java", + "_build/out_app", + "_tests/out_java", + "_tests/out_app", + "_tests/received", + "_tests/_stubdata", + "_tests/shots", + "__pycache__", + "android/build", + "android/gen", + "android/bin", + "windows/data", +) + +# 文件名级排除 +EXCLUDE_NAMES = { + ".gitee_token", + "Thumbs.db", + "desktop.ini", + ".DS_Store", +} + +# 后缀级排除 +EXCLUDE_SUFFIXES = ( + ".pyc", ".pyo", ".pyd", + ".log", ".tmp", ".toc", ".pyz", ".pkg", + ".bak", ".orig", +) + +# 要打进去的顶层条目(只打这些,别的一律不要) +INCLUDE_TOP = ( + "windows", "android", "relay", "installer", "docs", "tools", + "_tests", "_build", + "README.md", "LICENSE.md", ".gitignore", + "diag", "dist", +) + +# dist 里只带上成品,别把中间目录带进去 +DIST_KEEP = (".exe", ".apk", ".zip", ".idsig", ".txt") + + +def excluded(rel: str, path: Path) -> bool: + rel_posix = rel.replace("\\", "/") + for prefix in EXCLUDE_DIRS: + if rel_posix == prefix or rel_posix.startswith(prefix + "/"): + return True + if path.name in EXCLUDE_NAMES: + return True + if path.suffix.lower() in EXCLUDE_SUFFIXES: + return True + # dist 目录只留成品 + if rel_posix.startswith("dist/"): + if path.is_file() and not path.name.lower().endswith(DIST_KEEP): + return True + if rel_posix == "dist" and path.is_dir(): + return False + return False + + +def main() -> int: + if OUT.exists(): + OUT.unlink() + OUT.parent.mkdir(parents=True, exist_ok=True) + + count = 0 + total = 0 + skipped_token = False + + with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED, + compresslevel=6) as archive: + for top in INCLUDE_TOP: + root = ROOT / top + if not root.exists(): + print(f" [跳过] 没有 {top}") + continue + + if root.is_file(): + archive.write(root, f"ykxmz/{top}") + count += 1 + total += root.stat().st_size + continue + + for path in sorted(root.rglob("*")): + rel = str(path.relative_to(ROOT)) + if excluded(rel, path): + if path.name == ".gitee_token": + skipped_token = True + continue + if path.is_dir(): + continue + archive.write(path, f"ykxmz/{rel.replace(chr(92), '/')}") + count += 1 + total += path.stat().st_size + + size = OUT.stat().st_size / 1024 / 1024 + print(f" ✓ {OUT.name}") + print(f" {count} 个文件,原始 {total / 1024 / 1024:.1f} MB," + f"压缩后 {size:.1f} MB") + print(f" Gitee 令牌已排除: {skipped_token or '(本来就没扫到)'}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/test_apk_contents.py b/_tests/test_apk_contents.py index af1feda..c5504cc 100644 --- a/_tests/test_apk_contents.py +++ b/_tests/test_apk_contents.py @@ -23,7 +23,7 @@ def _latest_apk() -> Path: (ROOT / "dist").glob("*watch*.apk"), key=lambda p: p.stat().st_mtime, reverse=True) return candidates[0] if candidates else \ - ROOT / "dist" / "ykxmz-watch-1.6.2.apk" + ROOT / "dist" / "ykxmz-watch-1.7.0.apk" APK = _latest_apk() diff --git a/_tests/test_package_remote.py b/_tests/test_package_remote.py index c0515cb..597c481 100644 --- a/_tests/test_package_remote.py +++ b/_tests/test_package_remote.py @@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent # 被控端 -> 一个 zip # 服务器端 -> 一个 zip # 控制端 -> 直接给 APK(不打 zip) -AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.6.2.zip" -SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.6.2.zip" -WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.6.2.apk" +AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.7.0.zip" +SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.7.0.zip" +WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.7.0.apk" TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain") JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe" JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe" @@ -89,7 +89,7 @@ def main() -> int: for label, path in (("被控端", agent_dir / "README-agent.txt"), ("服务器端", relay_dir / "README-server.txt"), ("控制端(旁边那份)", - ROOT / "dist" / "ykxmz-watch-1.6.2-说明.txt")): + ROOT / "dist" / "ykxmz-watch-1.7.0-说明.txt")): raw = path.read_bytes() if path.is_file() else b"" check(f"{label}说明有 UTF-8 BOM", raw[:3] == b"\xef\xbb\xbf", raw[:3]) diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 7c75340..400dd30 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -2,7 +2,7 @@' + message + '
') if message else '' html = """ + +