2 Commits
Author SHA1 Message Date
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
85 changed files with 1075 additions and 36 deletions
+5
View File
@@ -59,3 +59,8 @@ desktop.ini
# ---- 测试收发的临时数据 ----
/_tests/received/
# 令牌(绝对不要进仓库)
/_build/.gitee_token
*.token
.env
View File
View File
View File
View File
+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())
+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())
+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())
+181
View File
@@ -0,0 +1,181 @@
# -*- 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}"
files = [
ROOT / "dist" / f"ykxmz-agent-{version}.zip",
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()
# ---- 1. 建发行版 ----
body = f"""## ykxmz {version}
教室电脑手表遥控。
老师抬一下手腕就能管教室里那台电脑 —— 放课件、放音频、投图、
看看谁在打游戏、下课关机,不用走回讲台。
### 这次下载哪个
| 你是 | 下这个 |
|---|---|
| 教室电脑装被控端 | `ykxmz-agent-{version}.zip` |
| 要跨网络(办公室管教室) | `ykxmz-server-{version}.zip` |
| 手表装控制端 | `ykxmz-watch-{version}.apk` |
只在一个教室用的话,第三件(中转服务器)可以先不装,
手表和电脑连同一个 WiFi 就能直连。
### 怎么用
装完看包里的 `README.txt`,或者仓库首页的说明。
### 这一版改了什么
- 重写说明文档,按老师 / 电教 / 开发者 / 普通用户分开讲
- 手表端内置根证书,修部分手表连中转时的证书校验失败
- 中转站加下载区
- 网络稳定性:修 MTU 问题,公网可用率从 48% 提到 95% 以上
"""
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())
+1 -1
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.6.2"
NAME = f"ykxmz-{VERSION}"
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
+185
View File
@@ -0,0 +1,185 @@
# -*- 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, "打三个成品")
proc = sh([sys.executable, "_build/package_remote.py"])
print(proc.stdout[-1400:])
if proc.returncode != 0:
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(2):
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(4)
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())
+65
View File
@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
r"""设备回来了:把 1.6.1 传到下载区,并部署最新 relay。"""
from __future__ import annotations
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
VER = "1.6.1"
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())
+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())
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.6.2.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.6.2.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.6.2.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.6.2.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.6.2-说明.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.6.2">
<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.6.2.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.6.2" `
--auto-add-overlay
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
+3 -3
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
版本:1.6.0
版本:1.6.2
作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -197,7 +197,7 @@
八、版本记录
--------------------------------------------------------
1.6.0
1.6.2
* 跨网络支持:Cloudflare 具名隧道,固定域名
* 中转服务器:单设备单配对码、管理页可删设备
* 手表端:自动判断局域网/中转并无缝切换
@@ -209,6 +209,6 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 1.6.0
软件:ykxmz 1.6.2
许可条款见《许可协议》(LICENSE.txt)
--------------------------------------------------------
+2 -2
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
版本:1.6.0
版本:1.6.2
作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -152,6 +152,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
软件:ykxmz 1.6.0
软件:ykxmz 1.6.2
继续安装,即表示你已阅读并同意《许可协议》。
--------------------------------------------------------
+2 -2
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz(远程管理工具)
版本:1.6.0
版本:1.6.2
作者:小麦子
生效日期:2026 年 10 月 5 日
@@ -150,7 +150,7 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 1.6.0
软件:ykxmz 1.6.2
如果你不同意本协议的任何一条,请不要安装或使用本软件。
--------------------------------------------------------
+1 -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.6.2"
# ---------------- 参数(都能用环境变量覆盖)----------------
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""局域网遥控 · Windows 被控端。"""
__version__ = "1.6.0"
__version__ = "1.6.2"
APP_NAME = "局域网遥控 被控端"
+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 "")
+26 -14
View File
@@ -88,7 +88,10 @@ class Session:
# 几十毫秒,而且地址永远不变)
# 电脑在外面 -> 第一个连不上,自动切第二个(Cloudflare 隧道,
# 600 毫秒左右,地址固定 relay.xxmxxs.top)
DEFAULT_RELAY_URL = "192.168.10.105:8080;https://relay.xxmxxs.top"
# 内置的中转地址:局域网优先,连不上自动切公网隧道。
# 第一个是随身 WiFi 的固定地址(写死是因为它常在同一网段、直连最快);
# 它变了也没关系 —— 连上过的地址会被记住并排到最前面。
DEFAULT_RELAY_URL = "192.168.2.156:8080;https://relay.xxmxxs.top"
class RemoteServer:
@@ -149,12 +152,23 @@ class RemoteServer:
self._sock = sock
self._running = True
# 配了中转地址就同时启用跨网络通道(和局域网监听并存,不冲突)
try:
self.enable_relay()
except Exception as exc: # noqa: BLE001
self._log(f"启用中转失败:{exc}")
# ── 先起局域网发现 ──
#
# 顺序很重要: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()
@@ -162,14 +176,12 @@ class RemoteServer:
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}")
# ── 最后才连中转(会阻塞,所以放最后)──
# 配了中转地址就同时启用跨网络通道(和局域网监听并存,不冲突)
try:
self.enable_relay()
except Exception as exc: # noqa: BLE001
self._log(f"启用中转失败:{exc}")
self._log(f"服务已启动,监听 0.0.0.0:{self.port}")
+2 -2
View File
@@ -27,12 +27,12 @@ VSVersionInfo(
[
StringStruct("CompanyName", "ykxmz"),
StringStruct("FileDescription", "ykxmz"),
StringStruct("FileVersion", "1.6.0.0"),
StringStruct("FileVersion", "1.6.2.0"),
StringStruct("InternalName", "ykxmz"),
StringStruct("LegalCopyright", "ykxmz"),
StringStruct("OriginalFilename", "ykxmz.exe"),
StringStruct("ProductName", "ykxmz"),
StringStruct("ProductVersion", "1.6.0.0"),
StringStruct("ProductVersion", "1.6.2.0"),
],
)
]