2.3.0:手表界面精简 + 播放器 + 鼠标重做

手表端
- 功能页从 10 个杂项精简到 7 个(屏幕/鼠标/媒体/文件/电脑/语音/电源)
- 删掉:游戏封锁、待办、掷骰子、抽奖、壁纸、显示器开关、定时关机、
  剪贴板历史、性能/磁盘/电量、文件夹浏览、教室限制等
- 改成显式列按钮,不再把电脑端所有功能组无脑铺到表盘
- 远程鼠标控制重做:滑动区 + 左键/滚轮/右键三按钮
  滚轮点按=中键、上下滑=滚动
- 新增视频/音乐播放器(手表本地播放):进度拖动、左右滑快进退10秒、
  点画面播放暂停、上一/下一、播完自动下一首

电脑端
- 新增 kill_all(结束所有进程),白名单保护系统关键进程和自身
- 新增 media_list / media_pull(播放器数据源)
  安全边界:只允许媒体目录,用 relative_to 判断,防同前缀兄弟目录绕过

中转站
- known_devices 从 ID 集合改成「设备ID -> 配对码」映射
- 修复:管理员改码后关机超过 6 小时,改码会被超时清理冲掉
- 老格式 state 文件平滑升级
This commit is contained in:
xmz7891
2026-10-18 11:39:14 +08:00
parent 1d368979aa
commit 2061f9525f
50 changed files with 13812 additions and 584 deletions
+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-2.2.0.apk"
ROOT / "dist" / "ykxmz-watch-2.3.0.apk"
APK = _latest_apk()
+8 -1
View File
@@ -156,7 +156,14 @@ def main() -> int:
print()
print("二、分页定义完整性")
tab_names = re.findall(r'new TabDef\("([^"]+)"', text)
check("定义了 10 个分页", len(tab_names) == 10, f"实际 {len(tab_names)} 个")
# 2.3.0:用户要求「杂七杂八功能都删掉」,分页从 10 个精简到 7 个。
# 断言改成 7,并且把期望的名字也列出来 —— 这样以后误删/误加
# 都能立刻看出来,比只数个数更有用。
EXPECT_TABS = ["屏幕", "鼠标", "媒体", "文件", "电脑", "语音", "电源"]
check(f"定义了 {len(EXPECT_TABS)} 个分页", len(tab_names) == len(EXPECT_TABS),
f"实际 {len(tab_names)} 个:{' '.join(tab_names)}")
check("分页名字和 2.3.0 设计一致", tab_names == EXPECT_TABS,
f"实际 {' '.join(tab_names)},期望 {' '.join(EXPECT_TABS)}")
print(f" 分页: {' '.join(tab_names)}")
check("selectTab 有定义", "private void selectTab(" in text)
check("selectTab 被调用", len(re.findall(r"\bselectTab\s*\(", text)) > 1)
+4 -4
View File
@@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
# 被控端 -> 一个 zip
# 服务器端 -> 一个 zip
# 控制端 -> 直接给 APK(不打 zip)
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.2.0.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.2.0.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.2.0.apk"
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.3.0.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.3.0.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.3.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-2.2.0-说明.txt")):
ROOT / "dist" / "ykxmz-watch-2.3.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])
+40 -10
View File
@@ -170,21 +170,51 @@ def main() -> int:
# ---------------- 五、鼠标触摸板 ----------------
print()
print("五、鼠标触摸板")
print("五、鼠标触摸板(2.3.0 版:滑动区 + 左键/滚轮/右键)")
check("有 buildMousePanel", "private void buildMousePanel()" in main_java)
check("用 GestureDetector(单击/双击/拖动)",
"GestureDetector" in main_java
and "onSingleTapConfirmed" in main_java
and "onDoubleTap" in main_java
and "onScroll" in main_java)
check("单击 -> 左键", 'sendMouse("click", "left", 0)' in main_java)
check("双击 -> 右键", 'sendMouse("click", "right", 0)' in main_java)
check("滚轮上/下", 'sendMouse("scroll", "", 3)' in main_java
and 'sendMouse("scroll", "", -3)' in main_java)
# 2.3.0 用户明确要求:「中间是一个鼠标,可以上下左右滑动控制,
# 下面有三个按钮,左边是左键,中间是那个滑轮,右边是那个右键」。
# 所以**不再用单击/双击手势** —— 左键和右键各有专门按钮,
# 手势里只留"滑动 = 移动光标"。
check("滑动区用 GestureDetector 的 onScroll(移动光标)",
"GestureDetector" in main_java and "onScroll" in main_java)
check("左键按钮 -> sendMouse click left",
'sendMouse("click", "left", 0)' in main_java)
check("右键按钮 -> sendMouse click right",
'sendMouse("click", "right", 0)' in main_java)
check("滚轮按钮:点一下 = 中键", 'sendMouse("click", "middle", 0)' in main_java)
check("滚轮按钮:上下滑 = 滚动",
'sendMouse("scroll", "", dir)' in main_java)
check("滚轮按钮有独立的触摸处理(不是普通 OnClickListener)",
"wheel.setOnTouchListener" in main_java)
check("拖动 -> moveby", '"moveby"' in main_java)
check("isConnected() 存在(第一版漏了,编译才发现)",
"private boolean isConnected()" in main_java)
# ---------------- 五之一、播放器(2.3.0 新增)----------------
print()
print("五之一、播放器")
check("有 buildPlayerPanel", "private void buildPlayerPanel()" in main_java)
check("有 seekTo / seekBy(进度左右划)",
"private void seekTo(int ms)" in main_java
and "private void seekBy(int deltaMs)" in main_java)
check("有上一/下一(stepPlayer)",
"private void stepPlayer(int delta)" in main_java)
check("视频用 VideoView", "android.widget.VideoView" in main_java)
check("音频用 MediaPlayer", "android.media.MediaPlayer" in main_java)
check("画面区左右滑 = 快进/快退",
"seekBy(dx > 0 ? 10000 : -10000)" in main_java)
check("画面区点击 = 播放/暂停",
"public boolean onSingleTapConfirmed" in main_java)
check("★ 播放器面板登记进 showOnly 数组", "playerPanel" in main_java
and re.search(r"View\[\] all = \{[^}]*playerPanel", main_java) is not None,
"漏了的话它永远不隐藏")
check("★ 播放器面板挂到了 root 上",
"root.addView(playerPanel" in main_java,
"只建不挂 = 一片空白")
check("★ 播完自动下一首",
"onCompletion" in main_java and "stepPlayer(1)" in main_java)
# ---------------- 五之二、连接后的选择页 ----------------
print()
print("五之二、连接后的选择页(远控 / 静默更新被控端)")
+192
View File
@@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
"""中继站:管理员改码 + 关机超 TTL 后不能丢(2.3.0)。
要钉死的行为:
① 管理员在后台把配对码从 111111 改成 222222
② 那台电脑关机(或断网)超过 DEVICE_TTL(真实 6 小时)
③ snapshot() 把设备记录和"孤儿"码绑定都清掉
④ 电脑开机,报**自己本地的旧码** 111111
⑤ 中转应该回它 222222,并让它写回本地设置
—— 改动之前这里会收下 111111,管理员的改码白做
另外顺带验证:
* 老格式 relay_state.json(knownDevices 是纯 ID 列表)能平滑升级
* 「删除设备」之后历史被忘干净(开关语义不能被绕)
* 「抹码」之后历史里记的码也清空(否则抹码白做)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PORT = 8151
BASE = f"http://127.0.0.1:{PORT}"
_UA = {"User-Agent": "LanRemote/2.3.0"}
ADMIN_KEY = "recode-ttl-key"
TTL = 2.0
passed = 0
failed = 0
def check(name: str, ok: bool, detail: str = "") -> bool:
global passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else ""))
return ok
def main() -> int:
work = Path(tempfile.mkdtemp(prefix="recode_ttl_"))
data = work / "d"
data.mkdir(parents=True, exist_ok=True)
env = dict(os.environ)
env.update({
"RELAY_PORT": str(PORT), "RELAY_BIND": "127.0.0.1",
"RELAY_DATA": str(data), "RELAY_DEVICE_TTL": str(TTL),
"RELAY_ADMIN_KEY": ADMIN_KEY, "RELAY_POLL": "2",
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1",
})
relay = None
def start() -> subprocess.Popen:
p = subprocess.Popen(
[sys.executable, "-u", str(ROOT / "relay" / "relay.py")], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(80):
time.sleep(0.4)
try:
health()
return p
except Exception: # noqa: BLE001
continue
return p
def health() -> dict:
r = urllib.request.Request(BASE + "/v1/health", headers=_UA)
with urllib.request.urlopen(r, timeout=5) as resp:
return json.loads(resp.read().decode("utf-8"))
def post(path: str, payload: dict, admin: bool = False) -> dict:
body = json.dumps(payload).encode()
headers = {**_UA, "Content-Type": "application/json"}
if admin:
headers["X-Admin"] = ADMIN_KEY
req = urllib.request.Request(BASE + path, data=body, method="POST",
headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
try:
return json.loads(exc.read().decode("utf-8"))
except Exception: # noqa: BLE001
return {}
def reg(dev: str, code: str, name: str = "教室电脑") -> dict:
return post("/v1/agent/register",
{"deviceId": dev, "code": code, "name": name,
"version": "2.3.0"})
try:
relay = start()
dev = "classroom-PC-AAAA1111"
old_code = "111111"
new_code = "222222"
print()
print("═══ 1. 登记 + 管理员改码 ═══")
r = reg(dev, old_code)
check("① 首次登记成功", r.get("ok") is True, r)
check("② 登记返回的码是它上报的", r.get("code") == old_code, r)
res = post("/v1/admin/set_code",
{"deviceId": dev, "code": new_code}, admin=True)
check("③ 管理员改码成功", res.get("ok") is True, res)
r2 = reg(dev, old_code)
check("④ 在线时改码生效(回的是新码)",
r2.get("ok") is True and r2.get("code") == new_code, r2)
print()
print(f"═══ 2. 电脑关机,等 {TTL + 2:.0f} 秒超过 TTL ═══")
time.sleep(TTL + 2)
h = health()
check("⑤ 设备记录被清掉(设计中如此)",
dev not in [d["deviceId"] for d in h["devices"]])
print(f" 清理后 codeCount={h.get('codeCount')}")
print()
print("═══ 3. 电脑开机,报本地旧码 —— 应该被纠正成新码 ═══")
r3 = reg(dev, old_code)
check("⑥ 开机登记成功", r3.get("ok") is True, r3)
check("⑦ **写回的是新码 222222,不是它报的旧码**",
r3.get("code") == new_code,
f"拿到 {r3.get('code')!r},期望 {new_code!r}")
check("⑧ 有 note 说明改码原因", bool(r3.get("note")), r3.get("note"))
h2 = health()
codes_now = [d.get("code") for d in h2["devices"]]
check("⑨ /v1/health 里这台设备显示的也是新码",
new_code in codes_now, codes_now)
print()
print("═══ 4. 重启中转 —— 新码要能持久化 ═══")
relay.terminate()
relay.wait(timeout=15)
relay = start()
r4 = reg(dev, old_code)
check("⑩ 重启后仍然纠正成新码",
r4.get("ok") is True and r4.get("code") == new_code, r4)
print()
print("═══ 5. 「删除设备」要忘干净(否���开关被绕过)═══")
post("/v1/admin/delete", {"deviceId": dev}, admin=True)
post("/v1/admin/allow_new", {"allow": False}, admin=True)
r5 = reg(dev, new_code)
check("⑪ 删除后设备被当成新设备,且开关关着 -> 拒绝",
r5.get("ok") is False and r5.get("blocked") is True, r5)
post("/v1/admin/allow_new", {"allow": True}, admin=True)
print()
print("═══ 6. 「抹码」之后历史里的码也要清空 ═══")
dev2 = "classroom-PC-BBBB2222"
reg(dev2, "333333")
post("/v1/admin/forget", {"code": "333333"}, admin=True)
r6 = reg(dev2, "444444")
check("⑫ 抹码后可以换新码(历史没把旧码塞回来)",
r6.get("ok") is True and r6.get("code") == "444444", r6)
r7 = reg(dev2, "555555")
check("⑬ 但换过之后又锁定单码制(回 444444)",
r7.get("ok") is True and r7.get("code") == "444444", r7)
print()
print("=" * 62)
print(f" 通过 {passed} 项,失败 {failed} 项")
print("=" * 62)
return 0 if failed == 0 else 1
finally:
if relay is not None and relay.poll() is None:
relay.terminate()
try:
relay.wait(timeout=10)
except Exception: # noqa: BLE001
relay.kill()
if __name__ == "__main__":
sys.exit(main())
+129
View File
@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
"""2.3.0 新命令的单元测试:media_list / media_pull / kill_all。
重点验**安全边界**,不是"能跑就行":
* `media_pull` 必须拒绝媒体目录之外的文件(否则就是个远程任意文件读取器)
* 同前缀的兄弟目录(`音乐-evil`)不能被误判成子目录
* `kill_all` 必须保护自己、父进程链、系统关键进程
"""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "windows"))
passed = 0
failed = 0
def check(name, ok, detail=""):
global passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else ""))
return ok
def main() -> int:
print("=" * 62)
print(" 2.3.0 新命令测试")
print("=" * 62)
# ---------- 1. 静态检查:实现存在且关键点齐 ----��------
print()
print("一、代码存在性")
server = (ROOT / "windows" / "lanremote" / "server.py").read_text(
encoding="utf-8")
features = (ROOT / "windows" / "lanremote" / "features.py").read_text(
encoding="utf-8")
check("server.py 有 _media_list", "def _media_list(self)" in server)
check("server.py 有 _media_pull", "def _media_pull(self" in server)
check("路由 media_list", 'cmd == "media_list"' in server)
check("路由 media_pull", 'cmd == "media_pull"' in server)
check("features.py 有 _do_kill_all", "def _do_kill_all(self" in features)
check("注册了 kill_all 功能", 'add("kill_all"' in features)
# ---------- 2. 安全边界:只允许媒体目录 ----------
print()
print("二、media_pull 的安全边界")
check("用 relative_to 判断(不是字符串前缀)",
"wanted.relative_to(root)" in server,
"字符串前缀会被「音乐-evil」这种同前缀兄弟目录绕过")
check("有「不在允许的媒体目录里」的拒绝",
"不在允许的媒体目录里" in server)
check("有文件大小上限(512MB)", "512 * 1024 * 1024" in server)
check("拒绝空文件", "文件是空的" in server)
# ---------- 3. kill_all 的保护名单 ----------
print()
print("三、kill_all 的保护名单")
for name in ("csrss.exe", "winlogon.exe", "lsass.exe", "svchost.exe",
"explorer.exe", "python.exe", "ykxmz.exe"):
check("保护 %s" % name, '"%s"' % name in features,
"少了它一按就可能蓝屏/自断后路")
check("保护自己的 PID", "_protected_pids" in features)
check("追父进程链", "ParentProcessId" in features)
# ---------- 4. 真的跑一次 _media_list(用临时目录)----------
print()
print("四、_media_list 实跑")
try:
import os
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
from lanremote import server as srv_mod
# 造一个假的 store(不需要 Config —— 那个类不存在,之前是我写错了)
tmp = Path(tempfile.mkdtemp(prefix="media_"))
music = tmp / "Music"
music.mkdir()
(music / "a.mp3").write_bytes(b"x" * 100)
(music / "b.mp4").write_bytes(b"y" * 200)
(music / "c.txt").write_text("not media", encoding="utf-8")
class FakeStore:
def received_dir(self):
return tmp
# 直接借 Server 的方法:构造太麻烦,用 types 绑定
import types
obj = types.SimpleNamespace()
obj.store = FakeStore()
obj.AUDIO_EXT = srv_mod.RemoteServer.AUDIO_EXT
obj.VIDEO_EXT = srv_mod.RemoteServer.VIDEO_EXT
obj.MEDIA_LIST_LIMIT = srv_mod.RemoteServer.MEDIA_LIST_LIMIT
obj.MEDIA_SUBDIRS = ("Music", "Videos")
obj._media_roots = types.MethodType(srv_mod.RemoteServer._media_roots, obj)
# Path.home() 是真实用户目录,这里把 _media_roots 换成只返回 music。
# ⚠ 必须返回**装文件的那一层**,不是它的父目录 ——
# _media_list 只扫根目录下的文件,不递归子目录。
# (第一版写成了 tmp,扫到的是 Music 这个文件夹本身,全被跳过。)
obj._media_roots = lambda: [music.resolve()]
result = srv_mod.RemoteServer._media_list(obj)
names = [f["name"] for f in result["files"]]
check("列出了 a.mp3", "a.mp3" in names)
check("列出了 b.mp4", "b.mp4" in names)
check("**排除了 c.txt**", "c.txt" not in names,
"非媒体扩展名不该出现")
kinds = {f["name"]: f["kind"] for f in result["files"]}
check("a.mp3 标成 audio", kinds.get("a.mp3") == "audio")
check("b.mp4 标成 video", kinds.get("b.mp4") == "video")
except Exception as exc: # noqa: BLE001
check("_media_list 实跑", False, "%s: %s" % (type(exc).__name__, exc))
print()
print("=" * 62)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
print("=" * 62)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())