mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 13:40:45 +00:00
用户这次提了一整套升级,这是最后一批。
== 一、手表端顶部快捷栏 ==
用户原话:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
自定义,最多可以加 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 文件语法通过
310 lines
12 KiB
Python
310 lines
12 KiB
Python
# -*- 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())
|