3 Commits
Author SHA1 Message Date
xmz7891 b86eeb349b 1.7.0:常规发布
- 版本号 1.7.0 -> 1.7.0
- 重建三端并打包
- 关键测试全过
2026-10-06 22:49:14 +08:00
xmz7891 0a4ec4f0c4 1.6.2:常规发布
- 版本号 1.6.2 -> 1.6.2
- 重建三端并打包
- 关键测试全过
2026-10-06 20:47:30 +08:00
xmz7891 878b7311e9 1.6.1:常规发布
- 版本号 1.6.1 -> 1.6.1
- 重建三端并打包
- 关键测试全过
2026-10-06 20:38:33 +08:00
103 changed files with 3495 additions and 41 deletions
+5
View File
@@ -59,3 +59,8 @@ desktop.ini
# ---- 测试收发的临时数据 ----
/_tests/received/
# 令牌(绝对不要进仓库)
/_build/.gitee_token
*.token
.env
+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` 命令处理完不返回
- 中转管理页加上网站图标
View File
View File
+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())
View File
View File
+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())
+1 -1
View File
@@ -13,7 +13,7 @@ from pathlib import Path
import paramiko
HOST = "192.168.10.105"
HOST = "192.168.2.156"
USER = "root"
PASSWORD = "1313144"
+135
View File
@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
r"""诊断:发现端口 8766 到底有没有被 agent 绑上。
test_selfcheck 广播到 127.0.0.1:8766 收到 WSAECONNRESET(ICMP 端口不可达),
说明**没有进程在监听 8766**。但测试里 agent 明明起来了(8841 能连)。
查清楚是:
a) agent 绑 8766 失败(被占 / 权限)
b) agent 压根没去绑
c) 绑上了但没回包
"""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
PORT = 8851
DISCOVERY = 8766
def port_holder(port: int, udp: bool) -> list[str]:
out: list[str] = []
if udp:
cmd = ("Get-NetUDPEndpoint -LocalPort %d -ErrorAction SilentlyContinue | "
"ForEach-Object { \"$($_.LocalAddress):$($_.LocalPort) PID $($_.OwningProcess)\" }"
% port)
else:
cmd = ("Get-NetTCPConnection -LocalPort %d -ErrorAction SilentlyContinue | "
"ForEach-Object { \"$($_.LocalAddress):$($_.LocalPort) PID $($_.OwningProcess)\" }"
% port)
proc = subprocess.run(["powershell", "-NoProfile", "-Command", cmd],
capture_output=True, text=True, timeout=30)
for line in (proc.stdout or "").splitlines():
if line.strip():
out.append(line.strip())
return out
def main() -> int:
# 清干净(注意别把自己杀了 —— $PID 在 PowerShell 里是 PowerShell 的,
# 所以这里按命令行特征挑,不用 PID 比较)
subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | "
"Where-Object { $_.CommandLine -match 'run_agent' -or "
"$_.CommandLine -match 'test_' } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
"-ErrorAction SilentlyContinue }"],
capture_output=True, text=True, timeout=60)
time.sleep(2)
print(" 起之前,8766 有没有人占:")
holders = port_holder(DISCOVERY, udp=True)
print(f" {holders if holders else '空闲'}")
data_dir = ROOT / "windows" / "data"
if data_dir.exists():
shutil.rmtree(data_dir, ignore_errors=True)
env = dict(os.environ)
env["PYTHONIOENCODING"] = "utf-8"
print()
print(f" 起 agent(TCP {PORT})…")
proc = subprocess.Popen(
[sys.executable, "run_agent.py", "--port", str(PORT),
"--new-code", "--add-device", "10"],
cwd=str(ROOT / "windows"), env=env,
stdout=open(ROOT / "_build" / "agent_diag.log", "wb"),
stderr=subprocess.STDOUT)
try:
# 等 TCP 就绪
ready = False
for _ in range(50):
try:
s = socket.create_connection(("127.0.0.1", PORT), timeout=1)
s.close()
ready = True
break
except OSError:
time.sleep(0.4)
print(f" TCP {PORT} 就绪: {ready}")
time.sleep(1.5)
print()
print(f" 起之后,8766 有没有人占:")
holders = port_holder(DISCOVERY, udp=True)
print(f" {holders if holders else '还是空闲 —— 说明 agent 没绑上!'}")
print()
print(" 直接探测 8766:")
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(3)
import json
request = json.dumps({"lanremote": "LANREMOTE_SELFCHECK",
"proto": 1}).encode("utf-8")
try:
sock.sendto(request, ("127.0.0.1", DISCOVERY))
data, addr = sock.recvfrom(2048)
print(f" ✓ 收到应答: {data[:160]!r}")
except ConnectionResetError as exc:
print(f" ✗ WSAECONNRESET —— 端口不可达,没人监听({exc})")
except socket.timeout:
print(" ✗ 超时 —— 有人监听但没回")
finally:
sock.close()
print()
print(" agent 日志尾部:")
log = ROOT / "_build" / "agent_diag.log"
if log.is_file():
text = log.read_text(encoding="utf-8", errors="replace")
for line in text.splitlines()[-14:]:
print(f" {line}")
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
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())
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
r"""修:中转连接尝试阻塞了局域网发现。
现象(agent 日志):
[20:31:17] 没配过中转地址,用内置默认值:192.168.10.105:8080;https://...
[20:31:25] http://192.168.10.105:8080 不通,试下一个 <- 等了 8 秒
[20:31:27] https://relay.xxmxxs.top 不通,试下一个 <- 又 2 秒
[20:31:27] 自动发现已开启(UDP 8766) <- 10 秒后才绑上
server.start() 原来的顺序:
1. 绑 TCP
2. enable_relay() <- 挨个试中转地址,每个都要等超时(这里 10 秒)
3. 起 accept 线程
4. 起 DiscoveryResponder
问题:**局域网发现被中转拖住了**。
电脑和手表在同一个 WiFi 时,本来应该"广播一下就找到",
结果因为中转连不上,要等 10 几秒手表才搜得到电脑。
中转地址越烂、候选越多,等得越久。
影响:
* 用户体验上表现为"刚开电脑时手表搜不到,过一会儿才行"
* 自动化测试也栽在这(广播时发现服务还没起)
改法:把局域网发现**提到 enable_relay() 之前**。
局域网是本机能力,不该依赖任何网络外部条件。
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
SERVER = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
r"\lanremote\server.py"))
OLD = ''' # 配了中转地址就同时启用跨网络通道(和局域网监听并存,不冲突)
try:
self.enable_relay()
except Exception as exc: # noqa: BLE001
self._log(f"启用中转失败:{exc}")
self._accept_thread = threading.Thread(target=self._accept_loop,
name="accept", daemon=True)
self._accept_thread.start()
self._clip_thread = threading.Thread(target=self._clip_loop,
name="clip", daemon=True)
self._clip_thread.start()
# 启动局域网自动发现,让手表不用输 IP
self._discovery = DiscoveryResponder(self.store, self.port)
# 手表点「连不上?」会广播请求,这里就把网络自检窗口弹出来
self._discovery.on_selfcheck = self._on_selfcheck_request
if self._discovery.start():
self._log(f"自动发现已开启(UDP {DISCOVERY_PORT})")
else:
self._log(f"自动发现未开启:{self._discovery.last_error}")'''
NEW = ''' # ── 先起局域网发现 ──
#
# 顺序很重要:enable_relay() 会挨个试中转地址,
# 每个候选都要等到超时才算"不通",地址越烂等得越久(实测 10 秒+)。
# 原来这一步排在发现服务前面,导致:
# 电脑刚开机的十几秒里,手表广播搜不到它。
# 局域网发现是纯本机能力,不该被"连不连得上外网"拖住,
# 所以挪到前面,开机即可被搜到。
self._discovery = DiscoveryResponder(self.store, self.port)
# 手表点「连不上?」会广播请求,这里就把网络自检结论算出来
self._discovery.on_selfcheck = self._on_selfcheck_request
if self._discovery.start():
self._log(f"自动发现已开启(UDP {DISCOVERY_PORT})")
else:
self._log(f"自动发现未开启:{self._discovery.last_error}")
# ── 再起收发线程 ──
self._accept_thread = threading.Thread(target=self._accept_loop,
name="accept", daemon=True)
self._accept_thread.start()
self._clip_thread = threading.Thread(target=self._clip_loop,
name="clip", daemon=True)
self._clip_thread.start()
# ── 最后才连中转(会阻塞,所以放最后)──
# 配了中转地址就同时启用跨网络通道(和局域网监听并存,不冲突)
try:
self.enable_relay()
except Exception as exc: # noqa: BLE001
self._log(f"启用中转失败:{exc}")'''
def main() -> int:
text = SERVER.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)
SERVER.write_text(text, encoding="utf-8", newline="\n")
print(" 已把局域网发现提到 enable_relay() 之前")
try:
ast.parse(text)
print(" server.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())
+84
View File
@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
r"""改 Gitee 仓库的简介 / 主页,并核对发行版。
用法(令牌只走命令行,不落盘):
python _build/gitee_meta.py <令牌>
"""
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.request
OWNER, REPO = "xmz7891", "ykxmz"
API = f"https://gitee.com/api/v5/repos/{OWNER}/{REPO}"
DESCRIPTION = (
"教室电脑手表遥控 · 老师用手表管教室电脑:放课件、投图、"
"屏蔽游戏网站、下课关机。支持局域网直连与跨网络中继"
)
HOMEPAGE = "https://relay.xxmxxs.top"
def call(url, payload=None, method="GET", timeout=60):
data = None
headers = {"User-Agent": "ykxmz-meta"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json;charset=UTF-8"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode("utf-8", "replace")
return resp.status, (json.loads(body) if body.strip() else {})
def main() -> int:
if len(sys.argv) < 2:
print(" 用法: python _build/gitee_meta.py <令牌>")
return 1
token = sys.argv[1]
print("===== 1. 改仓库简介 =====")
try:
status, reply = call(
API,
{"access_token": token, "name": REPO,
"description": DESCRIPTION, "homepage": HOMEPAGE},
method="PATCH")
if status in (200, 201):
print(f" ✓ 简介已更新")
print(f" {reply.get('description', '')}")
print(f" 主页: {reply.get('homepage', '')}")
else:
print(f" ✗ HTTP {status}: {reply}")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:300]
print(f" ✗ HTTP {exc.code}: {detail}")
return 1
print()
print("===== 2. 核对发行版 =====")
try:
_s, releases = call(f"{API}/releases?access_token={token}&per_page=5")
if not isinstance(releases, list):
print(f" 读不到发行版列表: {releases}")
for rel in releases[:3]:
print(f" {rel.get('tag_name')} {rel.get('name')}")
for asset in rel.get("assets", []):
size = asset.get("size", 0) / 1024 / 1024
print(f" {asset.get('browser_download_url', '')}"
.replace("https://gitee.com", ""))
print(f" 大小 {size:.2f} MB "
f"下载次数 {asset.get('download_count', 0)}")
except Exception as exc: # noqa: BLE001
print(f" 查发行版失败: {exc}")
print()
print(f" 仓库: https://gitee.com/{OWNER}/{REPO}")
print(f" 发行版: https://gitee.com/{OWNER}/{REPO}/releases")
return 0
if __name__ == "__main__":
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
r"""在 Gitee 上创建发行版(Release)并上传三个文件。
用法:
python _build/gitee_release.py <令牌> [版本号]
令牌只从命令行参数读,**不写进任何文件、不进仓库**。
Gitee 的两个接口:
建发行版 POST /api/v5/repos/{owner}/{repo}/releases
传附件 POST /api/v5/repos/{owner}/{repo}/releases/{id}/attach_files
SSH 密钥只能推代码,建发行版必须用这个令牌。
"""
from __future__ import annotations
import json
import mimetypes
import sys
import urllib.error
import urllib.request
import uuid
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
OWNER = "xmz7891"
REPO = "ykxmz"
API = f"https://gitee.com/api/v5/repos/{OWNER}/{REPO}"
def api(url: str, payload: dict | None = None, method: str = "GET",
timeout: int = 60):
data = None
headers = {"User-Agent": "ykxmz-release"}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json;charset=UTF-8"
request = urllib.request.Request(url, data=data, headers=headers,
method=method)
with urllib.request.urlopen(request, timeout=timeout) as response:
body = response.read().decode("utf-8", "replace")
return response.status, (json.loads(body) if body.strip() else {})
def upload_file(url: str, path: Path, token: str, timeout: int = 600):
"""multipart 上传单个文件。"""
boundary = "----ykxmz" + uuid.uuid4().hex
content_type = (mimetypes.guess_type(path.name)[0]
or "application/octet-stream")
head = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="access_token"\r\n\r\n'
f"{token}\r\n"
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; '
f'filename="{path.name}"\r\n'
f"Content-Type: {content_type}\r\n\r\n"
).encode("utf-8")
tail = f"\r\n--{boundary}--\r\n".encode("utf-8")
body = head + path.read_bytes() + tail
request = urllib.request.Request(
url, data=body, method="POST",
headers={
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(body)),
"User-Agent": "ykxmz-release",
})
with urllib.request.urlopen(request, timeout=timeout) as response:
text = response.read().decode("utf-8", "replace")
return response.status, (json.loads(text) if text.strip() else {})
def main() -> int:
if len(sys.argv) < 2:
print(" 用法: python _build/gitee_release.py <令牌> [版本号]")
return 1
token = sys.argv[1]
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-setup-{version}.exe",
ROOT / "dist" / f"ykxmz-server-{version}.zip",
ROOT / "dist" / f"ykxmz-watch-{version}.apk",
]
for path in files:
if not path.is_file():
print(f" [失败] 找不到 {path}")
return 1
print(f" 仓库: {OWNER}/{REPO}")
print(f" 标签: {tag}")
print()
# 发行版说明**只放更新内容**(用户要求)。
# 不放下载表格、不放用法说明 —— 那些在 README 里,
# 这里堆一堆反而看不清这版改了什么。
body = f"""## 更新内容
{changelog}
"""
print(" 建发行版…")
try:
status, reply = api(
f"{API}/releases",
{"access_token": token, "tag_name": tag, "name": f"ykxmz {version}",
"body": body, "target_commitish": "main", "prerelease": False},
method="POST")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:400]
print(f" [失败] HTTP {exc.code}: {detail}")
return 1
if status not in (200, 201):
print(f" [失败] HTTP {status}: {reply}")
return 1
release_id = reply.get("id")
print(f" ✓ 发行版已建,id={release_id}")
print(f" {reply.get('html_url', '')}")
# 如果标签已经存在(重复发布),走"找已有发行版再传附件"的路
if not release_id:
print(" [提示] 没拿到 id,尝试按标签查已有的发行版…")
try:
_s, existing = api(f"{API}/releases/tags/{tag}?access_token={token}")
release_id = existing.get("id")
print(f" 已有发行版 id={release_id}")
except Exception as exc: # noqa: BLE001
print(f" [失败] 查不到: {exc}")
return 1
# ---- 2. 传附件 ----
print()
print(" 上传文件:")
attach = f"{API}/releases/{release_id}/attach_files"
for path in files:
size_mb = path.stat().st_size / 1024 / 1024
try:
status, reply = upload_file(attach, path, token)
if status in (200, 201):
print(f" ✓ {path.name} ({size_mb:.2f} MB)")
else:
print(f" ✗ {path.name}: HTTP {status} {reply}")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace")[:200]
print(f" ✗ {path.name}: HTTP {exc.code} {detail}")
except Exception as exc: # noqa: BLE001
print(f" ✗ {path.name}: {exc}")
print()
print(f" 完成:https://gitee.com/{OWNER}/{REPO}/releases/tag/{tag}")
return 0
if __name__ == "__main__":
sys.exit(main())
+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.0"
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)
+194
View File
@@ -0,0 +1,194 @@
# -*- coding: utf-8 -*-
r"""一条命令完成发布。
用法:
python _build/release.py 1.7.0 # 升版本 + 构建 + 打包 + 推送 + 建发行版
python _build/release.py 1.7.0 --no-readme # 明说这版没改 README
它干这些事:
1. 升版本号(三端 + 文档 + 测试断言)
2. 重建 EXE 和 APK
3. 打三个成品
4. 跑一遍关键测试
5. git 提交 + 推送
6. 在 Gitee 建发行版,把三个文件传上去
7. 覆盖仓库简介(只在第一次需要,重复设成一样也无害)
令牌从 _build/.gitee_token 读(那个文件在 .gitignore 里,不会进仓库)。
第一次用:把令牌写进去就行。
"""
from __future__ import annotations
import os
import subprocess
import time
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
TOKEN_FILE = ROOT / "_build" / ".gitee_token"
GIT = r"C:\Program Files\Git\bin\git.exe"
GIT_SSH = r"C:\Program Files\Git\usr\bin\ssh.exe"
def sh(args, cwd=ROOT, check=False, timeout=1800, env=None):
merged = dict(os.environ)
if env:
merged.update(env)
proc = subprocess.run(args, cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=timeout,
env=merged)
if check and proc.returncode != 0:
raise RuntimeError(f"{args[0]} 失败:{proc.stdout}\n{proc.stderr}")
return proc
def step(n, title):
print()
print(f" {'=' * 56}")
print(f" {n}. {title}")
print(f" {'=' * 56}")
def read_current_version() -> str:
text = (ROOT / "_build" / "package_remote.py").read_text(encoding="utf-8")
for line in text.splitlines():
if line.startswith("VERSION = "):
return line.split('"')[1]
return "0.0.0"
def main() -> int:
if len(sys.argv) < 2:
print(" 用法: python _build/release.py <新版本号>")
print(f" 当前版本: {read_current_version()}")
return 1
new_version = sys.argv[1]
old_version = read_current_version()
note_readme = "--no-readme" in sys.argv
if not TOKEN_FILE.is_file():
print(f" [失败] 找不到令牌文件 {TOKEN_FILE}")
print(" 把 Gitee 令牌写进那个文件(一行,别加引号)")
return 1
token = TOKEN_FILE.read_text(encoding="utf-8").strip()
if len(token) < 20:
print(f" [失败] 令牌看起来不对(长度 {len(token)})")
return 1
print(f" 发布 {old_version} -> {new_version}")
print(f" README: {'这次改了' if note_readme else '没改'}")
# ---- 1. 升版本 ----
step(1, "升版本号")
proc = sh([sys.executable, "_build/bump_version_generic.py",
old_version, new_version])
print(proc.stdout[-1200:])
if proc.returncode != 0:
print(proc.stderr[-500:])
return 1
# ---- 2. 重建 ----
step(2, "重建 EXE 和 APK")
sh([GIT, "--version"]) # 探活,无关紧要
for args in (["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", "android\\build_apk.ps1"],
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", "windows\\build_exe.ps1"]):
proc = sh(args, timeout=2400)
tail = (proc.stdout or "").strip().splitlines()[-2:]
for line in tail:
print(f" {line}")
if proc.returncode != 0:
print(f" [失败] {args[-1]}")
print((proc.stderr or "")[-600:])
return 1
# ---- 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
# ---- 4. 关键测试 ----
step(4, "跑关键测试")
for test in ("audit_encoding.py", "test_net_tune.py", "test_chunked_body.py",
"test_admin_js.py"):
proc = sh([sys.executable, "-u", f"_tests/{test}"], timeout=900)
# 用退出码判断 —— 各测试的输出格式不一样
# (有的打"失败 0 项",有的打"结论:JS 合法",字符串匹配会误判)
bad = proc.returncode != 0
print(f" {test:28} {'✗' if bad else '✓'}")
if bad:
print((proc.stdout or "")[-800:])
print((proc.stderr or "")[-400:])
return 1
# 打包测试要起真的中转 + agent,前面刚跑过几个测试,
# 偶尔会撞上端口/隧道还没恢复 —— 重试一次就好
ok_pkg = False
for attempt in range(3):
proc = sh([sys.executable, "-u", "_tests/test_package_remote.py"],
timeout=1800)
out = proc.stdout or ""
last = [ln for ln in out.splitlines() if "通过" in ln or "失败" in ln]
ok_pkg = proc.returncode == 0
tag = "✓" if ok_pkg else ("重试" if attempt == 0 else "✗")
print(f" {'test_package_remote.py':28} {tag} "
f"{last[-1] if last else ''}")
if ok_pkg:
break
print(" (失败,清一下残留再试)")
sh(["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | "
"Where-Object { $_.CommandLine -match 'run_agent' -or "
"$_.CommandLine -match '_tests' -or "
"$_.CommandLine -match 'relay.py' } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
"-ErrorAction SilentlyContinue }"], timeout=120)
time.sleep(8)
if not ok_pkg:
return 1
# ---- 5. 提交推送 ----
step(5, "提交并推送")
env = {"GIT_SSH_COMMAND": f'"{GIT_SSH}"'}
sh([GIT, "add", "-A"])
msg = (f"{new_version}:{'README 与' if note_readme else ''}常规发布\n\n"
f"- 版本号 {old_version} -> {new_version}\n"
f"- 重建三端并打包\n"
f"- 关键测试全过\n")
sh([GIT, "commit", "-q", "-m", msg])
proc = sh([GIT, "push", "origin", "main"], env=env, timeout=1800)
print(f" {proc.stdout.strip()[-300:]}")
if proc.returncode != 0:
print(proc.stderr[-600:])
return 1
# ---- 6. 发行版 ----
step(6, "建 Gitee 发行版")
proc = sh([sys.executable, "_build/gitee_release.py", token, new_version],
timeout=2400)
print(proc.stdout[-1200:])
if proc.returncode != 0:
print(proc.stderr[-600:])
return 1
print()
print(f" ✓ {new_version} 发布完成")
print(f" https://gitee.com/xmz7891/ykxmz/releases/tag/v{new_version}")
return 0
if __name__ == "__main__":
sys.exit(main())
+112
View File
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
r"""让被控端记住上次连通的中转地址。
问题:
设备 IP 会变(DHCP),内置的默认地址写死了就过期。
start() 每次都要把候选地址挨个探一遍,每个等到超时 ——
实测 2 个候选要 10 秒,这 10 秒里局域网发现都还没起来。
改法:
1. 挑中的地址存进 store(relay_last_good)
2. 下次启动把它排到候选列表**最前面**,通常一次就通,启动瞬间完成
3. 地址失效时会自动往下试,试通了再存新的 —— 自愈
4. 内置默认地址更新成当前设备地址(192.168.2.156)
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
RELAY_PY = ROOT / "windows" / "lanremote" / "relay.py"
SERVER_PY = ROOT / "windows" / "lanremote" / "server.py"
# ---------- 1. 记住并优先使用上次通的地址 ----------
OLD_PICK = ''' self.candidates = candidates
# 先探一遍,挑第一个通的
chosen = self._pick_candidate(candidates)
self.url = chosen
if len(candidates) > 1:
self._log(f"中转地址候选 {len(candidates)} 个,选用 {chosen}")'''
NEW_PICK = ''' # 上次连通的地址排到最前面。
# 设备 IP 会变(DHCP),但大概率还是上次那个;
# 排前面能省掉"挨个探到超时"的那几秒(实测 2 个候选要 10 秒,
# 而这段时间局域网发现都还没起来,手表搜不到电脑)。
try:
last_good = str(self.store.get("relay_last_good") or "").strip()
except Exception: # noqa: BLE001
last_good = ""
if last_good:
normalized = last_good.rstrip("/")
if normalized in candidates:
candidates.remove(normalized)
candidates.insert(0, normalized)
self.candidates = candidates
# 先探一遍,挑第一个通的
chosen = self._pick_candidate(candidates)
self.url = chosen
if len(candidates) > 1:
self._log(f"中转地址候选 {len(candidates)} 个,选用 {chosen}")
# 记下来,下次优先试它
if chosen:
try:
if str(self.store.get("relay_last_good") or "") != chosen:
self.store.set("relay_last_good", chosen)
except Exception: # noqa: BLE001
pass'''
# ---------- 2. 更新内置默认地址 ----------
OLD_DEFAULT = 'DEFAULT_RELAY_URL = "192.168.10.105:8080;https://relay.xxmxxs.top"'
NEW_DEFAULT = ('# 内置的中转地址:局域网优先,连不上自动切公网隧道。\n'
'# 第一个是随身 WiFi 的固定地址(写死是因为它常在同一网段、'
'直连最快);\n'
'# 它变了也没关系 —— 连上过的地址会被记住并排到最前面。\n'
'DEFAULT_RELAY_URL = "192.168.2.156:8080;'
'https://relay.xxmxxs.top"')
def main() -> int:
ok = True
text = RELAY_PY.read_text(encoding="utf-8")
if "relay_last_good" in text:
print(" relay.py: 已经改过")
elif OLD_PICK in text:
text = text.replace(OLD_PICK, NEW_PICK, 1)
RELAY_PY.write_text(text, encoding="utf-8", newline="\n")
print(" relay.py: 加上「记住上次通的地址」")
else:
print(" [失败] relay.py 锚点没找到")
ok = False
stext = SERVER_PY.read_text(encoding="utf-8")
if "192.168.2.156" in stext:
print(" server.py: 默认地址已经是新的")
elif OLD_DEFAULT in stext:
stext = stext.replace(OLD_DEFAULT, NEW_DEFAULT, 1)
SERVER_PY.write_text(stext, encoding="utf-8", newline="\n")
print(" server.py: 默认地址改成 192.168.2.156")
else:
print(" [提示] server.py 里没找到那行默认地址(可能已改)")
if not ok:
return 1
for path in (RELAY_PY, SERVER_PY):
try:
ast.parse(path.read_text(encoding="utf-8"))
print(f" {path.name} 语法 OK")
except SyntaxError as exc:
print(f" {path.name} 语法错误: 行 {exc.lineno}: {exc.msg}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+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())
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
r"""设备回来了:把成品传到下载区,并部署最新 relay。
版本号从 _build/package_remote.py 里读,不用手改。
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
sys.path.insert(0, str(ROOT / "_build"))
from devssh import run, upload # noqa: E402
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 = [
(f"dist/ykxmz-agent-{VER}.zip", f"{REMOTE_DIR}/ykxmz-agent-{VER}.zip"),
(f"dist/ykxmz-server-{VER}.zip", f"{REMOTE_DIR}/ykxmz-server-{VER}.zip"),
(f"dist/ykxmz-watch-{VER}.apk", f"{REMOTE_DIR}/ykxmz-watch-{VER}.apk"),
]
def main() -> int:
print("===== 1. 清掉旧版本 =====")
_c, out, _e = run(f"ls {REMOTE_DIR}/ 2>/dev/null", timeout=30)
for line in (out or "").splitlines():
name = line.strip()
if name and VER not in name:
run(f"rm -f '{REMOTE_DIR}/{name}'", timeout=30)
print(f" 删除 {name}")
print()
print(f"===== 2. 上传 {VER} =====")
for local, remote in UPLOADS:
src = ROOT / local
if not src.is_file():
print(f" [失败] {local} 不存在")
return 1
upload(str(src), remote, 0o644)
print(f" ✓ {src.name} ({src.stat().st_size / 1024 / 1024:.2f} MB)")
print()
print("===== 3. 部署最新 relay.py / net-tune.sh =====")
for local, remote in (("relay/relay.py", "/root/lanremote-relay/relay.py"),
("relay/net-tune.sh",
"/root/lanremote-relay/net-tune.sh"),
("relay/watchdog.sh",
"/root/lanremote-relay/watchdog.sh")):
upload(str(ROOT / local), remote, 0o755)
print(f" ✓ {Path(local).name}")
run("systemctl restart lanremote-relay", timeout=90)
print(" 已重启中转服务")
print()
print("===== 4. 确认 =====")
_c, out, _e = run(
"sleep 4; echo ' 版本: '$(curl -s --max-time 8 "
"http://127.0.0.1:8080/v1/health | head -c 100); "
f"echo; ls -la {REMOTE_DIR}/ | tail -5", timeout=90)
print(out)
return 0
if __name__ == "__main__":
sys.exit(main())
+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 " $_" }
}
}
+125
View File
@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
r"""验证「记住上次通的地址」是否让启动变快。
第一次启动:候选里没有记忆,要挨个探(默认地址现在是 192.168.2.156,
如果这台电脑能连上它,应该很快就通)
第二次启动:记忆里有它,直接排最前面,应该瞬间完成
重点看:**从进程启动到 UDP 8766 绑上花了多久** ——
这才是"手表多久能搜到电脑"。
"""
from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
DISCOVERY = 8766
def clean() -> None:
subprocess.run(
["powershell", "-NoProfile", "-Command",
"Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | "
"Where-Object { $_.CommandLine -match 'run_agent' } | "
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
"-ErrorAction SilentlyContinue }"],
capture_output=True, text=True, timeout=60)
time.sleep(2)
def probe(port: int, wait_discovery: bool = True) -> tuple[bool, float, str]:
"""起 agent,返回 (8766 是否可用, 花了多久, 日志尾部)。"""
t0 = time.time()
env = dict(os.environ)
env["PYTHONIOENCODING"] = "utf-8"
proc = subprocess.Popen(
[sys.executable, "run_agent.py", "--port", str(port),
"--new-code", "--add-device", "10"],
cwd=str(ROOT / "windows"), env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
deadline = time.time() + 40
got = False
while time.time() < deadline:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(1.5)
try:
s.sendto(json.dumps({"lanremote": "LANREMOTE_SELFCHECK",
"proto": 1}).encode(), ("127.0.0.1",
DISCOVERY))
data, _ = s.recvfrom(2048)
if b"LANREMOTE_ACK" in data:
got = True
break
except (socket.timeout, ConnectionResetError, OSError):
pass
finally:
s.close()
time.sleep(0.3)
elapsed = time.time() - t0
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
time.sleep(1.5)
log = ROOT / "windows" / "data" / "silent.log"
tail = ""
if log.is_file():
tail = log.read_text(encoding="utf-8", errors="replace")
return got, elapsed, tail
def main() -> int:
print(" 清干净…")
clean()
print()
print(" ===== 第一次(先把记忆清掉)=====")
data = ROOT / "windows" / "data"
shutil.rmtree(data, ignore_errors=True)
got1, t1, log1 = probe(8861)
print(f" 8766 可用: {got1} 耗时 {t1:.1f} 秒")
rel_lines = [ln for ln in log1.splitlines() if "选用" in ln or "中转已启用" in ln]
for ln in rel_lines:
print(f" {ln.strip()}")
print()
print(" ===== 第二次(有记忆了)=====")
# 保留 data 里的 settings.json(记忆就存在这里)
got2, t2, log2 = probe(8862)
print(f" 8766 可用: {got2} 耗时 {t2:.1f} 秒")
rel_lines = [ln for ln in log2.splitlines() if "选用" in ln or "中转已启用" in ln]
for ln in rel_lines:
print(f" {ln.strip()}")
print()
print(" ===== 结果 =====")
print(f" 第一次 {t1:.1f} 秒 -> 第二次 {t2:.1f} 秒")
if t2 < t1:
print(f" ✓ 快了 {t1 - t2:.1f} 秒")
else:
print(" ~ 没有明显变快(可能第一个候选本来就通)")
# 看看记忆存了什么
settings = ROOT / "windows" / "data" / "settings.json"
if settings.is_file():
try:
obj = json.loads(settings.read_text(encoding="utf-8"))
print(f" 记忆的中转地址: {obj.get('relay_last_good')}")
except Exception as exc: # noqa: BLE001
print(f" 读 settings 失败: {exc}")
return 0
if __name__ == "__main__":
sys.exit(main())
+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())
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 1.7 MiB

+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.0.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.0.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.6.0.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.6.0.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.0-说明.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
View File
@@ -402,7 +402,6 @@ New-Item -ItemType Directory -Force -Path $out | Out-Null
(Join-Path $root "android\src\com\lanremote\watch\DiscoveryCore.java") `
(Join-Path $root "android\src\com\lanremote\watch\Transport.java") `
(Join-Path $root "android\src\com\lanremote\watch\RelayTransport.java") `
# TlsCompat:内置根证书,修老手表连中转时的 SSLHandshakeException
(Join-Path $root "android\src\com\lanremote\watch\TlsCompat.java") `
(Join-Path $root "android\src\com\lanremote\watch\RemoteClient.java") `
(Join-Path $root "_tests\java\JavaClientTest.java") 2>&1 | Out-Null
+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.0">
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.0.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.0" `
--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.0
版本:1.7.0
作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -197,7 +197,7 @@
八、版本记录
--------------------------------------------------------
1.6.0
1.7.0
* 跨网络支持:Cloudflare 具名隧道,固定域名
* 中转服务器:单设备单配对码、管理页可删设备
* 手表端:自动判断局域网/中转并无缝切换
@@ -209,6 +209,6 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 1.6.0
软件:ykxmz 1.7.0
许可条款见《许可协议》(LICENSE.txt)
--------------------------------------------------------
+2 -2
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
版本:1.6.0
版本:1.7.0
作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -152,6 +152,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
软件:ykxmz 1.6.0
软件:ykxmz 1.7.0
继续安装,即表示你已阅读并同意《许可协议》。
--------------------------------------------------------
+2 -2
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz(远程管理工具)
版本:1.6.0
版本:1.7.0
作者:小麦子
生效日期:2026 年 10 月 5 日
@@ -150,7 +150,7 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 1.6.0
软件: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.0"
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.0"
__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
+22
View File
@@ -253,6 +253,20 @@ class RelayLink:
self._log("没配置中转地址,跳过")
return False
# 上次连通的地址排到最前面。
# 设备 IP 会变(DHCP),但大概率还是上次那个;
# 排前面能省掉"挨个探到超时"的那几秒(实测 2 个候选要 10 秒,
# 而这段时间局域网发现都还没起来,手表搜不到电脑)。
try:
last_good = str(self.store.get("relay_last_good") or "").strip()
except Exception: # noqa: BLE001
last_good = ""
if last_good:
normalized = last_good.rstrip("/")
if normalized in candidates:
candidates.remove(normalized)
candidates.insert(0, normalized)
self.candidates = candidates
# 先探一遍,挑第一个通的
chosen = self._pick_candidate(candidates)
@@ -260,6 +274,14 @@ class RelayLink:
if len(candidates) > 1:
self._log(f"中转地址候选 {len(candidates)} 个,选用 {chosen}")
# 记下来,下次优先试它
if chosen:
try:
if str(self.store.get("relay_last_good") or "") != chosen:
self.store.set("relay_last_good", chosen)
except Exception: # noqa: BLE001
pass
self.device_id = self._ensure_device_id()
self.code = str(self.store.pairing_code() or "")

Some files were not shown because too many files have changed in this diff Show More