1.7.0:常规发布

- 版本号 1.7.0 -> 1.7.0
- 重建三端并打包
- 关键测试全过
This commit is contained in:
xmz7891
2026-10-06 22:49:14 +08:00
parent 0a4ec4f0c4
commit e7fcf6de75
32 changed files with 2476 additions and 61 deletions
+10
View File
@@ -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` 命令处理完不返回
- 中转管理页加上网站图标
+112
View File
@@ -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())
+105
View File
@@ -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. 管理页 <head> 里加 <link rel="icon" ...>
为什么不做成 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 = '''<meta name=viewport content="width=device-width,initial-scale=1">'''
NEW_HEAD = ('''<meta name=viewport content="width=device-width,initial-scale=1">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/favicon.ico">''')
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())
+132
View File
@@ -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())
+66
View File
@@ -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))%"
+109
View File
@@ -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())
+124
View File
@@ -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())
+207
View File
@@ -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())
+27 -29
View File
@@ -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(
+52
View File
@@ -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())
+4 -2
View File
@@ -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)
+14 -5
View File
@@ -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
+109
View File
@@ -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())
+13 -2
View File
@@ -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 = [
+84
View File
@@ -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 " $_" }
}
}
+138
View File
@@ -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())
+1 -1
View File
@@ -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()
+4 -4
View File
@@ -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])
+1 -1
View File
@@ -2,7 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.lanremote.watch"
android:versionCode="5"
android:versionName="1.6.2">
android:versionName="1.7.0">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+2 -2
View File
@@ -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 失败" }
+3 -3
View File
@@ -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)
--------------------------------------------------------
+2 -2
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
版本:1.6.2
版本:1.7.0
作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -152,6 +152,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
软件:ykxmz 1.6.2
软件:ykxmz 1.7.0
继续安装,即表示你已阅读并同意《许可协议》。
--------------------------------------------------------
+2 -2
View File
@@ -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
如果你不同意本协议的任何一条,请不要安装或使用本软件。
--------------------------------------------------------
+113
View File
@@ -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())
+533
View File
@@ -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)
--------------------------------------------------------
+255
View File
@@ -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;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+26 -1
View File
@@ -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 = ('<p class="bad">' + message + '</p>') if message else ''
html = """<!doctype html><html lang=zh><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/favicon.ico">
<title>中转服务器 · 登录</title>
<style>
body{font:14px/1.6 -apple-system,'Microsoft YaHei',sans-serif;
@@ -994,6 +996,29 @@ class Handler(BaseHTTPRequestHandler):
})
return
# ---- 网站图标 ----
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
# ---- 成品下载(挂在管理页上给人下 APK / 整包)----
if path == "/v1/downloads":
self._json(200, {"ok": True, "files": _list_downloads()})
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""局域网遥控 · Windows 被控端。"""
__version__ = "1.6.2"
__version__ = "1.7.0"
APP_NAME = "局域网遥控 被控端"
+159 -4
View File
@@ -105,10 +105,57 @@ class Store:
@staticmethod
def _write(path: Path, data) -> None:
"""写 JSON。优先"临时文件 + 原子替换",失败就降级。
为什么要降级:
装到 Program Files / ProgramData 时,配置是**管理员**建的那一份,
属主是管理员。普通用户跑的时候:
能新建文件,但不能覆盖/改名别人建的文件。
于是 os.replace 报 WinError 5。
这时候退成"直接写目标文件" —— 牺牲原子性,换能存下配置。
"""
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)
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
def get(self, key: str, default=None):
with self._lock:
@@ -217,5 +264,113 @@ def app_dir() -> Path:
return Path(__file__).resolve().parent.parent
# 数据目录只算一次,之后缓存 —— 每个命令都去试写一次太浪费
_DATA_DIR_CACHE: Path | None = None
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
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:
return app_dir() / "data"
"""配置 / 令牌 / 接收文件的存放目录。
优先级:
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
# ── 安装版(装在 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
# 兜底: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
+66
View File
@@ -27,6 +27,8 @@
--status 打印状态后退出
--port 9000 指定端口
--new-code 换一组配对码
--code 123456 把配对码设成指定值(安装包用)
--name "三年二班" 设置电脑在手表上显示的名字
--reset-devices 清空已配对设备
"""
from __future__ import annotations
@@ -45,6 +47,12 @@ def parse_args(argv=None):
prog="lanremote-agent", description=f"{APP_NAME} v{__version__}")
parser.add_argument("--port", type=int, help="监听端口(默认 8765)")
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 的计算机名)")
parser.add_argument("--reset-devices", action="store_true", help="清空已配对设备")
parser.add_argument("--silent", action="store_true",
help="全静默运行:没有任何窗口,也没有终端")
@@ -602,6 +610,42 @@ def main(argv=None) -> int:
args = parse_args(argv)
# --code / --name 要**最先**处理 —— 它们会改配置,
# 而 --status / --autostart 这些是"看一眼就退出"的命令。
# 安装包的调用链是:
# ykxmz.exe --code 789113 --name 三年二班 --autostart on --silent
# 如果先把 --autostart 处理掉就 return 了,配对码根本写不进去。
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:
return cli_autostart(args.autostart)
if args.status:
@@ -629,6 +673,28 @@ def main(argv=None) -> int:
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()
+2 -2
View File
@@ -27,12 +27,12 @@ VSVersionInfo(
[
StringStruct("CompanyName", "ykxmz"),
StringStruct("FileDescription", "ykxmz"),
StringStruct("FileVersion", "1.6.2.0"),
StringStruct("FileVersion", "1.7.0.0"),
StringStruct("InternalName", "ykxmz"),
StringStruct("LegalCopyright", "ykxmz"),
StringStruct("OriginalFilename", "ykxmz.exe"),
StringStruct("ProductName", "ykxmz"),
StringStruct("ProductVersion", "1.6.2.0"),
StringStruct("ProductVersion", "1.7.0.0"),
],
)
]