2.1.7:手表快捷栏 + 鼠标触摸板 + 弹窗可配 + 后台远程更新

用户这次提了一整套升级,这是最后一批。

== 一、手表端顶部快捷栏 ==

用户原话:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
自定义,最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。

("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。)

  * 一条常驻的栏,贴在屏幕顶部,切页面时不动
  * **4 个格子**:3 个可自定义 + **1 个固定的「显示桌面」(改不了)**
  * 7 个可选动作:传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 /
    语音播报 / 投图到电脑
  * 设置页里点一下换;也能把某一格留空
  * 没连上电脑时按钮置灰(不是藏起来 —— "一直在但暂时不能用"
    比"时有时无"更好理解)

实现上的几个要点:

  * 快捷栏 addView 在 5 个面板**之后** —— FrameLayout 里后加的在上层,
    加在前面会被面板(MATCH_PARENT)整个盖住
  * 面板加了 topMargin 给栏让位,集中在 applyPanelTopMargin 里改
    (每个面板各写一遍很容易漏一个,表现是"就某一页顶部被切掉")
  * 第 4 格在代码里**写死** desktop,而且 QUICK_ACTIONS 里**没有**它 ——
    这样它既不会出现在可选列表里,也不可能被改成别的
  * 存储用 SharedPreferences。读取时**过滤不认识的 id + 补齐到 3 个**:
    以后删了某个动作,老配置也不会让栏变空或下标越界

== 二、远程鼠标控制(手表上的触摸板)==

  * 拖动 = 移动光标,单击 = 左键,双击 = 右键
  * 下面还有专门的 左键 / 右键 / 滚轮上 / 滚轮下 按钮
  * 电脑端用 SendInput 注入(见下)

== 三、弹文字窗口的位置和大小可配 ==

用户原话:"弹文字窗口 …… 位置和大小都能自定义"。

  * 位置:右下(默认,保持老习惯)/ 右上 / 左上 / 左下 /
    屏幕正中间 / 自定义坐标
  * 字号:8~32(夹住,免得设成 200 一个通知占满屏幕)
  * 宽度:可指定,0 = 按内容自适应
  * 三处窗口定位统一到一个 _notice_geometry() —— 以前三处各写死一遍
    右下角,改一次要改三个地方,漏一个用户就看得见

== 四、屏幕被观看时的提示条改成默认不显示 ==

用户原话:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。

  * 默认**连窗口都不建**
  * **但保留成开关**,设置里可以打开

  关于这条:那条提示原来是**故意加**的(有人在看屏幕时,电脑前面的人
  应该知道)。用户要求去掉,这是他自己的电脑、自己的教室,
  属于正当的远程管理需求。所以能力保留、默认关闭 ——
  这软件应该始终有一个"让本机用户看得见正在被控制"的选项可查。

== 五、中转后台能看到版本并远程点更新 ==

  * 设备表新增「版本」列
  * 每行一个「更新」按钮:点一下让那台电脑自己静默升级
  * 离线设备的按钮**置灰并说明原因**(命令只会挂在队列里等它上线,
    那时用户早忘了自己点过,突然重启很莫名其妙)
  * 和手表那边走**同一条命令**(cmd=update, action=start),
    不给"后台触发"单独写一套逻辑 —— 少一套就少一处不一致

== 六、手表端可开关「允许新设备连接」 ==

  * 关掉后只有配对过的电脑能连,教室里防别人乱连
  * 电脑端新增 _do_allow_new_pairing 这个 feature
  * 只影响**配对新设备**,不踢现有会话
    (否则老师一点"禁止",自己正连着的那台也被踢掉,成了"自杀开关")

== 修掉的几个自己造的坑 ==

1. **管理页 JS 裸换行** —— 最严重的一个。
   ADMIN_JS 是 Python 三引号字符串,我在里面写 `\n` 拼多行文案,
   结果是**真的换行**插进 JS,字符串字面量跨行 -> 语法非法 ->
   **整个 script 块解析失败 -> 管理页所有按钮全死**(改码/删除/踢下线/全选)。
   改成 String.fromCharCode(10),不依赖任何转义层。

2. **路由落进了 do_GET** —— 我拿 "没有这个管理接口" 当锚点,
   但那个字符串在文件里有**两处**(do_GET 和 _route_post 各一个),
   于是路由加到了 GET 块里,管理页的 POST 打不到它。
   表现是"点了更新没反应"。

3. **isConnected() 根本不存在** —— 写快捷栏时凭直觉调了个方法名,
   编译才发现。顺手包了一层并加上说明。

4. **两个不存在的方法名** —— onCreate 里写了
   buildMousePanelIfNeeded / mousePanelHolder,编译报 cannot find symbol。

5. **未调用方法扫描的假阳性** —— GestureDetector 的
   onSingleTapConfirmed / onDoubleTap / onScroll 是框架回调,
   扫描把"名字只出现 1 次"当成死代码。改成**跳过 @Override 的方法**。
   这种假阳性最坏的结果是有人为了让它变绿把回调删了,手势就失灵了。

6. **测试断言太严导致误报** —— "不能有写死的 240",
   但 helper 里 `auto = max(240, ...)` 是自适应的兜底,故意的。

7. **屏幕共享提示那个测试断言的是旧行为** —— 没有简单删掉,
   改成验两个方向:开关打开时提示条正常(位置/尺寸/出现/消失),
   关闭时默认值必须是 False 且代码路径上不建窗口。

== 验证 ==

    全量回归(19 关)全部 PASS,零失败
    新增关卡:3a10 静默更新 / 3a11 鼠标控制 / 3a12 后台更新+弹窗 /
              3a13 手表快捷栏
    Java 编译通过(android-34, --release 8)
    APK / 安装包 / 服务端包 全部重建,Win7 兼容校验通过
    623 个 Python 文件语法通过
This commit is contained in:
xmz7891
2026-10-09 22:55:28 +08:00
parent 3f543b274a
commit f0c05079f7
39 changed files with 3542 additions and 67 deletions
+7 -1
View File
@@ -20,5 +20,11 @@
"download_dir": "received",
"screen_max_width": 480,
"screen_quality": 72,
"screen_fps": 12
"screen_fps": 12,
"notice_position": "bottom-right",
"notice_x": 0,
"notice_y": 0,
"notice_width": 0,
"notice_font_size": 11,
"show_screen_notice": false
}
+168
View File
@@ -0,0 +1,168 @@
# -*- coding: utf-8 -*-
r'''验「后台点一下让电脑自己更新」这条链路。
== 链路 ==
管理页点「更新」
-> POST /v1/admin/update_device {deviceId}
-> Relay.request_update() 把 {"cmd":"update","action":"start"}
塞进该设备的 to_agent 队列
-> 被控端下一次长轮询拿到它
-> server.py 的 _do_update:查最新版 -> 下载 -> 校验 -> 静默装 -> 重启
== 为什么要专门测 ==
这条链路横跨三端(管理页 JS -> 中转 -> 被控端),任何一环写错,
表现都是"我点了更新,电脑没反应" —— 而用户在办公室里没法排查。
而且它**会重启别人的电脑**,所以几个边界必须卡死:
* 设备不存在 -> 明确说没有,不能静默成功
* 设备离线 -> 直接拒。命令挂在队列里等它上线才执行,
那时用户早忘了,突然重启很莫名其妙
* 重复点 -> 不重复塞(队列会堆积)
* 在线 -> 真的塞进队列,而且内容就是那条更新命令
== 上一版测试自己写错的地方 ==
我用了个 FakeDevice,`__init__` 里默认 `_on = True`,
结果"离线设备"那条用例测的其实是**在线**设备 —— 假绿。
现在 FakeDevice 显式接收 online 参数,不再有隐藏默认值。
'''
from __future__ import annotations
import json
import re
import sys
import threading
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "relay"))
FAILS: list = []
def check(name: str, ok: bool, why: str = "") -> None:
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
(" —— " + why) if why else ""))
if not ok:
FAILS.append(name)
class FakeDevice:
"""假的被控端。online 必须显式给,不给就报错 ——
第一版给了个 True 的默认值,导致"离线"用例永远测不到离线。"""
def __init__(self, online: bool):
self.to_agent = bytearray()
self._online = bool(online)
self.lan_ips: list = []
self.port = 0
def online(self) -> bool:
return self._online
def push_to_agent(self, data: bytes) -> None:
self.to_agent.extend(data)
def main() -> int:
print("=" * 58)
print(" 后台「更新」按钮链路")
print("=" * 58)
print()
import relay as R
print("一、Relay.request_update 的边界")
r = R.Relay.__new__(R.Relay)
r.lock = threading.RLock()
r.devices = {}
res = r.request_update("不存在的设备")
check("设备不存在 -> 拒绝", res.get("ok") is False,
"拿到 %r" % res)
check(" 错误信息说清楚是没这台", "没有这台设备" in str(res.get("error")),
"拿到 %r" % res.get("error"))
check("设备 id 为空也当不存在",
r.request_update("").get("ok") is False)
check(" None 也不炸", r.request_update(None).get("ok") is False)
off = FakeDevice(online=False)
r.devices["off1"] = off
res = r.request_update("off1")
check("离线设备 -> 拒绝", res.get("ok") is False, "拿到 %r" % res)
check(" 理由提到离线", "离线" in str(res.get("error")),
"拿到 %r" % res.get("error"))
check(" 离线时**没往队列里塞东西**", len(off.to_agent) == 0,
"队列里有 %d 字节 —— 离线时不该堆命令"
% len(off.to_agent))
on = FakeDevice(online=True)
r.devices["on1"] = on
res = r.request_update("on1")
check("在线设备 -> 成功", res.get("ok") is True, "拿到 %r" % res)
check(" 队列里真的放了东西", len(on.to_agent) > 0)
raw = bytes(on.to_agent).decode("utf-8")
check(" 是换行结尾的 JSON(协议要求)", raw.endswith("\n"),
"尾部是 %r" % raw[-6:])
try:
msg = json.loads(raw.strip())
except Exception as exc: # noqa: BLE001
check(" 内容是合法 JSON", False, str(exc))
msg = {}
check(" 内容是合法 JSON", bool(msg))
check(" cmd == update", msg.get("cmd") == "update", "拿到 %r" % msg)
check(" action == start", msg.get("action") == "start",
"拿到 %r" % msg)
n_before = len(on.to_agent)
res = r.request_update("on1")
check("重复点 -> 不重复塞", len(on.to_agent) == n_before,
"队列从 %d 涨到 %d" % (n_before, len(on.to_agent)))
check(" 但仍然返回 ok(用户看到的是「已经在排队」)",
res.get("ok") is True and "排队" in str(res.get("message")),
"拿到 %r" % res)
print()
print("二、HTTP 接口接到了 Relay 方法上")
src = (ROOT / "relay" / "relay.py").read_text(encoding="utf-8")
check("有 /v1/admin/update_device 路由",
'"/v1/admin/update_device"' in src)
m = re.search(r'if path == "/v1/admin/update_device":(.{0,300})',
src, re.S)
body = m.group(1) if m else ""
check(" 它调的是 RELAY.request_update", "RELAY.request_update(" in body,
"应该走 Relay 的方法,而不是在 HTTP 处理里伸手掏 RELAY.devices")
check(" 它把 deviceId 传下去了", 'get("deviceId")' in body)
check("有 Relay.request_update 方法", "def request_update(self" in src)
print()
print("三、管理页那边")
adm = (ROOT / "relay" / "admin.py").read_text(encoding="utf-8")
check("设备行有「更新」按钮", 'data-update="%s"' in adm)
check("JS 里有 data-update 的处理",
"querySelectorAll('button[data-update]')" in adm)
check("JS 会二次确认(别误点就重启别人电脑)",
"confirm(" in adm and "更新到最新版" in adm)
check("离线设备的按钮带 disabled",
'disabled title="设备离线' in adm)
check("设备行显示了版本", 'data-l="版本"' in adm)
check("表头也有版本列", "<th>版本</th>" in adm)
# 空表提示的 colspan 要跟列数对上(加了版本列之后是 9)
check("空表 colspan 是 9(跟列数对上)", 'colspan="9"' in adm,
"加列之后忘了改 colspan,空表时表格会错位")
print()
print("=" * 58)
if FAILS:
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
return 1
print(" 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -23,7 +23,7 @@ def _latest_apk() -> Path:
(ROOT / "dist").glob("*watch*.apk"),
key=lambda p: p.stat().st_mtime, reverse=True)
return candidates[0] if candidates else \
ROOT / "dist" / "ykxmz-watch-2.1.6.apk"
ROOT / "dist" / "ykxmz-watch-2.1.7.apk"
APK = _latest_apk()
+42 -1
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
import ctypes
import json
import re
import shutil
import socket
import subprocess
@@ -128,9 +129,21 @@ class Talk:
def seed(data_dir: Path) -> None:
"""准备好数据目录。
⚠ 这里**故意把 show_screen_notice 设成 True**。
为什么:这个测试验的是"提示条显示出来的时候位置和尺寸对不对",
而 2.1.7 起**默认是不显示的**(用户要求"像 UU 远程一样啥窗口都没有")。
开着开关跑,才能继续验到提示条本身还在正常工作 ——
否则这个能力哪天被改坏了也没人知道。
"默认不显示"这件事由下面的第六节单独验(不重启进程,直接读源码/默认值)。
"""
data_dir.mkdir(parents=True, exist_ok=True)
(data_dir / "settings.json").write_text(
json.dumps({"setup_done": True}, ensure_ascii=False), encoding="utf-8")
json.dumps({"setup_done": True, "show_screen_notice": True},
ensure_ascii=False), encoding="utf-8")
def main() -> int:
@@ -237,6 +250,34 @@ def main() -> int:
talk.call({"cmd": "screen_stop"}, timeout=15)
check("再次停止后消失", wait_notice(proc.pid, False))
# ------------------------------------------------------------------
print()
print("六、默认必须是**不显示**(用户 2.1.7 的要求)")
#
# 用户原话:"现在不是要会弹一个屏幕已被共享吗?以后不需要弹,
# 啥窗户都没有,就可以像 UU 远程一样"
#
# 这一节**不重启进程**(重启一次要十几秒),
# 直接查默认值 —— 被控端读取设置时,没写这一项就用默认值,
# 所以默认值是 False 就意味着"新装的机器不会弹提示条"。
sys.path.insert(0, str(ROOT / "windows"))
from lanremote.config import DEFAULT_SETTINGS
check("★ 默认 show_screen_notice == False",
DEFAULT_SETTINGS.get("show_screen_notice") is False,
"拿到 %r —— 不是 False 的话新装机还会弹提示条"
% DEFAULT_SETTINGS.get("show_screen_notice"))
# 而且设置关着的时候,代码路径上**连窗口都不该建**
headless_src = (ROOT / "windows" / "lanremote"
/ "headless.py").read_text(encoding="utf-8")
m = re.search(r"def set_screen_sharing\(self.*?(?=\n def )",
headless_src, re.S)
body = m.group(0) if m else ""
check("★ 关着时直接 return,不建窗口",
'store.get("show_screen_notice")' in body
and "return" in body,
"开关没接进 set_screen_sharing")
talk.close()
finally:
+22
View File
@@ -95,6 +95,23 @@ def check(name, ok, detail=""):
def defined_methods(text: str) -> list[str]:
"""找出主程序里定义的方法名。
⚠ **带 `@Override` 的跳过**。
为什么:这些是**父类/接口回调**,由框架调用,源码里当然只出现一次 ——
而这个扫描的判据正是"名字只出现 1 次 = 没人调"。
踩到的实例:手表端的 `GestureDetector.SimpleOnGestureListener`
里有 onSingleTapConfirmed / onDoubleTap / onScroll,
它们由 Android 的触摸框架回调,源码里各只出现一次。
不跳过的话扫描会把它们报成"未知的未调用方法"(假阳性)——
而这种假阳性最坏的结果是**有人为了让它变绿,把回调删了**,
于是手势就失灵了。
同类还会遇到的:View.OnClickListener.onClick、
DialogInterface.OnClickListener.onClick、Runnable.run 等。
"""
pattern = re.compile(
r"(?:private|public|protected|static|final|\s)+"
r"[\w<>\[\],.\s]+\s+(\w+)\s*\([^)]*\)\s*(?:throws [\w.,\s]+)?\{")
@@ -104,6 +121,11 @@ def defined_methods(text: str) -> list[str]:
if name in ("if", "for", "while", "switch", "catch", "synchronized",
"try", "return", "new"):
continue
# 往上看几行,如果有 @Override 就跳过
head = text[max(0, match.start() - 200):match.start()]
tail_lines = head.splitlines()[-3:]
if any("@Override" in ln for ln in tail_lines):
continue
names.append(name)
return names
+178
View File
@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
r'''验「弹窗位置/大小可配置」和「屏幕提示条默认关」。
== 用户的要求 ==
1. 弹文字窗口:位置和大小都能自定义(原来写死右下角)
2. 屏幕共享提示条:以后不要弹,像 UU 远程一样啥窗口都没有
== 验什么 ==
一、设置项都在,而且默认值符合要求
(位置默认右下角 = 不改变老用户习惯;
但 show_screen_notice 默认必须是 False)
二、_notice_geometry 对 6 种位置算出来的坐标对不对
三、三处写死的 geometry 都改成了调 helper(不能再有硬编码的右下角)
四、字号和宽度是可配置的
五、屏幕提示条真的会读那个开关
== 为什么第三层重要 ==
这次改了 3 处 geometry、5 处字号、2 处宽度。
如果漏掉一处,表现就是"有一个窗口还在右下角" ——
而用户只会看到"你改了没用"。所以必须断言**没有残留的硬编码**。
'''
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "windows"))
FAILS: list = []
def check(name: str, ok: bool, why: str = "") -> None:
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
(" —— " + why) if why else ""))
if not ok:
FAILS.append(name)
HEADLESS = ROOT / "windows" / "lanremote" / "headless.py"
CONFIG = ROOT / "windows" / "lanremote" / "config.py"
def main() -> int:
print("=" * 58)
print(" 弹窗位置/大小可配置 + 屏幕提示条默认关")
print("=" * 58)
print()
src = HEADLESS.read_text(encoding="utf-8")
cfg_src = CONFIG.read_text(encoding="utf-8")
# ---------------- 一、设置项 ----------------
print("一、设置项")
from lanremote.config import DEFAULT_SETTINGS as D
for key in ("notice_position", "notice_x", "notice_y",
"notice_width", "notice_font_size", "show_screen_notice"):
check(" %s 存在" % key, key in D)
check("位置默认右下角(不改变老用户习惯)",
D.get("notice_position") == "bottom-right",
"拿到 %r" % D.get("notice_position"))
check("**屏幕提示条默认关**(用户明确要求)",
D.get("show_screen_notice") is False,
"拿到 %r —— 这个默认必须是 False" % D.get("show_screen_notice"))
check("字号默认 11(原来的值)", D.get("notice_font_size") == 11)
check("宽度默认 0(自适应)", D.get("notice_width") == 0)
# ---------------- 二、_notice_geometry 算得对不对 ----------------
print()
print("二、_notice_geometry 的坐标计算")
# 直接复刻逻辑来验(不启动 Tk)—— 和源码里的算法必须一致
def geom(pos, w, h, screen=(1920, 1080), cx=0, cy=0):
screen_w, screen_h = screen
pad, pad_bottom = 24, 72
if pos == "custom":
x = max(0, min(max(0, screen_w - w), cx))
y = max(0, min(max(0, screen_h - h), cy))
return (x, y)
if pos == "center":
return (max(0, (screen_w - w) // 2), max(0, (screen_h - h) // 2))
left = pos in ("top-left", "bottom-left")
top = pos in ("top-left", "top-right")
x = pad if left else screen_w - w - pad
y = pad if top else screen_h - h - pad_bottom
return (max(0, x), max(0, y))
cases = [
("bottom-right", (1920 - 300 - 24, 1080 - 100 - 72), "右下角"),
("bottom-left", (24, 1080 - 100 - 72), "左下角"),
("top-right", (1920 - 300 - 24, 24), "右上角"),
("top-left", (24, 24), "左上角"),
("center", ((1920 - 300) // 2, (1080 - 100) // 2), "屏幕正中间"),
]
for pos, want, label in cases:
got = geom(pos, 300, 100)
check(" %s(%s)" % (pos, label), got == want,
"期望 %s,拿到 %s" % (want, got))
# custom:正常值
check(" custom 用指定坐标", geom("custom", 300, 100, cx=500, cy=400)
== (500, 400))
# custom:超范围要夹回来
check(" custom 超右边界被夹住",
geom("custom", 300, 100, cx=99999, cy=0)
== (1920 - 300, 0),
"拿到 %s" % (geom("custom", 300, 100, cx=99999, cy=0),))
check(" custom 负坐标被夹到 0",
geom("custom", 300, 100, cx=-50, cy=-50) == (0, 0))
# ---------------- 三、没有残留的硬编码定位 ----------------
print()
print("三、源码里不能有残留的硬编码(漏一处用户就看得见)")
check("没有写死的右下角 geometry",
"screen_h - height - 72" not in src,
"还有窗口固定放右下角")
check("没有写死的字号 11",
'"Microsoft YaHei UI", 11, "bold"' not in src,
"还有窗口用写死的字号")
# ⚠ 这里要排除 helper 自己那处 —— _notice_width 里
# uto = max(240, win.winfo_reqwidth()) 是**自适应的兜底值**,
# 属于故意保留的。第一版断言太粗,把这处也算成写死了。
hard = [ln for ln in src.splitlines()
if "max(240, win.winfo_reqwidth())" in ln
and "auto =" not in ln]
check("没有别处写死的宽度 240", not hard,
"还有窗口用写死的宽度:%s" % hard)
n_geom = src.count("self._notice_geometry(width, height)")
check("三处定位都改调 helper(实际 %d 处)" % n_geom, n_geom == 3,
"应该是 3 处(通知×2 + 屏幕提示×1)")
check("有 _notice_geometry 方法", "def _notice_geometry(self" in src)
check("有 _notice_font 方法", "def _notice_font(self" in src)
check("有 _notice_width 方法", "def _notice_width(self" in src)
# ---------------- 四、屏幕提示条读开关 ----------------
print()
print("四、屏幕提示条")
m = re.search(r"def set_screen_sharing\(self.*?(?=\n def )", src, re.S)
body = m.group(0) if m else ""
check("找到了 set_screen_sharing", bool(body))
check("它读了 show_screen_notice",
'store.get("show_screen_notice")' in body,
"没读开关的话提示条还是会强制弹出来")
# 读开关的判断必须在 _show_notice 之前
i_check = body.find('store.get("show_screen_notice")')
i_show = body.find("_show_notice(")
check("判断在弹窗之前(关着就不会弹)",
0 <= i_check < i_show,
"顺序反了的话会先弹再判断")
# ---------------- 五、设置界面里能不能改 ----------------
print()
print("五、设置界面")
ui_src = (ROOT / "windows" / "lanremote" / "ui.py").read_text(
encoding="utf-8")
has_any = any(k in ui_src for k in
("notice_position", "notice_width", "notice_font_size",
"show_screen_notice"))
check("设置界面里有这些项", has_any,
"配置项加了但界面没入口的话,用户根本改不了")
print()
print("=" * 58)
if FAILS:
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
return 1
print(" 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
+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.1.6.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.6.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.6.apk"
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.7.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.7.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.7.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.1.6-说明.txt")):
ROOT / "dist" / "ykxmz-watch-2.1.7-说明.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])
+193
View File
@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
r'''验「手表快捷栏」和「允许新设备连接」。
== 验什么 ==
一、手表端源码
* 快捷栏建出来了,4 个格子(3 自定义 + 1 固定显示桌面)
* 固定那个**不给改**(用户明确要求"其中有一个按钮不能改")
* 挂进了 onCreate(光有方法不 addView 是白搭 —— 项目里踩过)
* 加进了 showOnly 的数组(漏了的话它会永远压在最上面)
* 7 个动作都在
* 设置页里有入口(没入口用户改不了)
* 鼠标触摸板在
二、电脑端 allow_new_pairing 这个 feature
* 能查、能关、能开
* 存储里真的变了
* 只影响新配对,不踢现有会话(注释里写了理由)
== 为什么专门验"固定那个不给改" ==
用户原话:"最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。
如果哪天有人"顺手"把它也做成可配置的,用户点了半天发现
"显示桌面"没了 —— 而这种回归不会报错,只会让人困惑。
所以这里断言:第 4 格永远是 desktop,且 QUICK_ACTIONS 里**没有**它。
'''
from __future__ import annotations
import json
import re
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "windows"))
FAILS: list = []
def check(name: str, ok: bool, why: str = "") -> None:
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
(" —— " + why) if why else ""))
if not ok:
FAILS.append(name)
def main() -> int:
print("=" * 58)
print(" 手表快捷栏 + 允许新设备连接")
print("=" * 58)
print()
main_java = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
/ "MainActivity.java").read_text(encoding="utf-8")
# ---------------- 一、快捷栏 ----------------
print("一、快捷栏(手表端)")
for token, label in [
("private void buildQuickBar()", "有 buildQuickBar"),
("private void refreshQuickBar()", "有 refreshQuickBar"),
("private void onQuickClick(int slot)", "有点击分发 onQuickClick"),
("private void runQuickAction(String id)", "有动作执行 runQuickAction"),
("private void loadQuickSlots()", "有读取设置"),
("private void saveQuickSlots()", "有保存设置"),
]:
check(label, token in main_java)
# 4 个格子
check("格子数 = 3 自定义 + 1 固定",
"QUICK_CUSTOM_SLOTS + 1" in main_java
and "QUICK_CUSTOM_SLOTS = 3" in main_java)
# 固定那个
m = re.search(r'String id = \(slot < QUICK_CUSTOM_SLOTS\) \? quickSlots\[slot\]'
r' : "desktop"', main_java)
check("★ 第 4 格固定是 desktop(不给改)", m is not None,
"第 4 格不是写死的 desktop —— 用户要求它不能改")
check("★ QUICK_ACTIONS 里没有 desktop(否则它会出现在可选列表里)",
not re.search(r'\{"desktop"', main_java),
"desktop 出现在可选动作里了,用户就能把它改掉")
check("desktop 走的是 show_desktop 命令",
'"cmd", "show_desktop"' in main_java,
"固定按钮应该让电脑 Win+D")
# 7 个动作
print()
print(" 7 个可选动作:")
for aid, label in [("file", "传文件"), ("mirror", "屏幕镜像"),
("mouse", "鼠标控制"), ("sfx", "音效"),
("power", "关机"), ("tts", "语音播报"),
("image", "投图到电脑")]:
ok = ('{"%s", "%s"}' % (aid, label)) in main_java
check(" %s(%s)" % (aid, label), ok)
# 挂接
print()
print("二、挂接(漏了就白写)")
oncreate = main_java[main_java.find("protected void onCreate"):
main_java.find("protected void onCreate") + 6000]
check("★ onCreate 里调了 buildQuickBar()",
"buildQuickBar();" in oncreate,
"没挂上去的话栏根本不显示")
check("★ onCreate 里调了 loadQuickSlots()",
"loadQuickSlots();" in oncreate)
check("★ 快捷栏**最后** addView(保证在最上层)",
oncreate.find("root.addView(quickBar") > oncreate.find("root.addView(mousePanel"),
"加在面板之前会被面板整个盖住")
check("★ showOnly 的数组里有 mousePanel",
"settingsPanel, mousePanel};" in main_java,
"漏了的话鼠标面板永远不隐藏")
check("有 applyPanelTopMargin(面板给栏让位)",
"private void applyPanelTopMargin" in main_java)
# 设置页入口
print()
print("三、设置页入口(没入口用户改不了)")
check("有 fillSettingsExtra", "private void fillSettingsExtra()" in main_java)
check("有 pickQuickSlot(选动作)",
"private void pickQuickSlot(final int slot)" in main_java)
check("设置页里加了「快捷栏」小节",
'sectionTitle("快捷栏")' in main_java)
check("设置页里加了「允许新设备连接」小节",
'sectionTitle("允许新设备连接")' in main_java)
check("界面上说明了第 4 格改不了",
"固定的、改不了" in main_java,
"不说明的话用户会一直找怎么改第四个")
# 鼠标触摸板
print()
print("四、鼠标触摸板")
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)
check("拖动 -> moveby", '"moveby"' in main_java)
check("isConnected() 存在(我第一版漏了,编译才发现)",
"private boolean isConnected()" in main_java)
# ---------------- 五、电脑端 allow_new_pairing ----------------
print()
print("五、电脑端 allow_new_pairing feature")
from lanremote.config import Store
from lanremote.features import Features
class FakeActions:
"""Features 要的是 actions 对象,不是 store(第一版就是这里传错了)。"""
def __init__(self, store):
self.store = store
self.ui = None
d = Path(tempfile.mkdtemp())
st = Store(d)
feats = Features(FakeActions(st))
check("能查到当前状态",
"允许" in feats.run("allow_new_pairing", {}))
out = feats.run("allow_new_pairing", {"allow": False})
check("能关掉", "禁止" in out, "拿到 %r" % out)
check(" ★ 存储里真的变了",
st.get("allow_new_pairing") is False,
"拿到 %r" % st.get("allow_new_pairing"))
check(" 关掉之后再查也是关",
"不允许" in feats.run("allow_new_pairing", {}))
out = feats.run("allow_new_pairing", {"allow": True})
check("能打开", "已允许" in out, "拿到 %r" % out)
check(" 存储里也变了", st.get("allow_new_pairing") is True)
# 不存在名字要报错(别静默成功)
try:
feats.run("allow_new_pairing_typo", {})
check("不认识的名字会报错", False, "它竟然没报错")
except ValueError:
check("不认识的名字会报错", True)
print()
print("=" * 58)
if FAILS:
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
return 1
print(" 全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())
+39 -10
View File
@@ -430,7 +430,7 @@ $stageOut = & python (Join-Path $root "_tests\test_lan_path.py") 2>&1
Record "局域网直连路径" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a2
Banner "3a2/7 局域网优化(网段掩码 / 发现重发 / 回切)"
Banner "3a2/9 局域网优化(网段掩码 / 发现重发 / 回切)"
# 盯四件事:
# 1. 网段判断按网卡真实掩码,不再写死 /24(/16 网络下能直连却去走中转)
# 2. 广播发现重发 + 多网段(原来只发一次、只探一个网段)
@@ -441,7 +441,7 @@ $stageOut = & python (Join-Path $root "_tests\test_lan_optimize.py") 2>&1
Record "局域网优化" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a3
Banner "3a3/7 手表局域网切换逻辑(源码断言)"
Banner "3a3/9 手表局域网切换逻辑(源码断言)"
$stageOut = & python (Join-Path $root "_tests\test_watch_lan_switch.py") 2>&1
Show-Fails $stageOut
Record "手表局域网切换" ($LASTEXITCODE -eq 0)
@@ -459,7 +459,7 @@ $stageOut = & python (Join-Path $root "_tests\test_rescue_section.py") 2>&1
Record "抢救报告分区" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a4
Banner "3a4/7 编码与换行(.java/.py 必须 UTF-8 无 BOM + LF)"
Banner "3a4/9 编码与换行(.java/.py 必须 UTF-8 无 BOM + LF)"
# 踩过的坑:edit 工具会把行尾写成 CRLF,而仓库约定是 LF。
# 这个检查专门盯住,免得改动悄悄把整个文件的行尾换掉。
$stageOut = & python (Join-Path $root "_tests\check_encoding.py") 2>&1
@@ -467,7 +467,7 @@ $stageOut = & python (Join-Path $root "_tests\check_encoding.py") 2>&1
Record "编码与换行" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a7
Banner "3a7/7 Python 语法(必须用 compile,不能用 ast.parse)"
Banner "3a7/9 Python 语法(必须用 compile,不能用 ast.parse)"
# 这一轮被同一件事咬了三次:补丁脚本把 import 插到 from __future__
# 前面,整个文件就废了。而更糟的是 ast.parse 查不出来(它只解析,
# 不做编译期检查)—— 前几次「全仓语法检查 0 问题」都是假绿。
@@ -476,13 +476,13 @@ $stageOut = & python (Join-Path $root "_tests\check_python_syntax.py") 2>&1
Record "Python 语法检查" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a8
Banner "3a8/7 版本号一致性(防漏改)"
Banner "3a8/9 版本号一致性(防漏改)"
$stageOut = & python (Join-Path $root "_tests\check_version_consistency.py") 2>&1
Show-Fails $stageOut
Record "版本号一致性" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a9
Banner "3a9/7 Windows 7 兼容(学校电脑是 Win7)"
Banner "3a9/9 Windows 7 兼容(学校电脑是 Win7)"
# 用户报:学校电脑是 Windows 7,装完报"dll 缺失"。
#
# 根因不是漏打包,是 **Python 版本越界**:
@@ -503,7 +503,7 @@ $stageOut = & python (Join-Path $root "_build\check_win7_compat.py") 2>&1
Record "Windows 7 兼容" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a10
Banner "3a10/7 静默自动更新链路"
Banner "3a10/9 静默自动更新链路"
# 用户要求:"手表端点击一个更新按钮,电脑端就可以自动进行静默更新,
# 其他什么设置都不变 …… 不弹任何窗口出来"
# 这条链是:中转 /v1/update/latest -> 被控端 updater.py 下载校验
@@ -516,7 +516,7 @@ $stageOut = & python (Join-Path $root "_tests\test_update_flow.py") 2>&1
Record "静默自动更新" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a11
Banner "3a11/7 远程鼠标 + 一键显示桌面"
Banner "3a11/9 远程鼠标 + 一键显示桌面"
# 鼠标注入是最"有力量"的功能,参数校验写错的话表现是"手表点了没反应",
# 教室里没法排查。这里盯坐标归一化(除以 宽-1 那条规则)、
# 滚轮夹取、Win+D 的事件顺序、以及非法参数必须被拒。
@@ -525,8 +525,37 @@ $stageOut = & python (Join-Path $root "_tests\test_mouse_control.py") 2>&1
Record "远程鼠标控制" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a12
Banner "3a12/9 后台更新按钮 + 弹窗位置/大小"
# 用户要求:后台能点一下让电脑自己更新;弹文字窗口位置和大小可自定义。
# 后台那条链路横跨三端(JS -> 中转 -> 被控端),错了的表现是
# "我点了更新,电脑没反应",所以几个边界必须卡死:
# 设备不存在 / 离线 / 重复点 / 在线。
$stageOut = & python (Join-Path $root "_tests\test_admin_update.py") 2>&1
Show-Fails $stageOut
Record "后台更新按钮" ($LASTEXITCODE -eq 0)
$stageOut = & python (Join-Path $root "_tests\test_notice_options.py") 2>&1
Show-Fails $stageOut
Record "弹窗位置和大小" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a13
Banner "3a13/9 手表快捷栏 + 允许新设备连接"
# 用户要求:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
# 自定义,最多可以加 4 个,其中有一个按钮不能改,
# 功能就是一键返回桌面"。
#
# 四个地方最容易漏,漏了都不会报错、只会让人困惑:
# 1. buildQuickBar 没挂进 onCreate -> 栏根本不显示
# 2. 没加进 showOnly 的数组 -> 面板永远压在上面
# 3. 第 4 格做成了可配置 -> 用户找半天"显示桌面"没了
# 4. 设置页没有入口 -> 说是能自定义其实改不了
$stageOut = & python (Join-Path $root "_tests\test_quickbar.py") 2>&1
Show-Fails $stageOut
Record "手表快捷栏" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a5
Banner "3a5/7 局域网/中转对比基准(防测试自己骗人)"
Banner "3a5/9 局域网/中转对比基准(防测试自己骗人)"
# 这个基准一度是红的,而且报告里写着「这是中转链路的产品 bug」——
# 实际是基准自己有两个 bug:两条路用了同一个配对码、
# 以及 attach 用了旧码(打到已经死掉的设备上)。
@@ -537,7 +566,7 @@ $stageOut = & python (Join-Path $root "_tests\test_lan_bench_guard.py") 2>&1
Record "局域网对比基准" ($LASTEXITCODE -eq 0)
# ---------------------------------------------------------------- 3a6
Banner "3a6/7 管理页 JS 语法(防按钮全死)"
Banner "3a6/9 管理页 JS 语法(防按钮全死)"
# 踩过两次的坑:admin.py 的 ADMIN_JS 是普通三引号字符串,
# 里面写 \n 会被 Python 变成真实换行;而 JS 的行注释 // 不能跨行,
# 一断行整段 <script> 语法错误 -> 页面上所有按钮失效。