Files
xmz7891 4d97e40280 2.1.6:静默自动更新 + 远程鼠标控制 + 一键显示桌面
用户这次提了一整套升级。这个提交先落地三块(其余几块在做)。

== 一、静默自动更新 ==

用户原话:"手表端点击一个更新按钮,电脑端就可以自动进行静默更新,
其他什么设置都不变 …… 不弹任何窗口出来"。

链路:

    手表点按钮 -> 被控端问中转"最新是哪版"
               -> 下载到 %TEMP%\ykxmz-update\
               -> 校验 sha256
               -> 起一个**分离的 .cmd 帮手**
               -> 自己退出
               -> 帮手等它退出 -> 静默跑安装器 -> 重新拉起

关键设计说明:

  * **为什么要有那个 .cmd 帮手**:Windows 上正在运行的 exe 是锁着的,
    安装器覆盖不了,所以必须"先退出、再装、再起来",而这三步
    不能由被控端自己做(它一退出就没人执行后面的了)。
    用 DETACHED_PROCESS 起的助手不受父进程退出影响。

  * **配置为什么不会丢**:配置在 %ProgramData%\ykxmz\data\,
    **不在程序目录里**。安装器升级只替换 {app} 下的文件,
    不碰 ProgramData —— 中转地址、配对码、各种开关原样保留。
    这是这个功能能做的前提。

  * **更新源是用户自己的中转站**,不是作者的服务器,也不是任何
    写死的公网地址。被控端问的是它自己配置的那个地址。
    中转没有这个接口(老版本服务端)时会明确提示"先手动升级一次服务端"。

  * **sha256 必须校验**:自更新最大的风险是下到一半断了或被换了,
    装上去是个坏程序、而且它还会自己再更新 —— 螺旋下去很难救。
    对不上就**放弃这一轮**,保持现状(现状至少能用)。

中转新增:GET /v1/update/latest?platform=windows
返回 version / name / url / size / sha256。版本比较按**数字段**
(不能按字符串,否则 "1.9.0" > "2.1.4")。sha256 按
(文件名,大小,mtime) 缓存,避免每次重算 9MB。

手表端:"电脑端版本:点这里查看" 按钮,查完显示
"电脑端 2.1.4 → 2.1.5(点这里更新)",确认后才真的更新。

== 二、远程鼠标控制 ==

新增 windows/lanremote/input.py,用 SendInput 模拟鼠标键盘。

  * **为什么用 SendInput 而不是 mouse_event**:后者是 Win95 时代的
    API,会被 UIPI 拦,而且没有 dwExtraInfo(多个注入源会互相干扰)。
    SendInput 还能一次打包多个事件,按下+抬起之间不会插进别的输入。

  * **坐标是 0..65535 归一化**,而且**必须除以 (屏幕宽-1)**。
    少了那个 -1,最右边和最下边几像素点不到 —— 表现为
    "鼠标到不了屏幕边缘",任务栏在底下的机器上尤其明显。

  * **INPUT 结构在 32/64 位下大小不同**(28 / 40),
    ctypes 里按进程位数算,不能写死。

  * 权限说明:目标窗口如果是管理员权限的(UAC 提升过的),
    普通权限的注入会被 UIPI 拦掉。这不是 bug,是 Windows 的设计。
    被控端以管理员运行就都能点。

== 三、一键显示桌面 ==

用户要求快捷栏里那个"不能改"的按钮就是这个功能,等于 Win+D。
和鼠标控制共用 input.py。

== 顺手修的一个自己造的坑 ==

往 server.py 插方法时,**两次**把 dispatch 的兜底
`return {"ok": False, "error": f"未知命令: {cmd}"}` 一起卷进了新方法里:

  * pyflakes 报 "undefined name 'cmd'"
  * 而且 dispatch 丢了兜底分支(未知命令会返回 None)

