Files
ykxmz/windows/lanremote/updater.py
T
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

295 lines
11 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. 问中转:最新是哪版、在哪下、sha256 多少
GET <中转>/v1/update/latest?platform=windows
2. 和本机版本比,一样就直接返回"已是最新"
3. 下载安装包到 %TEMP%\ykxmz-update\
4. **校验 sha256**(下坏了/被换了都必须拦住)
5. 写一个 .cmd 帮手,**分离进程**启动它,然后自己退出
6. 帮手等本进程退出 -> 静默跑安装器 -> 重新拉起被控端
== 为什么要有第 5 步那个 .cmd ==
Windows 上**正在运行的 exe 文件是锁着的**,安装器覆盖不了。
所以必须"先退出、再安装、再起来",而这三步不能由本进程做
(它一退出就没人执行后面的了)。
用 `DETACHED_PROCESS` 起的 .cmd 不依赖本进程存活,
本进程退出后它继续跑 —— 这是 Windows 自更新的标准做法。
== 配置为什么不会丢 ==
配置在 `%ProgramData%\ykxmz\data\`(见 config.py 的 data_dir),
**不在程序目录里**。安装器升级时只替换 `{app}` 下的文件,
不碰 ProgramData —— 所以中转地址、配对码、各种开关原样保留。
这一点是这个功能能做的前提。要是配置在程序目录里,
每次更新都会被清掉,那"设置不变"就无从谈起。
== 为什么 sha256 必须校验 ==
自更新的最大风险是"下到一半断了"或"下到的不是我们要的东西",
装上去就是一个坏掉的程序,而且它还会自己再更新 —— 螺旋下去很难救。
sha256 对不上就**直接放弃这一轮**,保持现状(现状至少是能用的)。
== 安全边界(写清楚,免得以后自己忘了)==
这个功能**只能从中转站取包**,而中转站地址是用户自己配的。
所以:
* 用户自己搭的中转,就只从那里更新 —— 不存在"被第三方投毒"
* 中转上的包是用户自己上传的
* 校验和不匹配就拒绝,不做"绕过校验"的降级路径
换句话说:**更新源 = 用户自己的服务器**,不是作者的服务器,
也不是任何写死的公网地址。这一点是刻意的。
'''
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
# 中转没有这个接口时(老版本服务端)会返回 404/其它,直接当"不支持"处理
TIMEOUT_CHECK = 20
TIMEOUT_DOWNLOAD = 600
CHUNK = 256 * 1024
class UpdateError(Exception):
"""更新过程中出的问题(会被上层翻成中文提示)。"""
def _ver_tuple(v: str) -> tuple:
"""'2.1.5' -> (2,1,5)。解析不了就当 (0,0,0)。"""
try:
parts = [int(x) for x in str(v).strip().split(".")]
while len(parts) < 3:
parts.append(0)
return tuple(parts[:3])
except (ValueError, AttributeError):
return (0, 0, 0)
def _ua() -> str:
# 用自家标识。Cloudflare 会把 Python-urllib 判为可疑客户端直接 403
# (报 error code 1010),这个坑仓库里记过两次了。
return "LanRemote-updater/1.0"
def check(relay_url: str, current_version: str,
timeout: int = TIMEOUT_CHECK) -> dict:
"""问中转最新版。返回:
{"supported": bool, 中转支持不支持这个接口
"latest": "2.1.5",
"url": "https://.../download/ykxmz-setup-2.1.5.exe",
"name": "...", "size": 123, "sha256": "...",
"needUpdate": bool}
"""
base = (relay_url or "").strip().rstrip("/")
if not base:
raise UpdateError("还没配中转地址 —— 自动更新要从你自己的中转站取包")
if not base.startswith(("http://", "https://")):
base = "https://" + base
url = base + "/v1/update/latest?platform=windows"
req = urllib.request.Request(url)
req.add_header("User-Agent", _ua())
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
if exc.code == 404:
return {"supported": False,
"error": "中转站版本太旧,不支持自动更新(先手动升级一次服务端)"}
raise UpdateError("查询更新失败:HTTP %s" % exc.code)
except Exception as exc: # noqa: BLE001
raise UpdateError("连不上中转站:%s" % exc)
if not data.get("ok"):
return {"supported": True,
"error": data.get("error") or "中转站上还没有可下载的安装包"}
latest = str(data.get("version") or "")
url_path = str(data.get("url") or "")
if not latest or not url_path:
raise UpdateError("中转返回的信息不完整")
full = url_path if url_path.startswith("http") else base + url_path
return {
"supported": True,
"latest": latest,
"current": current_version,
"url": full,
"name": data.get("name") or "",
"size": int(data.get("size") or 0),
"sha256": (data.get("sha256") or "").lower(),
"needUpdate": _ver_tuple(latest) > _ver_tuple(current_version),
}
def download(url: str, dest_dir: Path, expect_sha256: str = "",
expect_size: int = 0,
progress=None) -> Path:
"""下载安装包并校验。返回下载好的路径。
progress: 可选回调 (已下载字节, 总字节)
"""
dest_dir.mkdir(parents=True, exist_ok=True)
name = url.rsplit("/", 1)[-1].split("?")[0] or "ykxmz-update.exe"
# 只留文件名,别让 URL 里的东西跑到路径里(防目录穿越)
name = os.path.basename(urllib.parse.unquote(name))
if not name or name.startswith("."):
name = "ykxmz-update.exe"
dest = dest_dir / name
req = urllib.request.Request(url)
req.add_header("User-Agent", _ua())
try:
with urllib.request.urlopen(req, timeout=TIMEOUT_DOWNLOAD) as resp:
total = int(resp.headers.get("Content-Length") or 0)
h = hashlib.sha256()
got = 0
with dest.open("wb") as f:
while True:
chunk = resp.read(CHUNK)
if not chunk:
break
f.write(chunk)
h.update(chunk)
got += len(chunk)
if progress:
try:
progress(got, total)
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
try:
dest.unlink()
except OSError:
pass
raise UpdateError("下载失败:%s" % exc)
if expect_size and got != expect_size:
dest.unlink(missing_ok=True)
raise UpdateError("下载大小对不上(期望 %d,实际 %d)"
% (expect_size, got))
if expect_sha256 and h.hexdigest() != expect_sha256:
dest.unlink(missing_ok=True)
raise UpdateError("校验和不匹配 —— 包可能下坏了,已丢弃(保持当前版本)")
return dest
WAITER_TEMPLATE = r'''@echo off
rem ===================================================================
rem ykxmz 自动更新的"帮手"脚本
rem
rem 被控端自己要退出了,没法再做后面的事,所以把这三步交给这个脚本:
rem 1. 等被控端真的退出(不然安装器覆盖不了正在运行的 exe)
rem 2. 静默跑安装器
rem 3. 重新拉起被控端
rem
rem 它由被控端用 DETACHED_PROCESS 启动,不受被控端退出影响。
rem 跑完自己删掉。
rem ===================================================================
setlocal
set "PID={pid}"
set "SETUP={setup}"
set "APPEXE={appexe}"
set "LOGFILE={log}"
echo [%date% %time%] 等待进程 %PID% 退出... >> "%LOGFILE%"
set /a TRIES=0
:waitloop
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
if errorlevel 1 goto gone
set /a TRIES+=1
if %TRIES% GEQ 150 goto gone
timeout /t 2 /nobreak >nul
goto waitloop
:gone
echo [%date% %time%] 进程已退出,开始静默安装 >> "%LOGFILE%"
rem /VERYSILENT 全静默、不弹任何窗口
rem /SUPPRESSMSGBOXES 连错误框也不弹
rem /NORESTART 不自动重启电脑
rem 不带 /DIR,让它按注册表里记的上次安装位置原地升级
start "" /wait "%SETUP%" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART
echo [%date% %time%] 安装器退出码 %ERRORLEVEL% >> "%LOGFILE%"
rem 给安装器一点时间把文件落完
timeout /t 3 /nobreak >nul
echo [%date% %time%] 重新拉起被控端 >> "%LOGFILE%"
if exist "%APPEXE%" start "" "%APPEXE%" --silent
timeout /t 3 /nobreak >nul
rem 清理:删掉安装包和自己
del /f /q "%SETUP%" >nul 2>&1
(goto) 2>nul & del /f /q "%~f0"
'''
def apply_update(installer: Path, app_exe: Path, pid: int = None) -> str:
"""写帮手脚本、分离启动它。调用方**紧接着必须自己退出**。
返回日志文件路径(写进被控端日志,方便事后查)。
"""
pid = pid or os.getpid()
work = Path(tempfile.gettempdir()) / "ykxmz-update"
work.mkdir(parents=True, exist_ok=True)
log = work / "update.log"
script = work / "do-update.cmd"
body = (WAITER_TEMPLATE
.replace("{pid}", str(pid))
.replace("{setup}", str(installer))
.replace("{appexe}", str(app_exe))
.replace("{log}", str(log)))
# .bat/.cmd 用系统 ANSI 编码(GBK),不能让中文变乱码
script.write_bytes(body.replace("\n", "\r\n").encode("mbcs", "replace"))
# DETACHED_PROCESS: 不继承控制台、不随父进程退出
# CREATE_NEW_PROCESS_GROUP: 不受父进程的 Ctrl 事件影响
flags = 0
flags |= getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
subprocess.Popen(["cmd.exe", "/c", str(script)],
creationflags=flags,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True)
return str(log)
def cleanup_old_downloads(keep_days: int = 3) -> None:
"""清掉临时目录里过期的更新包(失败的残留)。"""
work = Path(tempfile.gettempdir()) / "ykxmz-update"
if not work.is_dir():
return
cutoff = time.time() - keep_days * 86400
try:
for f in work.iterdir():
if f.is_file() and f.stat().st_mtime < cutoff:
f.unlink()
except OSError:
pass