Files
ykxmz/_tests/test_indicator_visible.py
T
xmz7891 f0c05079f7 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 文件语法通过
2026-10-09 22:55:28 +08:00

310 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""验证「屏幕共享时被控端一定显示可见提示」这条安全属性。
界面重做之后:主控制窗口已经删掉了,被控端默认静默运行。
所以这个测试改成:
用 --add-device 启动(静默 + 临时开放配对)
→ 配对
→ screen_start
→ 用 EnumWindows 枚举被控端进程的窗口,**必须**出现那条红色提示
→ 检查它在右下角
→ screen_stop
→ 提示必须消失
注意:静默模式**不显示**主窗口,但屏幕共享提示**保留** —— 这是故意的:
有人在看你的屏幕时,屏幕前面的人应该知道。
"""
from __future__ import annotations
import ctypes
import json
import re
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "windows"))
AGENT = ROOT / "windows" / "run_agent.py"
DATA = ROOT / "windows" / "data"
PORT = 8893
PASSED = 0
FAILED = 0
NOTICE_TITLE = "屏幕正在被共享"
def check(name, ok, detail=""):
global PASSED, FAILED
if ok:
PASSED += 1
print(f" [PASS] {name}")
else:
FAILED += 1
print(f" [FAIL] {name} {detail}")
def windows_of(pid: int) -> list:
user32 = ctypes.windll.user32
found: list = []
EnumProc = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p,
ctypes.c_void_p)
def callback(hwnd, _lparam):
owner = ctypes.c_ulong()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(owner))
if owner.value == pid:
length = user32.GetWindowTextLengthW(hwnd)
buffer = ctypes.create_unicode_buffer(max(2, length + 1))
user32.GetWindowTextW(hwnd, buffer, length + 1)
rect = ctypes.wintypes.RECT() if hasattr(ctypes, "wintypes") else None
left = top = width = height = 0
try:
box = ctypes.create_string_buffer(16)
user32.GetWindowRect(hwnd, box)
values = ctypes.cast(box, ctypes.POINTER(ctypes.c_long * 4)).contents
left, top, right, bottom = values
width, height = right - left, bottom - top
except Exception: # noqa: BLE001
pass
found.append({
"title": buffer.value,
"visible": bool(user32.IsWindowVisible(hwnd)),
"rect": (left, top, width, height),
})
return True
user32.EnumWindows(EnumProc(callback), 0)
return found
def notice_windows(pid: int) -> list:
return [w for w in windows_of(pid)
if NOTICE_TITLE in (w["title"] or "") and w["visible"]]
def wait_notice(pid: int, want: bool, timeout: float = 8.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
found = notice_windows(pid)
if bool(found) == want:
return True
time.sleep(0.3)
return False
class Talk:
def __init__(self, port, timeout=10.0):
self.sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
self.sock.settimeout(timeout)
self.buf = b""
def call(self, obj, timeout=12.0):
self.sock.sendall((json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8"))
deadline = time.time() + timeout
while True:
while b"\n" not in self.buf:
if time.time() > deadline:
raise TimeoutError("超时")
chunk = self.sock.recv(65536)
if not chunk:
raise ConnectionError("断开")
self.buf += chunk
line, _, self.buf = self.buf.partition(b"\n")
message = json.loads(line.decode("utf-8"))
if "event" in message: # 主动推送的事件不是回复
continue
return message
def close(self):
try:
self.sock.close()
except OSError:
pass
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, "show_screen_notice": True},
ensure_ascii=False), encoding="utf-8")
def main() -> int:
backup = None
if DATA.exists():
backup = DATA.parent / f"data_bk_ind_{int(time.time())}"
shutil.move(str(DATA), str(backup))
proc = None
try:
seed(DATA)
print("启动被控端(静默 + 临时开放配对)…")
proc = subprocess.Popen(
[sys.executable, str(AGENT), "--port", str(PORT),
"--new-code", "--add-device", "10"],
cwd=str(ROOT / "windows"),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# 先等端口真的在监听 —— 只看 settings.json 会误判:
# 那个文件在服务端启动之前就写好了,
# 而且如果实例锁被别的进程占着,被控端会直接退出。
settings = DATA / "settings.json"
listening = False
deadline = time.time() + 30
while time.time() < deadline:
if proc.poll() is not None:
print(f" 被控端启动后退出了(退出码 {proc.returncode})")
log = DATA / "silent.log"
if log.is_file():
print(" silent.log:")
for line in log.read_text(encoding="utf-8",
errors="replace").splitlines()[-6:]:
print(f" {line}")
return 1
try:
probe = socket.create_connection(("127.0.0.1", PORT), timeout=1)
probe.close()
listening = True
break
except OSError:
time.sleep(0.4)
check("被控端在监听", listening)
if not listening:
return 1
code = ""
try:
code = json.loads(settings.read_text(
encoding="utf-8")).get("pairing_code", "")
except Exception: # noqa: BLE001
code = ""
check("生成配对码", bool(code), code)
if not code:
return 1
# 静默模式:不该有任何窗口
time.sleep(1.5)
print()
print("一、静默模式本身不该有窗口")
visible = [w for w in windows_of(proc.pid) if w["visible"]]
check("启动后没有可见窗口", not visible, str(visible[:3]))
print()
print("二、配对")
talk = Talk(PORT)
reply = talk.call({"cmd": "pair", "deviceId": "ind-test",
"deviceName": "提示条测试", "code": code})
check("配对成功", reply.get("ok") is True, str(reply)[:120])
print()
print("三、开始共享 → 必须出现提示")
check("共享前没有提示条", not notice_windows(proc.pid))
reply = talk.call({"cmd": "screen_start", "maxWidth": 320,
"quality": 40, "fps": 2}, timeout=15)
check("开始屏幕共享", reply.get("ok") is True, str(reply)[:120])
appeared = wait_notice(proc.pid, True)
check("★ 共享中出现了红色提示条", appeared,
str([w["title"] for w in windows_of(proc.pid) if w["visible"]]))
if appeared:
found = notice_windows(proc.pid)
left, top, width, height = found[0]["rect"]
user32 = ctypes.windll.user32
screen_w = user32.GetSystemMetrics(0)
screen_h = user32.GetSystemMetrics(1)
print(f" 提示条位置: ({left},{top}) {width}x{height},"
f"屏幕 {screen_w}x{screen_h}")
check("提示条在右下角",
left > screen_w * 0.5 and top > screen_h * 0.5,
f"({left},{top})")
check("提示条可见尺寸合理", width > 40 and height > 10,
f"{width}x{height}")
print()
print("四、停止共享 → 提示必须消失")
reply = talk.call({"cmd": "screen_stop"}, timeout=15)
check("停止屏幕共享", reply.get("ok") is True, str(reply)[:120])
check("★ 停止后提示条消失", wait_notice(proc.pid, False))
print()
print("五、再次共享 → 提示再次出现")
talk.call({"cmd": "screen_start", "maxWidth": 320,
"quality": 40, "fps": 2}, timeout=15)
check("★ 提示条再次出现", wait_notice(proc.pid, True))
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:
if proc is not None:
try:
proc.terminate()
proc.wait(timeout=8)
except Exception: # noqa: BLE001
try:
proc.kill()
except Exception: # noqa: BLE001
pass
time.sleep(1.0)
try:
if DATA.exists():
shutil.rmtree(DATA, ignore_errors=True)
if backup is not None and backup.exists():
shutil.move(str(backup), str(DATA))
except Exception as exc: # noqa: BLE001
print(f" !! 还原失败: {exc}")
print()
print("=" * 68)
print(f"通过 {PASSED} 项,失败 {FAILED} 项")
return 1 if FAILED else 0
if __name__ == "__main__":
sys.exit(main())