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 @@ + android:versionName="1.7.0"> diff --git a/android/build_apk.ps1 b/android/build_apk.ps1 index a924b63..795ef68 100644 --- a/android/build_apk.ps1 +++ b/android/build_apk.ps1 @@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat" $work = Join-Path $android "build" $outDir = Join-Path $root "dist" -$apkName = "ykxmz-watch-1.6.2.apk" +$apkName = "ykxmz-watch-1.7.0.apk" function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan } @@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk" --min-sdk-version 24 ` --target-sdk-version 34 ` --version-code 5 ` - --version-name "1.6.2" ` + --version-name "1.7.0" ` --auto-add-overlay if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" } diff --git a/docs/AFTER-INSTALL.txt b/docs/AFTER-INSTALL.txt index 21d47e2..e228580 100644 --- a/docs/AFTER-INSTALL.txt +++ b/docs/AFTER-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:1.6.2 +版本:1.7.0 作者:小麦子 装好之后看这一页:怎么确认它在跑、怎么改设置、 @@ -197,7 +197,7 @@ 八、版本记录 -------------------------------------------------------- - 1.6.2 + 1.7.0 * 跨网络支持:Cloudflare 具名隧道,固定域名 * 中转服务器:单设备单配对码、管理页可删设备 * 手表端:自动判断局域网/中转并无缝切换 @@ -209,6 +209,6 @@ -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.6.2 + 软件:ykxmz 1.7.0 许可条款见《许可协议》(LICENSE.txt) -------------------------------------------------------- diff --git a/docs/BEFORE-INSTALL.txt b/docs/BEFORE-INSTALL.txt index ddb0cb9..7d5c994 100644 --- a/docs/BEFORE-INSTALL.txt +++ b/docs/BEFORE-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:1.6.2 +版本:1.7.0 作者:小麦子 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 @@ -152,6 +152,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.6.2 + 软件:ykxmz 1.7.0 继续安装,即表示你已阅读并同意《许可协议》。 -------------------------------------------------------- diff --git a/docs/LICENSE.txt b/docs/LICENSE.txt index 6b08412..ee42497 100644 --- a/docs/LICENSE.txt +++ b/docs/LICENSE.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz(远程管理工具) -版本:1.6.2 +版本:1.7.0 作者:小麦子 生效日期:2026 年 10 月 5 日 @@ -150,7 +150,7 @@ -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.6.2 + 软件:ykxmz 1.7.0 如果你不同意本协议的任何一条,请不要安装或使用本软件。 -------------------------------------------------------- diff --git a/installer/build_installer.py b/installer/build_installer.py new file mode 100644 index 0000000..4f35972 --- /dev/null +++ b/installer/build_installer.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +r"""编译 Inno Setup 安装包。 + +做三件事: + 1. 把三份文档合并成 license-combined.txt(Inno 的 LicenseFile 只认一个文件) + 2. 把 .iss 里的版本号同步成当前版本 + 3. 调 ISCC.exe 编译 +""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +INSTALLER = ROOT / "installer" +ISS = INSTALLER / "ykxmz.iss" +COMBINED = INSTALLER / "license-combined.txt" +ISCC = Path(r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe") + +DOCS = ("LICENSE.txt", "BEFORE-INSTALL.txt", "AFTER-INSTALL.txt") + + +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" + + +def build_license() -> None: + """三份文档拼成一份,给 Inno 的许可页用。 + + 每份之间加分隔线,免得读起来像一份文档。 + """ + parts = [] + for index, name in enumerate(DOCS): + path = ROOT / "docs" / name + if not path.is_file(): + print(f" [警告] 缺少 {name}") + continue + text = path.read_text(encoding="utf-8-sig").strip() + if index: + parts.append("") + parts.append("=" * 70) + parts.append("") + parts.append(text) + body = "\n".join(parts) + "\n" + + # 装许可页的正文要 UTF-8 **带 BOM**,否则 Inno 在中文系统上会显示乱码 + COMBINED.write_bytes(b"\xef\xbb\xbf" + body.encode("utf-8")) + print(f" 合并三份文档 -> {COMBINED.name}({len(body)} 字符)") + + +def sync_version(version: str) -> None: + text = ISS.read_text(encoding="utf-8") + new = re.sub(r'#define MyAppVersion "[^"]*"', + f'#define MyAppVersion "{version}"', text) + if new != text: + ISS.write_text(new, encoding="utf-8", newline="\r\n") + print(f" .iss 版本号 -> {version}") + else: + print(f" .iss 版本号已经是 {version}") + + +def main() -> int: + if not ISCC.is_file(): + print(f" [失败] 找不到 Inno Setup: {ISCC}") + return 1 + + version = current_version() + print(f" 编译 ykxmz 安装包 v{version}") + print() + + build_license() + sync_version(version) + + agent = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe" + if not agent.is_file(): + print(f" [失败] 被控端还没构建:{agent}") + print(" 先跑 windows\\build_exe.ps1") + return 1 + + print() + print(" 调 ISCC 编译…") + proc = subprocess.run([str(ISCC), str(ISS)], + capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=1800) + out = (proc.stdout or "") + (proc.stderr or "") + for line in out.splitlines(): + line = line.strip() + if line and ("Error" in line or "error" in line + or "Successful" in line or "compil" in line.lower() + or "warning" in line.lower()): + print(f" {line}") + + if proc.returncode != 0: + print(f" [失败] ISCC 退出码 {proc.returncode}") + print(out[-1500:]) + return 1 + + exe = ROOT / "dist" / f"ykxmz-setup-{version}.exe" + if not exe.is_file(): + print(f" [失败] 没生成 {exe}") + return 1 + size = exe.stat().st_size / 1024 / 1024 + print() + print(f" ✓ 安装包: {exe.name} {size:.2f} MB") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/installer/license-combined.txt b/installer/license-combined.txt new file mode 100644 index 0000000..15b17bd --- /dev/null +++ b/installer/license-combined.txt @@ -0,0 +1,533 @@ +======================================================== + ykxmz 软件许可协议 +======================================================== + +软件名称:ykxmz(远程管理工具) +版本:1.7.0 +作者:小麦子 +生效日期:2026 年 10 月 5 日 + + +-------------------------------------------------------- +第一条 协议的范围 +-------------------------------------------------------- + +本协议是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件 +(含被控端程序、中转服务器程序、手表端应用及相关文档, +以下统称"本软件")达成的许可使用协议。 + +下载、安装、复制或以任何方式使用本软件,即表示你已阅读、 +理解并同意本协议全部条款。如不同意,请不要安装或使用。 + + +-------------------------------------------------------- +第二条 授权范围 +-------------------------------------------------------- + +在遵守本协议的前提下,作者授予你一项免费的、非独占的、 +不可转让的使用许可,你可以: + + 1. 在**你自己拥有或已获得明确授权**的设备上安装和使用本软件; + 2. 复制并分发本软件的完整副本(须保持文件完整、不得修改、 + 不得去除本协议与作者署名); + 3. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备。 + +本软件免费使用,作者不收取任何费用。 + + +-------------------------------------------------------- +第三条 【重要】使用红线 +-------------------------------------------------------- + +本软件是一套远程控制工具,被控端可以被远程操作屏幕、文件、 +音量、电源等。正因为能力较强,以下行为**严格禁止**: + + 1. **禁止**在未经设备所有者或管理者明确同意的情况下, + 在他人的电脑、手机、平板上安装被控端; + + 2. **禁止**用于偷窥、跟踪、监视他人(包括配偶、家人、 + 同事、同学、下属)的隐私; + + 3. **禁止**用于窃取账号密码、聊天记录、照片、文件等 + 任何个人信息; + + 4. **禁止**将本软件伪装成系统组件、隐藏其存在、 + 故意使设备使用者无法发现或无法卸载; + + 5. **禁止**用于任何违反所在地法律法规的活动。 + +**一句话:只能管你自己的设备,或者别人明确同意你管的设备。** + +违反上述任何一条,本协议立即终止,且由此产生的全部法律 +责任由使用者自行承担,与作者无关。 + + +-------------------------------------------------------- +第四条 使用者应尽的通知义务 +-------------------------------------------------------- + +如果你在被控端所在的设备上使用本软件,你有义务确保: + + 1. 该设备的使用者知道这台设备可以被远程管理; + 2. 该设备的使用者知道如何查看、关闭或卸载本软件; + 3. 被控端保持"连接时可见提示"功能开启(默认开启)。 + + +-------------------------------------------------------- +第五条 隐私说明 +-------------------------------------------------------- + +本软件的设计原则是**数据不经过作者的服务器**: + + 1. 被控端与手表/控制端之间的所有数据(画面、文件、剪贴板、 + 语音等)通过你自己部署的中转服务器或局域网直连传输, + 作者**不会**收到、存储或分析任何内容; + + 2. 中转服务器只转发加密连接中的字节流,不解析内容; + 它记录的信息仅限于:设备名称、配对码、在线状态、 + 流量大小、连接时间——用于你在管理页上查看自己的设备; + + 3. 中转服务器部署在**你自己的设备**上,数据存在你自己的 + 硬盘里,作者无法访问; + + 4. 本软件不包含任何统计上报、行为采集、广告推送代码。 + +关于本软件需要使用的权限(网络访问、屏幕读取、文件读写等), +详见《安装前必读》。 + + +-------------------------------------------------------- +第六条 无担保声明 +-------------------------------------------------------- + +本软件按"现状"提供,作者不对以下事项作出任何明示或默示的 +担保,包括但不限于: + + 1. 软件无错误、不中断、能满足你的特定用途; + 2. 与你的操作系统、杀毒软件、网络环境完全兼容; + 3. 因网络运营商限制、Cloudflare 服务调整、系统更新等 + 外部原因导致的服务不可用。 + +**使用本软件的风险由你自行承担。** + + +-------------------------------------------------------- +第七条 责任限制 +-------------------------------------------------------- + +在适用法律允许的最大范围内,作者不对因使用或无法使用本软件 +而产生的任何直接、间接、偶然、特殊或后果性损失负责,包括 +但不限于数据丢失、业务中断、设备损坏、利润损失。 + +若适用法律不允许完全排除责任,作者承担的责任总额不超过 +你为获得本软件所支付的费用(本软件免费,故为零)。 + + +-------------------------------------------------------- +第八条 协议终止 +-------------------------------------------------------- + +出现下列情形之一,本协议自动终止: + + 1. 你违反本协议任何条款; + 2. 你将本软件用于第三条约定的禁止用途。 + +协议终止后,你应当立即停止使用并卸载本软件。第二条授予的 +分发许可同时失效。 + + +-------------------------------------------------------- +第九条 其他 +-------------------------------------------------------- + + 1. 本协议适用中华人民共和国法律; + 2. 因本协议产生的争议,双方应友好协商解决;协商不成的, + 提交作者所在地有管辖权的人民法院; + 3. 作者保留在后续版本中调整本协议的权利,调整后的协议 + 随新版本一同发布;继续使用新版本即视为接受新协议; + 4. 本协议部分条款无效,不影响其余条款的效力。 + + +-------------------------------------------------------- + 作者:小麦子 + 软件:ykxmz 1.7.0 + + 如果你不同意本协议的任何一条,请不要安装或使用本软件。 +-------------------------------------------------------- + +====================================================================== + +======================================================== + ykxmz 安装前必读(前信息文件) +======================================================== + +软件名称:ykxmz +版本:1.7.0 +作者:小麦子 + +请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 +提醒,尤其是第三节和第五节。 + + +-------------------------------------------------------- +一、这是什么 +-------------------------------------------------------- + +ykxmz 是一套自己搭建的远程管理工具,由三部分组成: + + 1-agent(被控端) 装在你**要管理**的电脑上(Windows) + 2-server(服务器端)装在一台能上外网的 Linux 小机器上 + (随身 WiFi、树莓派、VPS 都行) + 3-watch(控制端) 装在你的安卓手表上 + +装好之后,你在手表上就能看到并操作那台电脑:屏幕、文件、 +音量、电源、剪贴板……共两百多个功能。 + +**关键点:所有数据都走你自己搭的那台服务器, +作者碰不到你的任何内容。** 详见《许可协议》第五条。 + + +-------------------------------------------------------- +二、装机前对照检查 +-------------------------------------------------------- + +请先确认: + + [ ] 这台电脑是**你自己的**,或者设备主人明确同意你远程管理 + [ ] 电脑是 Windows 10 / 11(64 位) + [ ] 手表是安卓 9.0(API 28)或更高版本 + [ ] 服务器端那台机器是 Linux(Debian 12 实测通过), + 能上外网,内存 512MB 够用 + [ ] 你有一个 Cloudflare 账号和一个域名(跨网络才需要) + +**不需要**装 Python、不需要 root、不需要 ADB。 + + +-------------------------------------------------------- +三、【重要】三条必须知道的事 +-------------------------------------------------------- + +① 被控端默认是"静默"运行的 + + 装完之后**没有窗口、没有托盘图标、没有终端**, + 任务管理器里能看到一个叫 ykxmz.exe 的进程, + 开机自启项里也显示 ykxmz。 + + 这么做是为了不打扰用电脑的人,**不是**为了藏起来。 + 如果你要在这台电脑上装给别人用,你有义务告诉对方: + + 怎么查看(任务管理器 -> 详细信息 -> ykxmz.exe) + 怎么关闭(任务管理器 -> 结束任务) + 怎么卸载(ykxmz.exe --uninstall) + +② 配对码是唯一的口令,请设一个别人猜不到的 + + 配对码是 6 位数字,**它就是你电脑的钥匙**。 + 不要用 123456、000000、生日、手机号后六位这类。 + + 一个配对码同时只能绑定一台电脑。如果别人先用了你想要的码, + 后装的这台会被拒绝,并在屏幕上弹出提示: + 「该配对码已被使用,请换一个配对码」 + 换个没人用的码就行。 + +③ 连接时屏幕上有提示 + + 只要手表或控制端连上来,被控端会在屏幕右下角弹一个提示。 + 这是**故意保留**的功能,请不要关掉它—— + 它保证了设备使用者知道"现在有人在看这台电脑"。 + + +-------------------------------------------------------- +四、安装顺序 +-------------------------------------------------------- + +按这个顺序来,别跳: + + 第 1 步 架服务器端(只做一次) + 把 2-server 整个目录拷到随身 WiFi 上,执行: + bash install.sh + 它会装好中转服务 + Cloudflare 隧道,并打印一个 + 公网地址。 + + 第 2 步 装被控端(每台要管理的电脑做一次) + 双击 ykxmz.exe -> 输 6 位配对码 -> 勾上开机自启和 + 放行防火墙 -> 确定。会弹一次管理员确认。 + + 第 3 步 装控制端 + 把 3-watch 里的 APK 装到手表上。装完打开就能用, + 地址已经内置好了。 + + +-------------------------------------------------------- +五、会改你系统的哪些东西 +-------------------------------------------------------- + +安装和使用过程中,被控端会做这些改动,**请你知情**: + + 1. 写一条开机自启项 + 位置:HKEY_CURRENT_USER\...\CurrentVersion\Run + 名称:ykxmz + 内容:"...\ykxmz.exe" --silent + + 2. 添加 Windows 防火墙放行规则 + 规则名:LAN Remote TCP / LAN Remote UDP / LAN Remote App TCP + / LAN Remote App UDP + 作用:允许 8765(TCP)和 8766(UDP)端口通信 + + 3. 在程序目录下建一个 data 子目录 + 里面存:配对码、已配对设备、设置 + 卸载时会一并删除 + + 4. 说明:部分功能(关机、改注册表、隐藏任务栏等) + 需要管理员权限,会弹 UAC 确认,**不会**偷偷提权。 + +**以上全部可撤销**:卸载时自启项和防火墙规则会一起清掉。 + + +-------------------------------------------------------- +六、杀毒软件可能报警 +-------------------------------------------------------- + +本软件没有购买代码签名证书(那是要花钱的), +加上它确实具备"远程控制 + 开机自启"这类能力, +所以**部分杀毒软件可能会报可疑**。 + +这是误报。你可以: + 1. 在杀毒软件里把 ykxmz 整个目录加入信任/排除项; + 2. 或者先看完《许可协议》,确认用途正当后再决定是否放行。 + +**作者不会、也没有办法让本软件绕过杀毒软件。** +如果你需要"绝对不被杀软发现",那本软件不适合你。 + + +-------------------------------------------------------- +七、接下来看什么 +-------------------------------------------------------- + +装完之后请看《安装后须知》(AFTER-INSTALL.txt), +里面有:怎么确认它在正常运行、怎么换配对码、 +怎么彻底卸载、连不上怎么排查。 + + +-------------------------------------------------------- + 作者:小麦子 + 软件:ykxmz 1.7.0 + 继续安装,即表示你已阅读并同意《许可协议》。 +-------------------------------------------------------- + +====================================================================== + +======================================================== + ykxmz 安装后须知(后信息文件) +======================================================== + +软件名称:ykxmz +版本:1.7.0 +作者:小麦子 + +装好之后看这一页:怎么确认它在跑、怎么改设置、 +怎么彻底卸载、出问题怎么查。 + + +-------------------------------------------------------- +一、确认它真的在运行 +-------------------------------------------------------- + +被控端装好后**看不到任何窗口**,这是正常的。想确认它在跑: + + 方法一(最直观) + Ctrl+Shift+Esc 打开任务管理器 + -> "详细信息"标签 -> 找 ykxmz.exe + 有,就是在跑 + + 方法二(看端口) + 命令行执行: netstat -ano | findstr 8765 + 有 LISTENING,就是正常 + + 方法三(问程序自己) + 命令行执行: ykxmz.exe --status + 会打印:配对码、端口、中转连接状态、已配对设备数 + + 方法四(从手表看) + 手表上点"自动连接",能连上就说明在跑 + + +-------------------------------------------------------- +二、常用操作 +-------------------------------------------------------- + +重新打开设置窗口 + ykxmz.exe --setup + +换一个配对码 + 在设置窗口里改,或者: + ykxmz.exe --new-code (随机生成一个) + 换完之后,手表那边要用新码重连 + +看看当前状态 + ykxmz.exe --status + +临时允许新设备配对(默认关着) + ykxmz.exe --add-device 10 (开放 10 分钟) + 过了时间自动关回去 + +关掉/打开开机自启 + ykxmz.exe --autostart off + ykxmz.exe --autostart on + +网络自检(连不上时先跑这个) + 双击 wlzj.bat + 或 ykxmz.exe --netcheck 90 + +修复防火墙(要管理员) + 右键 xffhq.bat -> 以管理员身份运行 + 或 ykxmz.exe --fix-firewall + + +-------------------------------------------------------- +三、彻底卸载 +-------------------------------------------------------- + +一条命令清干净: + + ykxmz.exe --uninstall + +它会依次: + 1. 停掉正在跑的进程 + 2. 删掉开机自启项 + 3. 删掉数据目录(配对码、已配对手表全没了) + 4. 安排删除程序目录本身(退出后几秒自动删) + +**想保留数据**(比如以后还要用,只是换个位置装): + 先手动把 data 目录复制出来,再卸载。 + +**手动卸载也可以**(如果程序已经删了): + 1. 任务管理器结束 ykxmz.exe + 2. 删除注册表项 + HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run + 下的 ykxmz + 3. 删除整个程序目录 + 4. (可选)删除防火墙规则:netsh advfirewall firewall + delete rule name="LAN Remote TCP" + + +-------------------------------------------------------- +四、连不上怎么查 +-------------------------------------------------------- + +按顺序排查,绝大多数问题在前三步就解决了: + + 第 1 步 被控端在跑吗? + 任务管理器里有没有 ykxmz.exe + 没有 -> ykxmz.exe --setup + + 第 2 步 防火墙放行了吗? + 右键 xffhq.bat -> 以管理员身份运行 + + 第 3 步 网络通不通? + 双击 wlzj.bat 做自检 + 它会生成一份《网络自检结果.txt》 + + 第 4 步 跨网络的话,中转通不通? + 浏览器打开 http://<随身WiFi的IP>:8080/url + 看它有没有返回公网地址 + + 第 5 步 手表上点"连不上?点这里诊断" + + 常见现象对照: + + "连接失败,配对码错误" + -> 手表上的码和电脑上的不一样,对一下 + + "该配对码已被使用,请换一个配对码" + -> 这个码被另一台电脑占了。换一个, + 或者去中转管理页把占用它的那台设备删掉 + + "设备不在线" + -> 电脑上的程序没跑,或者网络断了 + + "连不上中转服务器" + -> 随身 WiFi 那台机器上的中转没起来, + 或者隧道挂了。SSH 上去执行 lanremote-status 看看 + + +-------------------------------------------------------- +五、服务器端(随身 WiFi)日常维护 +-------------------------------------------------------- + + 三个服务都是开机自启,正常情况下不用管: + + lanremote-relay 中转服务 + lanremote-tunnel Cloudflare 隧道 + lanremote-watchdog.timer 每 30 秒检查一次隧道连接 + + 想一眼看清状态: + lanremote-status + + 常用命令: + lanremote-url 打印当前公网地址 + lanremote-key 打印管理页钥匙 + systemctl restart lanremote-tunnel 手动换个地址 + journalctl -u lanremote-relay -f 看实时日志 + + 管理页: + 浏览器打开 <公网地址>,用管理密码登录(默认 120830) + 能看:设备列表、在线状态、配对码、流量、会话数 + 能做:删除设备(连配对码一起清)、踢会话、查看当前地址 + + 忘了公网地址? + 打开 http://<随身WiFi的IP>:8080/url —— 直接返回当前地址, + 不用登录。 + + +-------------------------------------------------------- +六、已知限制 +-------------------------------------------------------- + + 1. 一条隧道同时只服务一台手表 + 新连上来的会顶掉旧的,旧的那台会提示"已被另一台手表接管"。 + + 2. 跨网络时的延迟 + 经 Cloudflare 隧道走一圈,一来一回约 0.6 秒; + 局域网直连只有几十毫秒。**程序和手表会自动判断**: + 发现两边在同一个网络就切直连,断了自动切回中转。 + + 3. 网络环境的影响 + 如果随身 WiFi 用的 4G 卡对 UDP 有限制,隧道可能偶尔抖。 + 程序里有看门狗会自动重启隧道,通常 1 分钟内恢复。 + + 4. 杀毒软件误报 + 见《安装前必读》第六节。作者不提供绕过杀软的功能。 + + +-------------------------------------------------------- +七、出问题时怎么反馈 +-------------------------------------------------------- + +请带上这几样,作者才能定位: + + 1. ykxmz.exe --status 的输出 + 2. wlzj.bat 生成的《网络自检结果.txt》 + 3. 手表上的诊断结果(手表首页"连不上?点这里诊断") + 4. 问题什么时候开始的、之前做过什么 + + +-------------------------------------------------------- +八、版本记录 +-------------------------------------------------------- + + 1.7.0 + * 跨网络支持:Cloudflare 具名隧道,固定域名 + * 中转服务器:单设备单配对码、管理页可删设备 + * 手表端:自动判断局域网/中转并无缝切换 + * 被控端:所有开关和画面功能支持回退/撤销 + * 配对码被占用时弹通知提示 + * 新增 --uninstall 一键卸载 + * 针对 512MB 内存的小机器做了优化 + + +-------------------------------------------------------- + 作者:小麦子 + 软件:ykxmz 1.7.0 + 许可条款见《许可协议》(LICENSE.txt) +-------------------------------------------------------- diff --git a/installer/ykxmz.iss b/installer/ykxmz.iss new file mode 100644 index 0000000..e99f2b7 --- /dev/null +++ b/installer/ykxmz.iss @@ -0,0 +1,255 @@ +; ============================================================================ +; ykxmz 教室电脑手表遥控 · 被控端安装包 +; +; 用 Inno Setup 6 编译: +; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\ykxmz.iss +; +; ── 设计说明 ── +; +; 尽量用 Inno 自带的机制,不手搓: +; * 许可协议页 -> LicenseFile(三份文档合并成一份,点"我接受"才能继续) +; * 开机自启 -> [Tasks](标准勾选框) +; * 装完启动 -> [Run] postinstall(标准勾选框) +; * 安装目录 -> DisableDirPage=no(让用户自己选,默认 Program Files) +; * 卸载 -> [UninstallRun] 调被控端自己的 --uninstall +; 只有"填 6 位配对码"这一页 Inno 没有现成的,用标准的 +; CreateInputQueryPage —— 那是 Inno 的官方 API,不算自己造轮子。 +; +; ── 用户确定的设置 ── +; 安装位置 默认 C:\Program Files\ykxmz,安装时可改 +; 数据目录 能写 exe 旁就写,不能就转 ProgramData(程序内部处理) +; 三份文档 放安装包里,统一接受才能下一步 +; 配对码 安装向导里填 6 位 +; 快捷方式 不建开始菜单、不建桌面 +; 开机自启 默认勾上 +; 装完启动 立即静默启动 +; 卸载 一并删掉,什么都不留 +; ============================================================================ + +#define MyAppName "ykxmz 教室电脑手表遥控" +#define MyAppVersion "1.7.0" +#define MyAppPublisher "小麦子" +#define MyAppExeName "ykxmz.exe" + +[Setup] +AppId={{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +VersionInfoVersion={#MyAppVersion} + +; 默认装到 Program Files\ykxmz,但安装向导里有一页让用户自己改 +DefaultDirName={autopf}\ykxmz +DisableDirPage=no + +; 三份文档合并成一份许可页:看完点「我接受」才能继续 +LicenseFile=license-combined.txt + +; 不建开始菜单组(用户明确不要) +DisableProgramGroupPage=yes +AllowNoIcons=yes + +; 装到 Program Files 和改防火墙都要管理员 +PrivilegesRequired=admin +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +OutputDir=..\dist +OutputBaseFilename=ykxmz-setup-{#MyAppVersion} +SetupIconFile=..\windows\app.ico +UninstallDisplayIcon={app}\{#MyAppExeName} +UninstallDisplayName={#MyAppName} + +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +ShowLanguageDialog=no +DisableWelcomePage=no + +[Languages] +Name: "chinese"; MessagesFile: "compiler:Default.isl" + +[Tasks] +; 用户要的:默认勾上开机自启(取消勾选也允许) +Name: "autostart"; Description: "开机自动启动(推荐给教室电脑)"; \ + GroupDescription: "启动选项:"; Flags: checkedonce + +[Files] +; 被控端整个目录(ykxmz.exe + _internal) +Source: "..\dist_exe\ykxmz\*"; DestDir: "{app}"; \ + Flags: recursesubdirs createallsubdirs ignoreversion + +; 三份文档也放一份到安装目录,装完还能翻 +Source: "..\docs\LICENSE.txt"; DestDir: "{app}\docs"; Flags: ignoreversion +Source: "..\docs\BEFORE-INSTALL.txt"; DestDir: "{app}\docs"; Flags: ignoreversion +Source: "..\docs\AFTER-INSTALL.txt"; DestDir: "{app}\docs"; Flags: ignoreversion + +[Dirs] +; ⚠ 这一节很关键 —— 不配的话装完一运行就报 WinError 5 拒绝访问。 +; +; 被控端的配置存在 %ProgramData%\ykxmz\data 下(exe 旁边写不进去时的回退)。 +; 安装包是管理员跑的,它建的目录默认只有管理员能改; +; 之后普通用户(老师的账号)跑被控端时: +; 能新建文件,但覆盖不了管理员建的那份 settings.json +; 结果就是"装完打开报权限错误"。 +; +; Permissions: users-modify 把所有用户的写权限打开,才是真的能用。 +; 注意末尾不带反斜杠,否则 Inno 会给它单独建一层同名子目录。 +Name: "{commonappdata}\ykxmz"; Permissions: users-modify +Name: "{commonappdata}\ykxmz\data"; Permissions: users-modify + +[InstallDelete] +; 早期版本把配置写在程序目录下的 data\ 里。 +; 现在数据统一放 ProgramData(见 config.py 的 data_dir), +; 这里把旧的那份清掉 —— 留着的话,两份配置并存, +; 以后排查问题会被误导("我明明改了配对码怎么没变")。 +Type: filesandordirs; Name: "{app}\data" + +[Run] +; 装完立即静默启动(标准 postinstall 勾选框,默认勾上) +Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \ + Description: "立即启动(后台运行,不显示窗口)"; \ + Flags: nowait postinstall skipifsilent + +; 让用户看一眼装完须知 +Filename: "{app}\docs\AFTER-INSTALL.txt"; \ + Description: "打开「安装后须知」"; \ + Flags: postinstall shellexec unchecked skipifsilent + +[UninstallRun] +; 卸载前先让被控端自己收尾:停进程、删开机自启、清数据目录。 +; 这样"卸载后什么都不留"才是真的干净。 +Filename: "{app}\{#MyAppExeName}"; Parameters: "--uninstall"; \ + Flags: runhidden waituntilterminated; RunOnceId: "StopAgent" + +[UninstallDelete] +; 被控端会自己删数据目录,这里兜底再清一遍安装目录里的残留 +Type: filesandordirs; Name: "{app}\data" +Type: filesandordirs; Name: "{app}\docs" +Type: filesandordirs; Name: "{app}\_internal" + +[Code] +var + CodePage: TInputQueryWizardPage; + ForcedCode: String; + +{ 把字符串里的非数字滤掉。 } +function OnlyDigits(const raw: String): String; +var + i: Integer; + ch: Char; +begin + Result := ''; + for i := 1 to Length(raw) do + begin + ch := raw[i]; + if (ch >= '0') and (ch <= '9') then + Result := Result + ch; + end; +end; + +{ 配对码那一页。Inno 没有现成的输入页,用官方的 CreateInputQueryPage。 } +procedure InitializeWizard(); +begin + { 静默部署可以带 /CODE=123456 直接把码传进来(电教批量装用这个)。 + 注意:Inno 的 [Code] 段连**注释里的花括号**都会当常量去解析, + 所以这里不能直接写 param 那种写法,否则编译器报语法错误。 } + ForcedCode := OnlyDigits(ExpandConstant('{param:CODE|}')); + + CodePage := CreateInputQueryPage(wpSelectTasks, + '配对码', + '给这台电脑定一个 6 位配对码', + '手表上要输这一串数字才能连上这台电脑。自己定个好记的,' + + '比如 789113。装完之后也可以在被控端窗口里改。'); + CodePage.Add('6 位配对码:', False); + if ForcedCode <> '' then + CodePage.Values[0] := ForcedCode; +end; + +{ 校验:必须是 6 位数字。 + 注意 —— **静默模式下这一页不会显示**,值是空的, + 这时候绝不能拦(否则 /VERYSILENT 安装必然失败,我第一版就栽在这)。 } +function NextButtonClick(CurPageID: Integer): Boolean; +var + raw: String; + digits: String; +begin + Result := True; + if CurPageID <> CodePage.ID then + Exit; + if WizardSilent then + Exit; + + raw := CodePage.Values[0]; + digits := OnlyDigits(raw); + if (Length(digits) <> 6) and (raw <> '') then + begin + MsgBox('配对码要 6 位数字。' + #13#10 + #13#10 + + '你现在填的是:' + raw + #13#10 + + '(过滤掉非数字后剩 ' + IntToStr(Length(digits)) + ' 位)' + #13#10 + #13#10 + + '重新填一下,或者直接点「取消」退出安装。', + mbError, MB_OK); + Result := False; + Exit; + end; + { 规范化,后面 [Run] 阶段用得到 } + CodePage.Values[0] := digits; +end; + +{ 最终用的配对码:命令行 /CODE 优先,其次是向导里填的。 + 两个都没有就返回空串 —— 那就跳过 --code,让被控端自己生成一个。 } +function EffectiveCode(): String; +begin + Result := ForcedCode; + if Result = '' then + Result := OnlyDigits(CodePage.Values[0]); + if Length(Result) <> 6 then + Result := ''; +end; + +{ 把向导里收集的东西交给被控端。 + 注意这里**不能带 --silent** —— 那是常驻守护进程,永远不返回, + 用 ewWaitUntilTerminated 等它会把安装程序挂死(第一版就栽在这)。 + 写配置和启动服务分成两次调用: + --code / --autostart 一次性命令,秒退 + --silent 常驻,交给 [Run] 段用 nowait 启动 } +procedure CurStepChanged(CurStep: TSetupStep); +var + ResultCode: Integer; + Params: String; + Code: String; +begin + if CurStep <> ssPostInstall then + Exit; + + Code := EffectiveCode(); + if Code <> '' then + begin + Params := '--code ' + Code; + if not Exec(ExpandConstant('{app}\{#MyAppExeName}'), Params, + ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, + ResultCode) then + begin + if not WizardSilent then + MsgBox('配对码写入失败(错误码 ' + IntToStr(ResultCode) + ')。' + #13#10 + + '装完之后双击程序图标,重新设一次也行。', + mbInformation, MB_OK); + end; + end; + + { 开机自启:单独调一次,也是一次性命令 } + if WizardIsTaskSelected('autostart') then + Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--autostart on', + ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + +{ 卸载前先停掉正在跑的进程,不然文件删不干净。 } +function InitializeUninstall(): Boolean; +var + ResultCode: Integer; +begin + Result := True; + Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall', + ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; diff --git a/relay/favicon.ico b/relay/favicon.ico new file mode 100644 index 0000000..dea628a Binary files /dev/null and b/relay/favicon.ico differ diff --git a/relay/relay.py b/relay/relay.py index f2fadd1..21767e5 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -58,7 +58,7 @@ from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -VERSION = "1.6.2" +VERSION = "1.7.0" # ---------------- 参数(都能用环境变量覆盖)---------------- @@ -778,6 +778,8 @@ def _login_page(message: str = "") -> bytes: notice = ('

' + message + '

') if message else '' html = """ + + 中转服务器 · 登录