mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 23:00:44 +00:00
被控端 - 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%) - 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败) - 每分钟回头找局域网,自动切回快 400 倍的路径 - 画面心跳帧:静止时省流量,客户端仍知道流是活的 - 锁屏时给出可操作的提示 手表端 - 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException - 连接错误翻译成人话(证书/时间/解析/超时分别提示) - 经中转连上后用服务器报的地址刷新已配对记录 - 新增局域网连接页面、服务器延迟检测 服务器端 - 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502) - 网络调优脚本 net-tune.sh(开机自动应用) - 管理页:批量删除、网络体检、下载区 - 隧道改 HTTP/2 + 双连接 文档 - 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
464 lines
18 KiB
Python
464 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""被控端协议回归测试。
|
|
|
|
用一个"桩 UI"替代 tkinter,从而可以在无界面干扰的前提下,
|
|
把服务端全部命令跑一遍。这是产品功能的真实验证,不是 mock 逻辑。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import wave
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "windows"))
|
|
|
|
from lanremote import protocol as P # noqa: E402
|
|
from lanremote.actions import Actions # noqa: E402
|
|
from lanremote.config import Store # noqa: E402
|
|
from lanremote.server import RemoteServer # noqa: E402
|
|
|
|
PORT = 8799
|
|
PASSED: list[str] = []
|
|
FAILED: list[str] = []
|
|
|
|
|
|
def check(name: str, cond: bool, detail: str = "") -> None:
|
|
(PASSED if cond else FAILED).append(name)
|
|
mark = "PASS" if cond else "FAIL"
|
|
print(f" [{mark}] {name}" + (f" {detail}" if detail and not cond else ""))
|
|
|
|
|
|
class StubUI:
|
|
"""替身界面:只记录,不弹窗、不显示图片。"""
|
|
|
|
def __init__(self, workdir: Path):
|
|
self.workdir = workdir
|
|
self.logs: list[str] = []
|
|
self.notifications: list[str] = []
|
|
self.images: list[str] = []
|
|
self.audio: list[str] = []
|
|
self.clipboard = ""
|
|
self.screen_indicator = False
|
|
self.screen_flashed = False
|
|
|
|
# --- 服务端调用 ---
|
|
def log(self, text): self.logs.append(text)
|
|
def notify(self, title, text): self.notifications.append(f"{title}|{text}")
|
|
def on_state_changed(self): pass
|
|
def screen_size(self): return (1920, 1080)
|
|
def set_screen_sharing(self, active): self.screen_indicator = bool(active)
|
|
def flash_screen_indicator(self, seconds=2.5): self.screen_flashed = True
|
|
|
|
def ui_call(self, fn, timeout=8.0):
|
|
return fn()
|
|
|
|
def root(self): # actions 里用到 root.clipboard_* —— 由下面替换
|
|
raise RuntimeError("stub 不提供 tkinter root")
|
|
|
|
|
|
def make_stub_actions(ui: "StubUI", store: Store) -> Actions:
|
|
"""构造 Actions,但把依赖 tkinter/显示的部分替换为记录。"""
|
|
acts = Actions.__new__(Actions)
|
|
acts.ui = ui
|
|
acts.store = store
|
|
acts._audio_alias = None
|
|
acts._image_window = None
|
|
acts._lock = threading.RLock()
|
|
|
|
def show_image(path, title=""):
|
|
ui.images.append(Path(path).name)
|
|
ui.last_image_title = title
|
|
return f"已显示 {Path(path).name}"
|
|
|
|
def play_audio(path):
|
|
ui.audio.append(Path(path).name)
|
|
return f"正在播放 {Path(path).name}"
|
|
|
|
def clip_set(text):
|
|
ui.clipboard = text
|
|
return f"剪贴板已更新({len(text)} 字符)"
|
|
|
|
def clip_get():
|
|
return ui.clipboard
|
|
|
|
acts.show_image = show_image # type: ignore[method-assign]
|
|
acts.play_audio = play_audio # type: ignore[method-assign]
|
|
acts.clip_set = clip_set # type: ignore[method-assign]
|
|
acts.clip_get = clip_get # type: ignore[method-assign]
|
|
acts.stop_audio = lambda: "已停止" # type: ignore[method-assign]
|
|
return acts
|
|
|
|
|
|
class Client:
|
|
"""极简协议客户端(与安卓端行为一致)。"""
|
|
|
|
def __init__(self, port=PORT):
|
|
self.sock = socket.create_connection(("127.0.0.1", port), timeout=10)
|
|
self.sock.settimeout(15)
|
|
self.buf = b""
|
|
self.events: list[dict] = []
|
|
|
|
def send(self, obj):
|
|
self.sock.sendall(P.dumps(obj))
|
|
|
|
def recv(self) -> dict:
|
|
while b"\n" not in self.buf:
|
|
data = self.sock.recv(65536)
|
|
if not data:
|
|
raise ConnectionError("服务端关闭了连接")
|
|
self.buf += data
|
|
line, self.buf = self.buf.split(b"\n", 1)
|
|
return P.loads(line)
|
|
|
|
def call(self, obj) -> dict:
|
|
self.send(obj)
|
|
while True:
|
|
msg = self.recv()
|
|
if "event" in msg:
|
|
self.events.append(msg)
|
|
continue
|
|
return msg
|
|
|
|
def send_chunks(self, cmd, name, blob):
|
|
for seq, last, piece in P.chunk_bytes(blob):
|
|
self.send({"cmd": cmd, "name": name, "seq": seq,
|
|
"last": last, "data": P.b64encode(piece)})
|
|
return self.recv()
|
|
|
|
def wait_event(self, kind, timeout=20.0) -> dict | None:
|
|
deadline = time.time() + timeout
|
|
for ev in self.events:
|
|
if ev.get("event") == kind:
|
|
return ev
|
|
while time.time() < deadline:
|
|
try:
|
|
msg = self.recv()
|
|
except (socket.timeout, ConnectionError):
|
|
return None
|
|
if msg.get("event") == kind:
|
|
return msg
|
|
self.events.append(msg)
|
|
return None
|
|
|
|
def close(self):
|
|
try:
|
|
self.sock.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def make_wav(path: Path, seconds=1.0, rate=8000):
|
|
with wave.open(str(path), "wb") as fh:
|
|
fh.setnchannels(1)
|
|
fh.setsampwidth(2)
|
|
fh.setframerate(rate)
|
|
frames = b"".join(
|
|
struct.pack("<h", int(3000 * ((i // 40) % 2 * 2 - 1)))
|
|
for i in range(int(rate * seconds)))
|
|
fh.writeframes(frames)
|
|
|
|
|
|
def main() -> int:
|
|
work = Path(tempfile.mkdtemp(prefix="lanremote_test_"))
|
|
print(f"测试工作目录: {work}")
|
|
|
|
store = Store(work / "data")
|
|
store.set("port", PORT)
|
|
store.set("allow_run", True)
|
|
store.set("allow_power", True)
|
|
code = store.new_pairing_code()
|
|
print(f"配对码: {code}\n")
|
|
|
|
ui = StubUI(work)
|
|
actions = make_stub_actions(ui, store)
|
|
server = RemoteServer(store, actions, ui=ui)
|
|
server.start()
|
|
time.sleep(0.5)
|
|
|
|
# 准备测试素材
|
|
png = work / "test_image.png"
|
|
try:
|
|
from PIL import Image
|
|
Image.new("RGB", (300, 200), (30, 120, 200)).save(png)
|
|
except ImportError:
|
|
png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 100)
|
|
wav = work / "test_audio.wav"
|
|
make_wav(wav)
|
|
|
|
print("=" * 68)
|
|
print("1. 未鉴权保护")
|
|
c = Client()
|
|
check("未鉴权时命令被拒绝",
|
|
c.call({"cmd": "ping"}).get("ok") is False)
|
|
c.close()
|
|
|
|
print("=" * 68)
|
|
print("2. 配对流程")
|
|
c = Client()
|
|
wrong = c.call({"cmd": "pair", "code": "000000", "deviceId": "w1",
|
|
"deviceName": "手表一号"})
|
|
check("错误配对码被拒绝", wrong.get("ok") is False and "配对码" in wrong.get("error", ""))
|
|
good = c.call({"cmd": "pair", "code": code, "deviceId": "w1",
|
|
"deviceName": "手表一号"})
|
|
check("正确配对码通过", good.get("ok") is True)
|
|
token = good.get("token", "")
|
|
check("返回了设备令牌", len(token) == 32, f"token={token!r}")
|
|
check("返回了服务器名", bool(good.get("serverName")))
|
|
c.close()
|
|
|
|
print("=" * 68)
|
|
print("3. 令牌免配对重连(模拟下次打开 App)")
|
|
c = Client()
|
|
bad = c.call({"cmd": "auth", "deviceId": "w1", "token": "deadbeef"})
|
|
check("错误令牌被拒绝", bad.get("ok") is False and bad.get("needPair") is True)
|
|
ok = c.call({"cmd": "auth", "deviceId": "w1", "token": token,
|
|
"deviceName": "手表一号"})
|
|
check("正确令牌免配对登录", ok.get("ok") is True)
|
|
c.close()
|
|
|
|
print("=" * 68)
|
|
print("4. 基础命令")
|
|
c = Client()
|
|
c.call({"cmd": "auth", "deviceId": "w1", "token": token, "deviceName": "手表一号"})
|
|
check("ping", c.call({"cmd": "ping"}).get("pong") is True)
|
|
info = c.call({"cmd": "info"}).get("info", {})
|
|
check("info 返回系统信息", bool(info.get("os")), str(info))
|
|
|
|
# 媒体按键会注入到当前前台播放器。音量/静音会改变系统状态,
|
|
# 因此每测一项立刻用反向按键还原。
|
|
check("media 播放/暂停", c.call({"cmd": "media", "action": "playpause"}).get("ok") is True)
|
|
check("media 下一曲", c.call({"cmd": "media", "action": "next"}).get("ok") is True)
|
|
check("media 上一曲", c.call({"cmd": "media", "action": "prev"}).get("ok") is True)
|
|
check("media 停止", c.call({"cmd": "media", "action": "stop"}).get("ok") is True)
|
|
check("media 音量+", c.call({"cmd": "media", "action": "volup"}).get("ok") is True)
|
|
check("media 音量-(还原)",
|
|
c.call({"cmd": "media", "action": "voldown"}).get("ok") is True)
|
|
check("media 静音", c.call({"cmd": "media", "action": "mute"}).get("ok") is True)
|
|
check("media 静音(还原)",
|
|
c.call({"cmd": "media", "action": "mute"}).get("ok") is True)
|
|
check("media 非法动作被拒绝",
|
|
c.call({"cmd": "media", "action": "explode"}).get("ok") is False)
|
|
|
|
print("=" * 68)
|
|
print("5. 剪贴板")
|
|
check("clip_set", c.call({"cmd": "clip_set", "text": "来自手表的文本"}).get("ok") is True)
|
|
got = c.call({"cmd": "clip_get"})
|
|
check("clip_get 取回内容", got.get("text") == "来自手表的文本", str(got))
|
|
|
|
print("=" * 68)
|
|
print("6. 语音播报 / 通知")
|
|
check("say", c.call({"cmd": "say", "text": "测试播报"}).get("ok") is True)
|
|
check("notify", c.call({"cmd": "notify", "title": "T", "text": "B"}).get("ok") is True)
|
|
check("通知已送达界面", any("T|B" == n for n in ui.notifications), str(ui.notifications))
|
|
|
|
print("=" * 68)
|
|
print("7. 投图(分块传输)")
|
|
blob = png.read_bytes()
|
|
reply = c.send_chunks("image", "remote_photo.png", blob)
|
|
check("图片接收成功", reply.get("ok") is True and reply.get("size") == len(blob),
|
|
str(reply))
|
|
check("触发了全屏显示", ui.images == ["remote_photo.png"], str(ui.images))
|
|
|
|
print("=" * 68)
|
|
print("8. 放音(分块传输)")
|
|
abytes = wav.read_bytes()
|
|
reply = c.send_chunks("audio", "remote_audio.wav", abytes)
|
|
check("音频接收成功", reply.get("ok") is True and reply.get("size") == len(abytes),
|
|
str(reply))
|
|
check("触发了音频播放", ui.audio == ["remote_audio.wav"], str(ui.audio))
|
|
|
|
print("=" * 68)
|
|
print("9. 文件上传 / 列表 / 下载")
|
|
payload = os.urandom(300 * 1024) # 300KB,跨越多个分块
|
|
reply = c.send_chunks("file_push", "bigfile.bin", payload)
|
|
check("大文件分块上传成功", reply.get("ok") is True and reply.get("size") == len(payload),
|
|
str(reply))
|
|
saved = Path(reply.get("path", ""))
|
|
check("落盘内容一致", saved.is_file() and saved.read_bytes() == payload)
|
|
|
|
listing = c.call({"cmd": "file_list"})
|
|
names = [f["name"] for f in listing.get("files", [])]
|
|
check("file_list 包含刚上传的文件", "bigfile.bin" in names, str(names))
|
|
|
|
c.send({"cmd": "file_pull", "path": "bigfile.bin"})
|
|
assembled = bytearray()
|
|
ok_pull = False
|
|
deadline = time.time() + 30
|
|
while time.time() < deadline:
|
|
msg = c.recv()
|
|
if msg.get("event") == "file":
|
|
assembled.extend(P.b64decode(msg.get("data", "")))
|
|
if msg.get("last"):
|
|
ok_pull = True
|
|
elif "ok" in msg:
|
|
break # 服务端在分块发完后的最终回执,必须读走
|
|
check("file_pull 回传内容一致", ok_pull and bytes(assembled) == payload,
|
|
f"got {len(assembled)} bytes")
|
|
|
|
print("=" * 68)
|
|
print("9b. 同名文件重复上传:拉回必须是最新的一份(回归曾经的 bug)")
|
|
first_blob = b"AAAA" * 100
|
|
second_blob = b"BBBB" * 250
|
|
r1 = c.send_chunks("file_push", "dup.bin", first_blob)
|
|
check("第一次上传 dup.bin", r1.get("ok") is True, str(r1))
|
|
time.sleep(0.1)
|
|
r2 = c.send_chunks("file_push", "dup.bin", second_blob)
|
|
check("第二次上传 dup.bin", r2.get("ok") is True, str(r2))
|
|
check("两次保存到不同文件(不覆盖旧数据)",
|
|
r1.get("path") != r2.get("path"),
|
|
f"{r1.get('path')} vs {r2.get('path')}")
|
|
|
|
c.send({"cmd": "file_pull", "path": "dup.bin"})
|
|
dup = bytearray()
|
|
dup_done = False
|
|
deadline = time.time() + 20
|
|
while time.time() < deadline:
|
|
msg = c.recv()
|
|
if msg.get("event") == "file":
|
|
dup.extend(P.b64decode(msg.get("data", "")))
|
|
if msg.get("last"):
|
|
dup_done = True
|
|
elif "ok" in msg:
|
|
break
|
|
check("dup.bin 拉回的是最新内容(不是旧文件)",
|
|
dup_done and bytes(dup) == second_blob,
|
|
f"got {len(dup)} bytes, expected {len(second_blob)}")
|
|
|
|
print("=" * 68)
|
|
print("10. 自定义命令")
|
|
r = c.call({"cmd": "run", "command": "echo lanremote-ok"})
|
|
check("run 执行并回传输出", r.get("ok") is True and "lanremote-ok" in r.get("stdout", ""),
|
|
str(r))
|
|
check("run 返回退出码", r.get("code") == 0, str(r.get("code")))
|
|
|
|
print("=" * 68)
|
|
print("11. 权限开关生效")
|
|
store.set("allow_run", False)
|
|
check("关闭后 run 被拒绝",
|
|
c.call({"cmd": "run", "command": "echo x"}).get("ok") is False)
|
|
store.set("allow_run", True)
|
|
store.set("allow_power", False)
|
|
check("关闭后 power 被拒绝",
|
|
c.call({"cmd": "power", "action": "lock"}).get("ok") is False)
|
|
store.set("allow_power", True)
|
|
|
|
print("=" * 68)
|
|
print("12. 电源:取消关机(安全动作)")
|
|
check("power abort 正常返回",
|
|
c.call({"cmd": "power", "action": "abort"}).get("ok") is True)
|
|
|
|
print("=" * 68)
|
|
print("13. 多设备与状态")
|
|
c2 = Client()
|
|
pair2 = c2.call({"cmd": "pair", "code": code, "deviceId": "w2",
|
|
"deviceName": "手表二号"})
|
|
check("第二台设备可配对", pair2.get("ok") is True)
|
|
status = server.status()
|
|
check("服务端报告 2 台在线", len(status["connected"]) == 2, str(status["connected"]))
|
|
check("已保存 2 台设备", len(status["paired_saved"]) == 2)
|
|
check("设备名正确记录",
|
|
{"手表一号", "手表二号"} == {d["name"] for d in status["paired_saved"]},
|
|
str(status["paired_saved"]))
|
|
|
|
print("=" * 68)
|
|
print("14. 屏幕镜像 / 截屏")
|
|
store.set("allow_screen", False)
|
|
check("关闭屏幕权限后 screen_start 被拒绝",
|
|
c.call({"cmd": "screen_start"}).get("ok") is False)
|
|
check("关闭屏幕权限后 screenshot 被拒绝",
|
|
c.call({"cmd": "screenshot"}).get("ok") is False)
|
|
store.set("allow_screen", True)
|
|
|
|
# --- 单次截屏 ---
|
|
shot = c.call({"cmd": "screenshot", "maxWidth": 640, "quality": 70})
|
|
check("截屏返回成功", shot.get("ok") is True, str(shot)[:160])
|
|
raw = P.b64decode(shot.get("data", ""))
|
|
check("截屏是合法 JPEG",
|
|
raw[:2] == b"\xff\xd8" and raw[-2:] == b"\xff\xd9", f"{len(raw)} 字节")
|
|
check("截屏尺寸有效",
|
|
int(shot.get("w") or 0) > 0 and int(shot.get("h") or 0) > 0,
|
|
f"{shot.get('w')}x{shot.get('h')}")
|
|
check("截屏遵守 maxWidth", int(shot.get("w") or 9999) <= 640, str(shot.get("w")))
|
|
check("截屏时被控端闪了可见提示", ui.screen_flashed is True)
|
|
|
|
# --- 实时镜像 ---
|
|
start = c.call({"cmd": "screen_start", "fps": 4, "maxWidth": 320, "quality": 50})
|
|
check("开始屏幕镜像", start.get("ok") is True, str(start))
|
|
check("共享时被控端显示了可见提示(强制)", ui.screen_indicator is True)
|
|
check("服务端报告正在共享", server.status().get("screen_sharing") is True)
|
|
check("服务端报告观看者数量", server.status().get("screen_viewers") == 1,
|
|
str(server.status().get("screen_viewers")))
|
|
|
|
frames: list[dict] = []
|
|
deadline = time.time() + 20
|
|
while time.time() < deadline and len(frames) < 3:
|
|
try:
|
|
msg = c.recv()
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f" 收帧异常: {exc}")
|
|
break
|
|
if msg.get("event") == P.EVT_FRAME:
|
|
frames.append(msg)
|
|
check("收到至少 3 帧镜像", len(frames) >= 3, f"实际 {len(frames)} 帧")
|
|
if frames:
|
|
first = frames[0]
|
|
data = P.b64decode(first.get("data", ""))
|
|
check("镜像帧是合法 JPEG",
|
|
data[:2] == b"\xff\xd8" and data[-2:] == b"\xff\xd9", f"{len(data)} 字节")
|
|
check("镜像帧遵守 maxWidth", int(first.get("w") or 9999) <= 320,
|
|
str(first.get("w")))
|
|
sizes = [int(f.get("size") or 0) for f in frames]
|
|
check("帧大小合理(几 KB 量级)", all(0 < s < 2_000_000 for s in sizes), str(sizes))
|
|
|
|
stop = c.call({"cmd": "screen_stop"})
|
|
check("停止屏幕镜像", stop.get("ok") is True)
|
|
time.sleep(0.8)
|
|
check("停止后不再共享", server.status().get("screen_sharing") is False)
|
|
check("停止后可见提示关闭", ui.screen_indicator is False)
|
|
|
|
# --- 被控端本机强制掐断(最终否决权)---
|
|
c.call({"cmd": "screen_start", "fps": 2})
|
|
time.sleep(0.5)
|
|
check("重新开始共享生效", server.status().get("screen_sharing") is True)
|
|
affected = server.stop_screen_sharing()
|
|
time.sleep(0.5)
|
|
check("本机一键掐断生效",
|
|
affected == 1 and server.status().get("screen_sharing") is False,
|
|
f"affected={affected}, sharing={server.status().get('screen_sharing')}")
|
|
check("掐断后可见提示关闭", ui.screen_indicator is False)
|
|
c.events.clear() # 丢掉残留帧事件,避免影响后续断言
|
|
|
|
print("=" * 68)
|
|
print("15. 断开与清理")
|
|
n = server.disconnect_all()
|
|
check("断开全部连接", n == 2, f"disconnected={n}")
|
|
time.sleep(0.4)
|
|
check("断开后在线数为 0", len(server.status()["connected"]) == 0)
|
|
check("移除设备凭据", store.forget_device("w2") is True)
|
|
check("移除后仅剩 1 台", len(store.device_list()) == 1)
|
|
|
|
c.close()
|
|
c2.close()
|
|
server.stop()
|
|
time.sleep(0.3)
|
|
|
|
print("\n" + "=" * 68)
|
|
print(f"通过 {len(PASSED)} 项,失败 {len(FAILED)} 项")
|
|
if FAILED:
|
|
print("失败项:")
|
|
for name in FAILED:
|
|
print(f" - {name}")
|
|
shutil.rmtree(work, ignore_errors=True)
|
|
return 1 if FAILED else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|