第一次靠 pyflakes 抓到,第二次也是。为了以后不再犯,
`_tests/test_update_flow.py` 里加了**结构断言**:
兜底必须在带 CMD_UPDATE 的那个方法体里,而各新方法里不能出现它。

(那条断言第一版写得太死 —— 要求"紧跟 CMD_UPDATE 分支",
 后来往 dispatch 里插了新分支就误报了。改成验结构真相。)

== 验证 ==

    全量回归(含新增的 3a10 / 3a11 两关)全部 PASS
    静默自动更新:协议 + updater 15 项 + 服务端结构 8 项
    远程鼠标:坐标归一化 / 滚轮夹取 / Win+D 事件顺序 / 非法参数 22 项
    610 个 Python 文件语法通过
    实际注入鼠标成功(move / click / scroll / show_desktop)

== 还没做的(下一批) ==

    手表顶部快捷栏(可自定义最多 4 个,1 个固定"显示桌面")
    屏幕镜像去掉提示条、去掉所有弹窗
    托盘图标改成设置项
    「允许新设备连接」开关(中转后台 + 安卓端)
    弹文字窗口位置/大小可自定义
    中转后台设备列表显示版本 + 远程点更新

== 顺手清理 ==

_tests/out_app/ 下面那堆 .class 是 Java 编译检查的产物,
以前误提交进仓库了(152 个文件)。这次从索引里移除并加进 .gitignore。
2026-10-09 22:12:09 +08:00

