Files
ykxmz/_build/wire_admin_update.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

144 lines
5.7 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 -*-
r'''后台「更新」按钮接上:点一下让那台电脑自己静默升级。
== 链路 ==
管理页点「更新」
-> POST /v1/admin/update_device {deviceId}
-> 中转把 {"cmd":"update","action":"start"} 塞进该设备的 to_agent 队列
-> 被控端下一次长轮询拿到它
-> 走 server.py 的 _do_update:查最新版 -> 下载 -> 校验 -> 静默装 -> 重启
也就是说:**后台和手表用的是同一条命令**,只是入口不同。
这样不用给"后台触发"单独写一套逻辑(少一套就少一处不一致)。
== 为什么只对在线设备开放 ==
被控端是靠**长轮询**取命令的。设备离线时命令只会挂在队列里,
等它什么时候上线才执行 —— 而那时用户可能早就忘了自己点过,
突然重启会莫名其妙。
所以离线设备直接拒绝,并在按钮上就置灰(admin.py 里已经这么做了)。
这样"点了没反应"变成"按钮本来就是灰的",用户知道为什么。
== 队列上限 ==
中转的队列有字节上限(MAX_QUEUE_BYTES),超了会丢最旧的。
一条更新命令才几十字节,不会撑爆 —— 但**重复点会堆积**,
所以这里先查一下队列里是不是已经有更新命令了,有就不再塞。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
RELAY = ROOT / "relay" / "relay.py"
ADMIN = ROOT / "relay" / "admin.py"
RELAY_ANCHOR = ''' self._json(404, {"ok": False, "error": "没有这个管理接口"})
return'''
RELAY_ADD = ''' # ---- 让某台电脑自己静默升级 ----
#
# 和手表那边用的是**同一条命令**(cmd=update, action=start),
# 只是入口不同 —— 不用给"后台触发"单独写一套逻辑。
if path == "/v1/admin/update_device":
body = self._json_body()
did = str(body.get("deviceId") or "")
device = RELAY.device_by_id(did)
if device is None:
self._json(404, {"ok": False,
"error": "找不到这台设备"})
return
if not device.online():
# 离线时命令只会挂在队列里,等它上线才执行 ——
# 那时用户早忘了,突然重启很莫名其妙。所以直接拒。
self._json(400, {"ok": False,
"error": "设备离线,连不上它"})
return
# 别重复塞:队列里已经有更新命令就跳过
payload = b'{"cmd":"update","action":"start"}\\n'
if payload in bytes(device.to_agent):
self._json(200, {"ok": True,
"message": "这台电脑的更新已经在排队了"})
return
try:
device.push_to_agent(payload)
except Exception as exc: # noqa: BLE001
self._json(500, {"ok": False,
"error": "下发失败:%s" % exc})
return
self._json(200, {
"ok": True,
"message": "已让这台电脑开始更新,它会自己下载、"
"静默安装并重启,设置和配对码不变",
})
return
''' + RELAY_ANCHOR
JS_ANCHOR = """document.querySelectorAll('button.danger[data-device]')"""
JS_ADD = '''// ---- 「更新」按钮:让那台电脑自己静默升级 ----
//
// 和手表那边走的是同一条命令,只是入口不同。
// 离线设备的按钮在 HTML 里就带 disabled 了,所以这里不用再判。
document.querySelectorAll('button[data-update]').forEach((btn) => {
btn.onclick = async () => {
const name = btn.dataset.name || '这台电脑';
if (!confirm('让「' + name + '」现在更新到最新版?\\n\\n'
+ '它会自己下载、静默安装并重启,全程不弹窗口,\\n'
+ '中转地址、配对码和各项设置都不变。')) {
return;
}
btn.disabled = true;
const old = btn.textContent;
btn.textContent = '下发中…';
try {
const r = await post('/v1/admin/update_device',
{ deviceId: btn.dataset.update });
toast((r && r.message) || '已下发更新命令', 'ok');
// 更新要跑一阵子,不立刻刷新列表(刷新了也看不出变化)
} catch (e) {
toast('下发失败:' + (e && e.message ? e.message : e), 'err');
} finally {
btn.disabled = false;
btn.textContent = old;
}
};
});
''' + JS_ANCHOR
def patch(path: Path, old: str, new: str, what: str) -> bool:
raw = path.read_bytes()
bom = raw.startswith(b"\xef\xbb\xbf")
text = raw.decode("utf-8-sig")
if new.split("\n")[0] in text or "update_device" in text:
print(" %s 已经加过了" % what)
return True
if old not in text:
print(" ✗ %s 找不到锚点" % what)
return False
text = text.replace(old, new, 1)
path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
print(" ✓ %s" % what)
return True
def main() -> int:
print("=" * 58)
print(" 后台「更新」按钮接通")
print("=" * 58)
print()
ok1 = patch(RELAY, RELAY_ANCHOR, RELAY_ADD,
"relay.py 加了 /v1/admin/update_device")
ok2 = patch(ADMIN, JS_ANCHOR, JS_ADD, "admin.py 加了 data-update 的 JS 处理")
return 0 if (ok1 and ok2) else 1
if __name__ == "__main__":
sys.exit(main())