285 lines
9.0 KiB
Python
Raw Permalink 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'''被控端的**输入注入**:模拟鼠标和键盘。
== 用来干什么 ==
1. 远程鼠标控制:移动 / 左键 / 右键 / 中键 / 滚轮
2. 「一键显示桌面」:Win+D
== 为什么用 SendInput 而不是 mouse_event ==
`mouse_event` / `keybd_event` 是 Win95 时代的旧 API,
微软文档明确说"新程序请用 SendInput":
* 旧的会被 UIPI 拦(不同完整性级别的窗口之间)
* 旧的没有 `dwExtraInfo`,多个注入源会互相干扰
* SendInput 一次可以打包多个事件,**原子性好** ——
比如"按下+抬起"中间不会插进别的输入
== 坐标怎么算 ==
SendInput 的绝对坐标是 **0..65535 归一化**的,不是像素。
而且它是**按整个虚拟屏幕**(多显示器算成一个大方块)算的。
所以:
归一化 x = 像素 x * 65535 / (虚拟屏宽 - 1)
归一化 y = 像素 y * 65535 / (虚拟屏高 - 1)
⚠ 那个 `-1` 不能省。微软文档写得很清楚:0 和 65535 分别对应
屏幕最左/最右**像素**,所以要除以 `宽-1`。少了这个 -1,
最右边和最下边几像素点不到 —— 表现为"鼠标到不了屏幕边缘",
任务栏在底下的机器上尤其明显。
== 权限 ==
被控端跑在普通用户下也能注入(同一会话内)。
但如果目标窗口是**管理员权限**的(比如 UAC 提升过的程序),
普通权限的 SendInput 会被 UIPI 拦掉,鼠标点不动。
这不是 bug,是 Windows 的安全设计(防止低权限程序操控高权限窗口)。
被控端如果以管理员运行就都能点。README 里说清楚这一点。
'''
from __future__ import annotations
import ctypes
import ctypes.wintypes as wt
user32 = ctypes.WinDLL("user32", use_last_error=True)
# ---- SendInput 的结构体 ----
#
# ⚠ INPUT 结构在 32 位和 64 位下**大小不一样**:
# 64 位下 union 前面有 4 字节的对齐填充,sizeof(INPUT) == 40;
# 32 位下是 28。
# 所以必须让 ctypes 按本进程的位数算,不能写死。
# (我们用 Python 3.8 **32 位**打包,所以实际是 28 ——
# 但代码要两边都对,免得以后换 64 位打包时静默出错。)
ULONG_PTR = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 \
else ctypes.c_ulong
class MOUSEINPUT(ctypes.Structure):
_fields_ = [("dx", wt.LONG),
("dy", wt.LONG),
("mouseData", wt.DWORD),
("dwFlags", wt.DWORD),
("time", wt.DWORD),
("dwExtraInfo", ULONG_PTR)]
class KEYBDINPUT(ctypes.Structure):
_fields_ = [("wVk", wt.WORD),
("wScan", wt.WORD),
("dwFlags", wt.DWORD),
("time", wt.DWORD),
("dwExtraInfo", ULONG_PTR)]
class HARDWAREINPUT(ctypes.Structure):
_fields_ = [("uMsg", wt.DWORD),
("wParamL", wt.WORD),
("wParamH", wt.WORD)]
class _INPUTunion(ctypes.Union):
_fields_ = [("mi", MOUSEINPUT),
("ki", KEYBDINPUT),
("hi", HARDWAREINPUT)]
class INPUT(ctypes.Structure):
_fields_ = [("type", wt.DWORD),
("union", _INPUTunion)]
INPUT_MOUSE = 0
INPUT_KEYBOARD = 1
# 鼠标标志
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
MOUSEEVENTF_RIGHTDOWN = 0x0008
MOUSEEVENTF_RIGHTUP = 0x0010
MOUSEEVENTF_MIDDLEDOWN = 0x0020
MOUSEEVENTF_MIDDLEUP = 0x0040
MOUSEEVENTF_WHEEL = 0x0800
MOUSEEVENTF_HWHEEL = 0x1000
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_VIRTUALDESK = 0x4000
# 键盘标志
KEYEVENTF_KEYUP = 0x0002
KEYEVENTF_EXTENDEDKEY = 0x0001
VK_LWIN = 0x5B
VK_D = 0x44
VK_ESCAPE = 0x1B
WHEEL_DELTA = 120
# 屏幕指标
SM_XVIRTUALSCREEN = 76
SM_YVIRTUALSCREEN = 77
SM_CXVIRTUALSCREEN = 78
SM_CYVIRTUALSCREEN = 79
# 屏幕宽高在进程生命周期里基本不变,缓存一下
_screen_cache: dict = {}
class InputError(Exception):
"""注入失败(被系统拦了、参数不对等)。"""
def _screen() -> tuple:
"""整个虚拟屏幕的 (左, 上, 宽, 高)。多显示器时是包住所有屏的矩形。"""
if not _screen_cache:
_screen_cache["v"] = (
user32.GetSystemMetrics(SM_XVIRTUALSCREEN),
user32.GetSystemMetrics(SM_YVIRTUALSCREEN),
user32.GetSystemMetrics(SM_CXVIRTUALSCREEN),
user32.GetSystemMetrics(SM_CYVIRTUALSCREEN),
)
return _screen_cache["v"]
def refresh_screen() -> None:
"""显示器分辨率变了之后调一下,让缓存重新读。"""
_screen_cache.clear()
def _send(*inputs: INPUT) -> None:
"""把一批事件一次性发出去。"""
if not inputs:
return
n = len(inputs)
arr = (INPUT * n)(*inputs)
sent = user32.SendInput(n, ctypes.byref(arr), ctypes.sizeof(INPUT))
if sent != n:
err = ctypes.get_last_error()
# 0x57 = ERROR_INVALID_PARAMETER,多半是被 UIPI 拦了
if err == 87:
raise InputError("系统拒绝了这次输入(目标窗口权限比被控端高?"
"把被控端用管理员身份运行就能解决)")
raise InputError("注入输入失败(SendInput 返回 %d/%d,错误码 %d)"
% (sent, n, err))
def _mouse(dx: int, dy: int, data: int, flags: int) -> INPUT:
return INPUT(type=INPUT_MOUSE,
union=_INPUTunion(mi=MOUSEINPUT(dx, dy, data, flags, 0, 0)))
def _key(vk: int, up: bool = False) -> INPUT:
flags = KEYEVENTF_KEYUP if up else 0
# 左右 Win / 方向键这些是扩展键(不是必须,但更准确)
if vk in (VK_LWIN, 0x5C, 0x25, 0x26, 0x27, 0x28):
flags |= KEYEVENTF_EXTENDEDKEY
return INPUT(type=INPUT_KEYBOARD,
union=_INPUTunion(ki=KEYBDINPUT(vk, 0, flags, 0, 0)))
# ---------------------------------------------------------------- 鼠标
def move_to(x: int, y: int) -> None:
"""把鼠标移到**屏幕像素坐标** (x, y)。
坐标是相对虚拟屏幕左上角的像素值;超出范围会被夹到边界内。
"""
vx, vy, vw, vh = _screen()
if vw <= 1 or vh <= 1:
raise InputError("拿不到屏幕尺寸")
# 夹到边界,免得归一化后溢出
px = max(vx, min(vx + vw - 1, int(x)))
py = max(vy, min(vy + vh - 1, int(y)))
# ⚠ 除以 (宽-1),见文件头说明
nx = int(round((px - vx) * 65535 / (vw - 1)))
ny = int(round((py - vy) * 65535 / (vh - 1)))
_send(_mouse(nx, ny, 0,
MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE
| MOUSEEVENTF_VIRTUALDESK))
def move_by(dx: int, dy: int) -> None:
"""相对移动(有些场景比绝对坐标好用,比如拖拽)。"""
_send(_mouse(int(dx), int(dy), 0, MOUSEEVENTF_MOVE))
_BUTTONS = {
"left": (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
"right": (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
"middle": (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
}
def click(button: str = "left", double: bool = False) -> None:
"""点一下(按下+抬起一次发出去,中间不会插进别的输入)。"""
b = _BUTTONS.get(str(button).lower())
if not b:
raise InputError("不认识的鼠标键:%s(只能 left/right/middle)"
% button)
down, up = b
seq = [_mouse(0, 0, 0, down), _mouse(0, 0, 0, up)]
if double:
seq = seq + seq
_send(*seq)
def down(button: str = "left") -> None:
b = _BUTTONS.get(str(button).lower())
if not b:
raise InputError("不认识的鼠标键:%s" % button)
_send(_mouse(0, 0, 0, b[0]))
def up(button: str = "left") -> None:
b = _BUTTONS.get(str(button).lower())
if not b:
raise InputError("不认识的鼠标键:%s" % button)
_send(_mouse(0, 0, 0, b[1]))
def scroll(amount: int, horizontal: bool = False) -> None:
"""滚轮。amount 正数向上/向右,负数向下/向左。
一个"格"按 120 算(Windows 的 WHEEL_DELTA)。
手表那边传 1/-1 这种格子数就行,这里乘 120。
"""
try:
n = int(amount)
except (TypeError, ValueError):
raise InputError("滚轮量必须是整数")
if n == 0:
return
# 夹一下,防止一次滚几百格把页面甩飞
n = max(-50, min(50, n))
flag = MOUSEEVENTF_HWHEEL if horizontal else MOUSEEVENTF_WHEEL
_send(_mouse(0, 0, (n * WHEEL_DELTA) & 0xFFFFFFFF, flag))
# ---------------------------------------------------------------- 键盘
def key(vk: int) -> None:
"""按一下某个虚拟键码。"""
_send(_key(int(vk)), _key(int(vk), up=True))
def combo(*vks: int) -> None:
"""组合键,比如 combo(VK_LWIN, VK_D) 就是 Win+D。
顺序:全部按下 -> 逆序抬起。这样修饰键不会卡住。
"""
if not vks:
return
seq = [_key(v) for v in vks]
seq += [_key(v, up=True) for v in reversed(vks)]
_send(*seq)
def show_desktop() -> None:
"""显示桌面(等于 Win+D)。
用户要求快捷栏里那个不能改的按钮就是这个功能。
"""
combo(VK_LWIN, VK_D)