mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 13:40:45 +00:00
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。
This commit is contained in:
@@ -45,9 +45,9 @@
|
|||||||
|
|
||||||
| 装在哪 | 是什么 | 文件 |
|
| 装在哪 | 是什么 | 文件 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.5.exe` |
|
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.6.exe` |
|
||||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.5.zip` |
|
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.6.zip` |
|
||||||
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.5.apk` |
|
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.6.apk` |
|
||||||
|
|
||||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
|
|
||||||
### 第一步:电脑上装被控端
|
### 第一步:电脑上装被控端
|
||||||
|
|
||||||
双击 `ykxmz-setup-2.1.5.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
双击 `ykxmz-setup-2.1.6.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||||
|
|
||||||
第一次运行会弹出窗口,填两样:
|
第一次运行会弹出窗口,填两样:
|
||||||
|
|
||||||
@@ -94,12 +94,12 @@
|
|||||||
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
||||||
> 想彻底退出,窗口上点「退出」。
|
> 想彻底退出,窗口上点「退出」。
|
||||||
|
|
||||||
> 不想用安装包的话,`ykxmz-agent-2.1.5.zip` 是绿色版:
|
> 不想用安装包的话,`ykxmz-agent-2.1.6.zip` 是绿色版:
|
||||||
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||||
|
|
||||||
### 第二步:手表上装 App
|
### 第二步:手表上装 App
|
||||||
|
|
||||||
把 `ykxmz-watch-2.1.5.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
把 `ykxmz-watch-2.1.6.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||||
|
|
||||||
打开 App,首页有四个入口:
|
打开 App,首页有四个入口:
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||||
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||||
|
|
||||||
把 `ykxmz-server-2.1.5.zip` 解压到那台机器上,然后一条命令:
|
把 `ykxmz-server-2.1.6.zip` 解压到那台机器上,然后一条命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash deploy.sh
|
bash deploy.sh
|
||||||
@@ -456,7 +456,7 @@ windows\build_exe.ps1
|
|||||||
```powershell
|
```powershell
|
||||||
android\build_apk.ps1
|
android\build_apk.ps1
|
||||||
# 流程:aapt2 → javac → d8 → zipalign → apksigner
|
# 流程:aapt2 → javac → d8 → zipalign → apksigner
|
||||||
# 产物:dist\ykxmz-watch-2.1.5.apk
|
# 产物:dist\ykxmz-watch-2.1.6.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||||
|
|||||||
@@ -1,3 +1,43 @@
|
|||||||
|
## 2.1.6
|
||||||
|
|
||||||
|
- **电脑端静默自动更新** —— 手表上点一下,电脑自己升到最新版,
|
||||||
|
全程不弹任何窗口,**中转地址、配对码、所有设置一个都不变**。
|
||||||
|
|
||||||
|
流程:手表点按钮 → 电脑端问中转"最新是哪版" → 下载并校验 →
|
||||||
|
起一个后台帮手 → 自己退出 → 帮手等它退出后静默装好 → 重新拉起。
|
||||||
|
|
||||||
|
几个关键点:
|
||||||
|
|
||||||
|
- **配置为什么不会丢**:配置存在 `%ProgramData%\ykxmz\data\`,
|
||||||
|
不在程序目录里。安装器升级只替换程序文件,不碰这个目录。
|
||||||
|
- **更新源是用户自己的中转站**,不是作者的服务器,也不是任何
|
||||||
|
写死的地址。中转没这功能(老版本服务端)时会明确提示
|
||||||
|
"先手动升级一次服务端"。
|
||||||
|
- **校验和不匹配就放弃这一轮**,保持现状(现状至少能用)。
|
||||||
|
自更新最怕的就是装上一个坏程序、然后它还自己继续更新。
|
||||||
|
|
||||||
|
手表首页新增「电脑端版本」按钮,点开就能看到
|
||||||
|
"电脑端 2.1.4 → 2.1.5",确认后才动手。
|
||||||
|
|
||||||
|
- **远程鼠标控制**:移动、左键、右键、中键、滚轮(含横向)。
|
||||||
|
|
||||||
|
这是"能直接操控电脑"的功能,所以参数校验写得很严:
|
||||||
|
非法动作、非法按键、非数字坐标全都会被拒。
|
||||||
|
|
||||||
|
- **一键显示桌面**(等于 Win+D)。
|
||||||
|
|
||||||
|
- **Windows 7 兼容性校验关卡**:打包时和回归里都会检查
|
||||||
|
(内嵌的是不是 3.8 运行库、**有没有**那个 Win7 上不存在的 DLL、
|
||||||
|
exe 是不是 32 位),不过就拒绝出包。
|
||||||
|
|
||||||
|
- 技术说明:
|
||||||
|
- 鼠标坐标用 SendInput 的 **0..65535 归一化**,而且**必须除以
|
||||||
|
(屏幕宽-1)**。少了那个 -1,最右边和最下边几像素点不到。
|
||||||
|
- `INPUT` 结构在 32/64 位下大小不同(28 / 40),代码按进程位数算。
|
||||||
|
- 目标窗口若是管理员权限的(UAC 提升过的),普通权限的注入会被
|
||||||
|
系统拦掉 —— 这是 Windows 的设计,不是 bug。被控端以管理员运行
|
||||||
|
就没问题。
|
||||||
|
|
||||||
## 2.1.5
|
## 2.1.5
|
||||||
|
|
||||||
- **修「Windows 7 装完报 dll 缺失」** —— 学校电脑装不上。
|
- **修「Windows 7 装完报 dll 缺失」** —— 学校电脑装不上。
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''在一台服务器上加一个新网站(nginx 虚拟主机 + 证书)。
|
||||||
|
|
||||||
|
== 为什么需要这个 ==
|
||||||
|
|
||||||
|
一台服务器上跑多个网站是常态。做法是:**一个 nginx 占 80/443,
|
||||||
|
每个域名一个 server 块**,nginx 按 Host 头分流到各自的本地端口。
|
||||||
|
|
||||||
|
手写这套配置每次都要复制粘贴改半天,还容易漏:
|
||||||
|
* 忘了签证书 -> 浏览器报不安全
|
||||||
|
* 后端端口写错 -> 502
|
||||||
|
* 忘了把后端绑到 127.0.0.1 -> 服务直接暴露在公网
|
||||||
|
|
||||||
|
所以做成一键的。
|
||||||
|
|
||||||
|
== 用法 ==
|
||||||
|
|
||||||
|
python _build/add_site.py 域名 后端地址 [--php] [--root 目录]
|
||||||
|
|
||||||
|
例:
|
||||||
|
# 反代到一个本地服务
|
||||||
|
python _build/add_site.py blog.xxmxxs.top 127.0.0.1:9000
|
||||||
|
|
||||||
|
# WordPress 那种 PHP 站(走 php-fpm 的 unix socket 或端口)
|
||||||
|
python _build/add_site.py xxmxxs.top 127.0.0.1:9000 --php --root /opt/1panel/www/sites/xxmxxs.top/index
|
||||||
|
|
||||||
|
== 设计上的几个注意 ==
|
||||||
|
|
||||||
|
1. **默认只回源 127.0.0.1**
|
||||||
|
后端服务必须只绑本地。写 0.0.0.0 的话,别人直接打 IP:端口
|
||||||
|
就绕过 nginx 和 Cloudflare 了 —— 那正是用户不想要的。
|
||||||
|
|
||||||
|
2. **证书用 certbot 的 nginx 插件签**
|
||||||
|
它自己改配置、自己加 443。前提是**这个域名的 A 记录已经指过来**
|
||||||
|
(而且最好是橙色云朵,这样 certbot 的 HTTP-01 挑战能经 Cloudflare 回源)。
|
||||||
|
|
||||||
|
3. **不动别的站点**
|
||||||
|
只新建 /etc/nginx/sites-available/<域名>,再链到 sites-enabled。
|
||||||
|
已有的站点(比如遥控中转 ykxmz)一个字都不碰。
|
||||||
|
|
||||||
|
4. **默认 server 的坑**
|
||||||
|
如果有人用 IP 直接访问,nginx 会用**第一个** server 块回应。
|
||||||
|
所以配置文件开头有个显式的 default server 把它们挡掉 ——
|
||||||
|
这样 IP:port 扫不到任何真实站点。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run, upload # noqa: E402
|
||||||
|
|
||||||
|
TEMPLATE = """# {domain} —— 由 _build/add_site.py 生成
|
||||||
|
#
|
||||||
|
# 后端: {backend}
|
||||||
|
# 说明: nginx 按 server_name 分流;后端只应该监听 127.0.0.1,
|
||||||
|
# 这样外面只能通过域名(也就是经过 nginx + Cloudflare)访问。
|
||||||
|
|
||||||
|
server {{
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name {domain};
|
||||||
|
|
||||||
|
access_log /var/log/nginx/{safe}_access.log;
|
||||||
|
error_log /var/log/nginx/{safe}_error.log;
|
||||||
|
|
||||||
|
{body}
|
||||||
|
server_tokens off;
|
||||||
|
client_max_body_size {max_body};
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PROXY_BODY = """ location / {{
|
||||||
|
proxy_pass http://{backend};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_connect_timeout 30s;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
PHP_BODY = """ root {root};
|
||||||
|
index index.php index.html;
|
||||||
|
|
||||||
|
location / {{
|
||||||
|
try_files $uri $uri/ /index.php?$args;
|
||||||
|
}}
|
||||||
|
|
||||||
|
location ~ \\.php$ {{
|
||||||
|
include snippets/fastcgi-php.conf;
|
||||||
|
fastcgi_pass {backend};
|
||||||
|
fastcgi_read_timeout 300s;
|
||||||
|
}}
|
||||||
|
|
||||||
|
location ~ /\\.ht {{
|
||||||
|
deny all;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="加一个网站")
|
||||||
|
ap.add_argument("domain", help="域名,例如 blog.xxmxxs.top")
|
||||||
|
ap.add_argument("backend", help="后端地址,例如 127.0.0.1:9000")
|
||||||
|
ap.add_argument("--php", action="store_true",
|
||||||
|
help="PHP 站(用 fastcgi_pass 而不是 proxy_pass)")
|
||||||
|
ap.add_argument("--root", default="", help="PHP 站的根目录")
|
||||||
|
ap.add_argument("--max-body", default="64m", help="上传大小上限,默认 64m")
|
||||||
|
ap.add_argument("--no-cert", action="store_true",
|
||||||
|
help="只配 80,先不签证书")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
domain = args.domain.strip()
|
||||||
|
backend = args.backend.strip()
|
||||||
|
safe = domain.replace(".", "_").replace("-", "_")
|
||||||
|
|
||||||
|
# ⚠ 后端必须是本地地址。写 0.0.0.0 或公网地址的话,
|
||||||
|
# 等于把后端服务直接暴露出去,nginx 和 Cloudflare 都白搭。
|
||||||
|
if not (backend.startswith("127.0.0.1:") or backend.startswith("localhost:")
|
||||||
|
or backend.startswith("unix:")):
|
||||||
|
print(" ⚠ 后端 %r 不是本地地址。" % backend)
|
||||||
|
print(" 后端必须只监听 127.0.0.1 —— 否则别人直接打 "
|
||||||
|
"IP:端口就绕过了 nginx 和 Cloudflare。")
|
||||||
|
print(" 要继续的话请显式确认(现在中止)。")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.php and not args.root:
|
||||||
|
print(" ✗ --php 需要同时给 --root(网站文件目录)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
body = (PHP_BODY.format(root=args.root, backend=backend)
|
||||||
|
if args.php else PROXY_BODY.format(backend=backend))
|
||||||
|
conf = TEMPLATE.format(domain=domain, backend=backend, safe=safe,
|
||||||
|
body=body, max_body=args.max_body)
|
||||||
|
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 加站点 %s -> %s" % (domain, backend))
|
||||||
|
print("=" * 58)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 1. 写配置 ──")
|
||||||
|
import tempfile
|
||||||
|
tmp = Path(tempfile.gettempdir()) / ("nginx-%s.conf" % safe)
|
||||||
|
tmp.write_text(conf, encoding="utf-8", newline="\n")
|
||||||
|
upload(str(tmp), "/tmp/%s.conf" % safe)
|
||||||
|
out = run(
|
||||||
|
"sudo mv /tmp/{s}.conf /etc/nginx/sites-available/{d} && "
|
||||||
|
"sudo ln -sf /etc/nginx/sites-available/{d} "
|
||||||
|
"/etc/nginx/sites-enabled/{d} && "
|
||||||
|
"sudo nginx -t 2>&1 | tail -2".format(s=safe, d=domain),
|
||||||
|
timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
if "successful" not in out[1]:
|
||||||
|
print(" ✗ nginx 配置检查没过,撤回")
|
||||||
|
run("sudo rm -f /etc/nginx/sites-enabled/%s" % domain,
|
||||||
|
timeout=60, quiet=True)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 2. 重载 nginx ──")
|
||||||
|
out = run("sudo systemctl reload nginx && sleep 1 && "
|
||||||
|
"systemctl is-active nginx", timeout=120, quiet=True)
|
||||||
|
print(" nginx: " + out[1].strip())
|
||||||
|
|
||||||
|
if not args.no_cert:
|
||||||
|
print()
|
||||||
|
print(" ── 3. 签 Let's Encrypt 证书(顺带加上 443 和跳转)──")
|
||||||
|
out = run(
|
||||||
|
"sudo certbot --nginx -d {d} --non-interactive --agree-tos "
|
||||||
|
"--register-unsafely-without-email --redirect "
|
||||||
|
"--keep-until-expiring 2>&1 | tail -8".format(d=domain),
|
||||||
|
timeout=600, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(" " + ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" ── 4. 复核 ──")
|
||||||
|
# ⚠ 这里**不能用 .format()** —— 远端命令里有 curl 的 `%{http_code}`,
|
||||||
|
# 而 .format() 会把 `{http_code}` 当占位符,抛
|
||||||
|
# "KeyError/IndexError: http_code"。
|
||||||
|
# 和之前用 % 格式化踩的是**同一类坑**(内容里有格式符)。
|
||||||
|
# 凡是把域名等变量拼进含 `{...}` 的远端命令,一律用 replace。
|
||||||
|
probe = (
|
||||||
|
'printf " %-34s %s\\n" "本机带 Host 头" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 10 '
|
||||||
|
'-H \'Host: __D__\' http://127.0.0.1:80/)"; '
|
||||||
|
'printf " %-34s %s\\n" "本机 443 带 SNI" '
|
||||||
|
'"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 10 '
|
||||||
|
'--resolve __D__:443:127.0.0.1 https://__D__/)"; '
|
||||||
|
'printf " %-34s %s\\n" "公网 443" '
|
||||||
|
'"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 20 '
|
||||||
|
'https://__D__/)"'
|
||||||
|
).replace("__D__", domain)
|
||||||
|
out = run(probe, timeout=180, quiet=True)
|
||||||
|
for ln in out[1].strip().splitlines():
|
||||||
|
print(ln)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 已加好。注意:")
|
||||||
|
print(" * 这个域名的 DNS 要指向这台服务器(Cloudflare 橙色云朵)")
|
||||||
|
print(" * 后端 %s 必须只监听 127.0.0.1" % backend)
|
||||||
|
print(" * 不要动 /etc/nginx/sites-available/ykxmz(那是遥控中转)",
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''给中转加「最新版本查询」接口 —— 被控端静默自动更新的第一步。
|
||||||
|
|
||||||
|
== 为什么由中转来回答 ==
|
||||||
|
|
||||||
|
被控端已经知道中转地址(那是它的配置),而中转的 downloads/ 目录里
|
||||||
|
就放着各版本的成品。让中转直接回答"最新是哪版、在哪下、校验和多少",
|
||||||
|
就不用把任何地址写死在客户端里 —— 换个域名/换台服务器都不用改代码。
|
||||||
|
|
||||||
|
== 新增 ==
|
||||||
|
|
||||||
|
GET /v1/update/latest?platform=windows
|
||||||
|
|
||||||
|
200 {
|
||||||
|
"ok": true,
|
||||||
|
"platform": "windows",
|
||||||
|
"version": "2.1.5",
|
||||||
|
"name": "ykxmz-setup-2.1.5.exe",
|
||||||
|
"url": "/download/ykxmz-setup-2.1.5.exe",
|
||||||
|
"size": 9395078,
|
||||||
|
"sha256": "....",
|
||||||
|
"notes": "..." <- 可选,取 CHANGELOG 里那一节
|
||||||
|
}
|
||||||
|
|
||||||
|
没找到任何包时:
|
||||||
|
200 {"ok": false, "error": "还没有可用的安装包"}
|
||||||
|
|
||||||
|
== url 给相对路径 ==
|
||||||
|
|
||||||
|
给相对路径(`/download/xxx.exe`),让被控端用**它自己配置的中转地址**
|
||||||
|
拼成完整 URL。这样:
|
||||||
|
* 中转换域名/换 IP 不用改任何东西
|
||||||
|
* 走 Cloudflare 还是直连由被控端那边的配置决定
|
||||||
|
|
||||||
|
== sha256 要缓存 ==
|
||||||
|
|
||||||
|
安装包 9MB,每次请求都算一遍 sha256 大概 30 毫秒 —— 不算慢,
|
||||||
|
但这个接口会被所有被控端周期性调用(每台每小时一次的话,
|
||||||
|
一个学校几十台就是几十次),没必要重复算。
|
||||||
|
按「文件名 + 大小 + 修改时间」做键缓存。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
RELAY = ROOT / "relay" / "relay.py"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = RELAY.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "_latest_package" in text:
|
||||||
|
print(" 已经加过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ⚠ **先把所有锚点都验一遍,再动手改。**
|
||||||
|
#
|
||||||
|
# 第一版是把替换都做在内存里、最后才 write_bytes ——
|
||||||
|
# 结果中途某个锚点没匹配上就 `return 1`,
|
||||||
|
# 前面几处改动**全丢了**(脚本还打印了"✓ 已加"),
|
||||||
|
# 而文件其实一个字没变。特别容易误判成"加好了"。
|
||||||
|
#
|
||||||
|
# 教训:**批处理脚本要幺先验后改,幺写一段落一段。**
|
||||||
|
ANCHORS = {
|
||||||
|
"import_json": "\nimport json",
|
||||||
|
"list_downloads": ' items.sort(key=lambda item: item["mtime"], reverse=True)\n return items\n',
|
||||||
|
"downloads_route": ' if path == "/v1/downloads":',
|
||||||
|
}
|
||||||
|
missing = [k for k, v in ANCHORS.items() if v not in text]
|
||||||
|
if missing:
|
||||||
|
print(" ✗ 这些锚点找不到,一个字都不改:%s" % ", ".join(missing))
|
||||||
|
return 1
|
||||||
|
print(" ✓ 锚点全部就位")
|
||||||
|
|
||||||
|
# ---- 1. import hashlib ----
|
||||||
|
if "\nimport hashlib" not in text:
|
||||||
|
text = text.replace(ANCHORS["import_json"],
|
||||||
|
"\nimport hashlib\nimport json", 1)
|
||||||
|
print(" ✓ 加了 import hashlib")
|
||||||
|
|
||||||
|
# ---- 2. 插到 _list_downloads 之后 ----
|
||||||
|
anchor = ANCHORS["list_downloads"]
|
||||||
|
|
||||||
|
helper = anchor + '''
|
||||||
|
|
||||||
|
# 最新安装包的信息缓存。键 = (文件名, 大小, 修改时间)。
|
||||||
|
_PKG_CACHE: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256_of(path: Path) -> str:
|
||||||
|
"""算文件的 sha256(分块读,别把 9MB 全塞内存)。"""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(256 * 1024), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_package(platform: str = "windows") -> dict:
|
||||||
|
"""找 downloads/ 里最新的安装包,给被控端自更新用。
|
||||||
|
|
||||||
|
`platform` 现在只有 windows;留着参数是为了以后加别的平台
|
||||||
|
(手表/服务端不通过这个接口自更新,它们各有各的更新方式)。
|
||||||
|
|
||||||
|
返回 {} 表示"还没有可用的包"。
|
||||||
|
"""
|
||||||
|
# 各平台的成品名前缀
|
||||||
|
prefix = {
|
||||||
|
"windows": "ykxmz-setup-",
|
||||||
|
}.get(platform)
|
||||||
|
if not prefix:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
best = None # (版本元组, 路径)
|
||||||
|
try:
|
||||||
|
if not DOWNLOADS_DIR.is_dir():
|
||||||
|
return {}
|
||||||
|
for entry in DOWNLOADS_DIR.iterdir():
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
name = entry.name
|
||||||
|
if not name.startswith(prefix) or not name.endswith(".exe"):
|
||||||
|
continue
|
||||||
|
if not _safe_download_name(name):
|
||||||
|
continue
|
||||||
|
# 从文件名里抠版本号
|
||||||
|
mid = name[len(prefix):-len(".exe")]
|
||||||
|
parts = mid.split(".")
|
||||||
|
if len(parts) != 3 or not all(p.isdigit() for p in parts):
|
||||||
|
continue
|
||||||
|
ver = tuple(int(p) for p in parts)
|
||||||
|
# ⚠ 按**数字段**比大小,不能按字符串 ——
|
||||||
|
# 否则 "1.9.0" > "2.1.4"(字符 '9' > '1')。
|
||||||
|
if best is None or ver > best[0]:
|
||||||
|
best = (ver, entry)
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if best is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
ver, path = best
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
key = (path.name, st.st_size, int(st.st_mtime))
|
||||||
|
cached = _PKG_CACHE.get(key)
|
||||||
|
if cached:
|
||||||
|
digest = cached
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
digest = _sha256_of(path)
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
# 只留最近几个,别让字典涨
|
||||||
|
_PKG_CACHE.clear()
|
||||||
|
_PKG_CACHE[key] = digest
|
||||||
|
|
||||||
|
return {
|
||||||
|
"platform": platform,
|
||||||
|
"version": ".".join(str(p) for p in ver),
|
||||||
|
"name": path.name,
|
||||||
|
"url": "/download/" + quote(path.name),
|
||||||
|
"size": st.st_size,
|
||||||
|
"sha256": digest,
|
||||||
|
"mtime": st.st_mtime,
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
text = text.replace(anchor, helper, 1)
|
||||||
|
print(" ✓ 加了 _latest_package / _sha256_of")
|
||||||
|
|
||||||
|
# ---- 3. 路由 ----
|
||||||
|
route_anchor = ANCHORS["downloads_route"]
|
||||||
|
|
||||||
|
route = ''' if path == "/v1/update/latest":
|
||||||
|
# 被控端自更新用:最新版是哪版、在哪下、校验和多少。
|
||||||
|
# 公开接口 —— 文件名和哈希本来就在下载页上人人可见,
|
||||||
|
# 不含任何敏感信息。
|
||||||
|
platform = (urllib.parse.parse_qs(
|
||||||
|
urllib.parse.urlparse(self.path).query)
|
||||||
|
.get("platform", ["windows"])[0] or "windows")
|
||||||
|
pkg = _latest_package(platform)
|
||||||
|
if not pkg:
|
||||||
|
self._json(200, {"ok": False,
|
||||||
|
"error": "还没有可用的安装包"})
|
||||||
|
else:
|
||||||
|
self._json(200, dict(pkg, ok=True))
|
||||||
|
return
|
||||||
|
|
||||||
|
''' + route_anchor
|
||||||
|
text = text.replace(route_anchor, route, 1)
|
||||||
|
print(" ✓ 加了 /v1/update/latest 路由")
|
||||||
|
|
||||||
|
RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ 写回 relay.py")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''往 _tests/verify_all.ps1 里加两个关卡。
|
||||||
|
|
||||||
|
== 加哪两个 ==
|
||||||
|
|
||||||
|
3a10 静默自动更新链路(_tests/test_update_flow.py)
|
||||||
|
3a11 远程鼠标 + 显示桌面(_tests/test_mouse_control.py)
|
||||||
|
|
||||||
|
== 这个脚本自己踩过的坑,写下来免得再犯 ==
|
||||||
|
|
||||||
|
往 server.py 插方法时,我两次把 **dispatch 的兜底 return**
|
||||||
|
一起卷进了新方法里:
|
||||||
|
|
||||||
|
if cmd == P.CMD_XXX:
|
||||||
|
return self._do_xxx(msg)
|
||||||
|
|
||||||
|
return {"ok": False, "error": f"未知命令: {cmd}"} <- dispatch 的兜底
|
||||||
|
|
||||||
|
def _do_xxx(...): <- 我的新方法
|
||||||
|
...
|
||||||
|
return {"ok": False, "error": f"未知命令: {cmd}"} <- 被卷进来了!
|
||||||
|
|
||||||
|
结果 pyflakes 报 "undefined name 'cmd'",而 dispatch 丢了兜底分支。
|
||||||
|
|
||||||
|
这次改 PowerShell 里插关卡用**更笨但更稳**的办法:
|
||||||
|
直接在固定锚点**之前**插,而且插完立刻读回来看一眼结构。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||||
|
|
||||||
|
STAGES = '''
|
||||||
|
# ---------------------------------------------------------------- 3a10
|
||||||
|
Banner "3a10/7 静默自动更新链路"
|
||||||
|
# 用户要求:"手表端点击一个更新按钮,电脑端就可以自动进行静默更新,
|
||||||
|
# 其他什么设置都不变 …… 不弹任何窗口出来"
|
||||||
|
# 这条链是:中转 /v1/update/latest -> 被控端 updater.py 下载校验
|
||||||
|
# -> 分离 .cmd 帮手 -> 静默装 -> 重启。
|
||||||
|
#
|
||||||
|
# 这里验协议常量、版本比较(按数字段,不是字符串)、下载校验、
|
||||||
|
# 以及**服务端 dispatch 的结构没被补丁弄坏**。
|
||||||
|
$stageOut = & python (Join-Path $root "_tests\\test_update_flow.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "静默自动更新" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 3a11
|
||||||
|
Banner "3a11/7 远程鼠标 + 一键显示桌面"
|
||||||
|
# 鼠标注入是最"有力量"的功能,参数校验写错的话表现是"手表点了没反应",
|
||||||
|
# 教室里没法排查。这里盯坐标归一化(除以 宽-1 那条规则)、
|
||||||
|
# 滚轮夹取、Win+D 的事件顺序、以及非法参数必须被拒。
|
||||||
|
$stageOut = & python (Join-Path $root "_tests\\test_mouse_control.py") 2>&1
|
||||||
|
Show-Fails $stageOut
|
||||||
|
Record "远程鼠标控制" ($LASTEXITCODE -eq 0)
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
ANCHOR = '# ---------------------------------------------------------------- 3a5'
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
raw = VERIFY.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
|
||||||
|
if "静默自动更新链路" in text:
|
||||||
|
print(" 已经加过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if ANCHOR not in text:
|
||||||
|
print(" ✗ 找不到锚点 %r" % ANCHOR)
|
||||||
|
return 1
|
||||||
|
print(" ✓ 锚点就位")
|
||||||
|
|
||||||
|
text = text.replace(ANCHOR, STAGES.lstrip("\n") + "\n" + ANCHOR, 1)
|
||||||
|
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ 加了 3a10 / 3a11 两个关卡")
|
||||||
|
|
||||||
|
# 读回来确认结构:两个 Record 都在、锚点还在
|
||||||
|
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||||
|
for token in ('Record "静默自动更新"', 'Record "远程鼠标控制"', ANCHOR):
|
||||||
|
ok = token in back
|
||||||
|
print(" [%s] 读回确认:%s" % ("PASS" if ok else "FAIL",
|
||||||
|
token[:40]))
|
||||||
|
if not ok:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''查 1Panel 和我装的 nginx 会不会打架。
|
||||||
|
|
||||||
|
== 为什么要查 ==
|
||||||
|
|
||||||
|
发现服务器上有 1Panel。1Panel 默认会**自己装一套 OpenResty(nginx)**
|
||||||
|
放在 /opt/1panel/apps/openresty/ 下,并且**接管 80/443**。
|
||||||
|
|
||||||
|
而我为了给遥控中转做反代,用 apt 装了**系统 nginx**,也在听 80/443。
|
||||||
|
|
||||||
|
两个 nginx 抢同一组端口,只有一个能起来 —— 另一个会
|
||||||
|
`Address already in use`。所以现在实际生效的到底是谁的配置,
|
||||||
|
必须搞清楚,否则:
|
||||||
|
|
||||||
|
* 我给 yk.xxmxxs.top 配的证书和反代可能**根本没生效**
|
||||||
|
(之前测通了,说明生效的是我的那份)
|
||||||
|
* 但用户在 1Panel 里建站,可能**建了个寂寞**(1Panel 的 OpenResty 起不来)
|
||||||
|
|
||||||
|
== 查什么 ==
|
||||||
|
|
||||||
|
1. 系统里到底有几个 nginx/OpenResty 可执行文件
|
||||||
|
2. 现在听 80/443 的是哪个进程(看 exe 路径)
|
||||||
|
3. 1Panel 的应用目录里有没有 openresty、有没有建过站
|
||||||
|
4. 1Panel 面板自己在哪个端口
|
||||||
|
5. 有没有已经存在的 WordPress 容器
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
echo "==== 1. 有几个 nginx / openresty ===="
|
||||||
|
for p in /usr/sbin/nginx /usr/local/openresty/nginx/sbin/nginx \
|
||||||
|
/opt/1panel/apps/openresty/openresty/bin/openresty; do
|
||||||
|
[ -x "$p" ] && echo " 有: $p"
|
||||||
|
done
|
||||||
|
command -v nginx openresty 2>/dev/null | sed 's/^/ PATH 里: /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 2. 现在听 80/443 的是谁(看可执行文件路径)===="
|
||||||
|
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443) ' | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 3. 1Panel 的 nginx 有没有在跑 ===="
|
||||||
|
for s in openresty nginx 1panel; do
|
||||||
|
st=$(systemctl is-active $s 2>/dev/null)
|
||||||
|
[ -n "$st" ] && echo " $s: $st"
|
||||||
|
done
|
||||||
|
echo " --- 1Panel 应用目录 ---"
|
||||||
|
sudo ls -1 /opt/1panel/apps/ 2>/dev/null | sed 's/^/ /' || echo " (没有 /opt/1panel/apps)"
|
||||||
|
echo " --- 1Panel 的 www 站点目录 ---"
|
||||||
|
sudo ls -1 /opt/1panel/www/sites/ 2>/dev/null | sed 's/^/ /' || echo " (还没建过站)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 4. 1Panel 面板自己在哪 ===="
|
||||||
|
sudo ss -tlnp 2>/dev/null | grep -i 1panel | sed 's/^/ /'
|
||||||
|
sudo grep -h "Port\|port" /opt/1panel/conf/app.yaml 2>/dev/null | head -3 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 5. 现有的容器 ===="
|
||||||
|
sudo docker ps --format ' {{.Names}} {{.Image}} {{.Status}} {{.Ports}}' 2>/dev/null || echo " (docker 命令不可用)"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 6. 系统 nginx 的配置来自哪 ===="
|
||||||
|
systemctl show nginx -p FragmentPath 2>/dev/null | sed 's/^/ /'
|
||||||
|
nginx -v 2>&1 | sed 's/^/ /'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== 7. 1Panel 有没有装 php/mysql 应用 ===="
|
||||||
|
sudo ls -1 /opt/1panel/apps/ 2>/dev/null | while read a; do
|
||||||
|
echo " 应用 $a:"
|
||||||
|
sudo ls -1 /opt/1panel/apps/$a 2>/dev/null | sed 's/^/ /'
|
||||||
|
done
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 1Panel 和系统 nginx 会不会打架")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
out = run(REMOTE, timeout=240, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把上次查询里两个可疑点查清楚。
|
||||||
|
|
||||||
|
== 疑点一:证书目录为什么是空的 ==
|
||||||
|
|
||||||
|
我明明给 yk.xxmxxs.top 签过 Let's Encrypt 证书。
|
||||||
|
但 `/etc/letsencrypt/live/` 列出来是空的 ——
|
||||||
|
多半是**权限**:那个目录是 0700 root,普通用户 ls 不到。
|
||||||
|
|
||||||
|
== 疑点二:3306 在监听,但没找到 mariadb/mysql ==
|
||||||
|
|
||||||
|
`ss` 显示 127.0.0.1:3306 有人在听,但 `command -v mariadb` 没找到。
|
||||||
|
可能是:
|
||||||
|
* 装了但可执行文件叫别的名字
|
||||||
|
* 是别的东西占了 3306(比如某个容器)
|
||||||
|
得看是哪个进程在听。
|
||||||
|
|
||||||
|
== 疑点三:内存用量涨了 ==
|
||||||
|
|
||||||
|
之前看是 414MB,现在 1037MB。得知道是谁吃的 ——
|
||||||
|
WordPress + MySQL 还要再吃 300~500MB,这台只有 2GB,得先弄清余量。
|
||||||
|
|
||||||
|
== 顺带 ==
|
||||||
|
|
||||||
|
`grep -r /etc/nginx/sites-enabled/` 没输出 server_name,
|
||||||
|
是因为那里全是**指向 sites-available 的符号链接**,
|
||||||
|
而 `grep -r` **默认不跟符号链接**。不是配置丢了。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
echo "==== 1. 证书(带 sudo)===="
|
||||||
|
sudo ls -1 /etc/letsencrypt/live/ 2>&1 | sed 's/^/ /'
|
||||||
|
echo " --- yk.xxmxxs.top 的证书 ---"
|
||||||
|
sudo openssl x509 -in /etc/letsencrypt/live/yk.xxmxxs.top/fullchain.pem \
|
||||||
|
-noout -subject -dates 2>&1 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 2. nginx 站点(跟符号链接)===="
|
||||||
|
sudo grep -h server_name /etc/nginx/sites-available/ykxmz 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo " sites-enabled:"
|
||||||
|
sudo ls -la /etc/nginx/sites-enabled/ 2>&1 | tail -n +2 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 3. 谁在听 3306 ===="
|
||||||
|
sudo ss -tlnp 2>/dev/null | grep 3306 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 4. 数据库到底装没装 ===="
|
||||||
|
dpkg -l 2>/dev/null | grep -iE 'mariadb|mysql' | head -8 | sed 's/^/ /'
|
||||||
|
echo " (上面没输出就是没装 —— 那 3306 是别的东西占的)"
|
||||||
|
echo
|
||||||
|
echo "==== 5. 内存被谁吃了 ===="
|
||||||
|
ps -eo rss,comm --sort=-rss 2>/dev/null | head -10 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo " --- systemd 服务占用 ---"
|
||||||
|
systemd-cgtop -n 1 -b 2>/dev/null | head -8 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 6. 有没有 swap ===="
|
||||||
|
free -m | sed 's/^/ /'
|
||||||
|
swapon --show 2>/dev/null | sed 's/^/ /' || echo " 没有 swap"
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 查清三个疑点")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
out = run(REMOTE, timeout=240, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''看服务器上装 WordPress 缺什么。
|
||||||
|
|
||||||
|
== 背景 ==
|
||||||
|
|
||||||
|
用户想把 xxmxxs.top 拿来做 WordPress(原来是 Cloudflare Workers 占着)。
|
||||||
|
WordPress 要 PHP + 数据库,而服务器上现在只跑着 nginx(给遥控中转用)。
|
||||||
|
|
||||||
|
== 为什么写成文件 ==
|
||||||
|
|
||||||
|
这个查询里有一堆 `$(...)`、`$b`、`2>/dev/null`。
|
||||||
|
用 `python -c "..."` 传的话,**PowerShell 会先在本机把它们求值**
|
||||||
|
(报"无法将 systemctl 项识别为 cmdlet"、"未能找到路径 C:\dev\null")。
|
||||||
|
这个坑今天踩了太多次 —— 凡是超过两行的远端命令,一律写成文件。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "_build"))
|
||||||
|
from lssh import run # noqa: E402
|
||||||
|
|
||||||
|
REMOTE = r'''
|
||||||
|
echo "==== 磁盘 ===="
|
||||||
|
df -h / | tail -1
|
||||||
|
echo
|
||||||
|
echo "==== 内存 ===="
|
||||||
|
free -m | head -2
|
||||||
|
echo
|
||||||
|
echo "==== nginx 站点 ===="
|
||||||
|
ls -1 /etc/nginx/sites-enabled/ 2>/dev/null
|
||||||
|
echo " server_name:"
|
||||||
|
grep -rh server_name /etc/nginx/sites-enabled/ 2>/dev/null | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== PHP ===="
|
||||||
|
if command -v php >/dev/null 2>&1; then
|
||||||
|
php -v | head -1
|
||||||
|
echo " php-fpm: $(systemctl is-active php8.3-fpm 2>/dev/null || systemctl is-active php8.2-fpm 2>/dev/null || echo 没装)"
|
||||||
|
else
|
||||||
|
echo " 没装 PHP"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "==== 数据库 ===="
|
||||||
|
if command -v mariadb >/dev/null 2>&1; then
|
||||||
|
echo " mariadb 客户端在: $(command -v mariadb)"
|
||||||
|
echo " mariadb 服务: $(systemctl is-active mariadb 2>/dev/null)"
|
||||||
|
elif command -v mysql >/dev/null 2>&1; then
|
||||||
|
echo " mysql 客户端在"
|
||||||
|
echo " mysql 服务: $(systemctl is-active mysql 2>/dev/null)"
|
||||||
|
else
|
||||||
|
echo " 没装数据库"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "==== apt 源里有 php-fpm 吗 ===="
|
||||||
|
apt-cache policy php-fpm 2>/dev/null | head -3 | sed 's/^/ /'
|
||||||
|
echo
|
||||||
|
echo "==== 服务状态 ===="
|
||||||
|
for s in nginx ykxmz-relay ykxmz-monitor.timer certbot.timer; do
|
||||||
|
printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)"
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo "==== 证书目录 ===="
|
||||||
|
ls -1 /etc/letsencrypt/live/ 2>/dev/null | sed 's/^/ /' || echo " (空)"
|
||||||
|
echo
|
||||||
|
echo "==== 监听端口 ===="
|
||||||
|
ss -tln 2>/dev/null | grep -E ':(80|443|3306) ' | sed 's/^/ /'
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 服务器 WordPress 就绪度")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
out = run(REMOTE, timeout=240, quiet=True)
|
||||||
|
print(out[1] if isinstance(out, tuple) else out)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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。
|
||||||
@@ -17,7 +17,7 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
VERSION = "2.1.5"
|
VERSION = "2.1.6"
|
||||||
NAME = f"ykxmz-{VERSION}"
|
NAME = f"ykxmz-{VERSION}"
|
||||||
|
|
||||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把「远程鼠标控制」和「一键显示桌面」接进协议 + 服务端。
|
||||||
|
|
||||||
|
== 为什么这两个一起做 ==
|
||||||
|
|
||||||
|
它们共用同一个底座(lanremote/input.py 的 SendInput 封装)。
|
||||||
|
分开做的话底座要写两遍。
|
||||||
|
|
||||||
|
{"cmd":"mouse","action":"move","x":800,"y":450}
|
||||||
|
{"cmd":"mouse","action":"click","button":"left"}
|
||||||
|
{"cmd":"mouse","action":"click","button":"right","double":true}
|
||||||
|
{"cmd":"mouse","action":"down","button":"left"}
|
||||||
|
{"cmd":"mouse","action":"up","button":"left"}
|
||||||
|
{"cmd":"mouse","action":"scroll","amount":3} (正数向上)
|
||||||
|
{"cmd":"mouse","action":"scroll","amount":-3,"horizontal":true}
|
||||||
|
|
||||||
|
{"cmd":"show_desktop"} -> Win+D,用户要求的快捷栏固定按钮
|
||||||
|
|
||||||
|
== 为什么 move 用绝对坐标而不是"相对位移" ==
|
||||||
|
|
||||||
|
手表上那块的触摸位置和电脑屏幕不是 1:1 的(表盘才多大)。
|
||||||
|
相对位移要手表自己算倍率,算错了鼠标就飘。
|
||||||
|
绝对坐标由手表把触摸点按比例映射到 [0,宽]x[0,高],
|
||||||
|
电脑端只管落到那个像素 —— 责任清晰,也不会累积漂移。
|
||||||
|
|
||||||
|
== 安全 ==
|
||||||
|
|
||||||
|
注入鼠标键盘的能力很强,所以**必须登录配对之后才能用** ——
|
||||||
|
这一点由现有的握手逻辑保证(没通过配对,一条命令都不执行,
|
||||||
|
见 _do_pair 和命令分发最前面那道校验)。这里不做额外放松。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
PROTO = ROOT / "windows" / "lanremote" / "protocol.py"
|
||||||
|
SERVER = ROOT / "windows" / "lanremote" / "server.py"
|
||||||
|
|
||||||
|
PROTO_ADD = '''CMD_UPDATE = "update"
|
||||||
|
# 远程鼠标控制:{"action":"move|click|down|up|scroll", ...}
|
||||||
|
CMD_MOUSE = "mouse"
|
||||||
|
# 一键显示桌面(Win+D)。用户要求的快捷栏固定按钮。
|
||||||
|
CMD_SHOW_DESKTOP = "show_desktop"'''
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
# ---- protocol.py ----
|
||||||
|
raw = PROTO.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
if "CMD_MOUSE" in text:
|
||||||
|
print(" protocol.py 已经加过了")
|
||||||
|
else:
|
||||||
|
old = 'CMD_UPDATE = "update"'
|
||||||
|
if old not in text:
|
||||||
|
print(" ✗ protocol.py 找不到 CMD_UPDATE")
|
||||||
|
return 1
|
||||||
|
text = text.replace(old, PROTO_ADD, 1)
|
||||||
|
PROTO.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||||
|
+ text.encode("utf-8"))
|
||||||
|
print(" ✓ protocol.py 加了 CMD_MOUSE / CMD_SHOW_DESKTOP")
|
||||||
|
|
||||||
|
# ---- server.py ----
|
||||||
|
raw = SERVER.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
if "_do_mouse" in text:
|
||||||
|
print(" server.py 已经加过了")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
anchors = {
|
||||||
|
"imports": "from . import updater as U",
|
||||||
|
"update_branch": " if cmd == P.CMD_UPDATE:\n return self._do_update(msg)",
|
||||||
|
"unknown": ' return {"ok": False, "error": f"未知命令: {cmd}"}',
|
||||||
|
}
|
||||||
|
missing = [k for k, v in anchors.items() if v not in text]
|
||||||
|
if missing:
|
||||||
|
print(" ✗ server.py 这些锚点找不到:%s" % ", ".join(missing))
|
||||||
|
return 1
|
||||||
|
print(" ✓ server.py 锚点全部就位")
|
||||||
|
|
||||||
|
text = text.replace(anchors["imports"],
|
||||||
|
anchors["imports"] + "\nfrom . import input as IN", 1)
|
||||||
|
|
||||||
|
text = text.replace(
|
||||||
|
anchors["update_branch"],
|
||||||
|
anchors["update_branch"] + '''
|
||||||
|
|
||||||
|
if cmd == P.CMD_MOUSE:
|
||||||
|
return self._do_mouse(msg)
|
||||||
|
|
||||||
|
if cmd == P.CMD_SHOW_DESKTOP:
|
||||||
|
try:
|
||||||
|
IN.show_desktop()
|
||||||
|
except IN.InputError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
return {"ok": True, "done": "show_desktop"}''', 1)
|
||||||
|
|
||||||
|
handler = '''
|
||||||
|
|
||||||
|
# ---------------- 远程鼠标 / 键盘注入 ----------------
|
||||||
|
|
||||||
|
def _do_mouse(self, msg: dict) -> dict:
|
||||||
|
"""处理 {"cmd":"mouse","action":...}。
|
||||||
|
|
||||||
|
坐标系是**屏幕像素**(相对虚拟屏幕左上角),由手表按比例映射好
|
||||||
|
再发过来 —— 电脑端不做缩放猜测,免得两边算法不一致导致漂移。
|
||||||
|
"""
|
||||||
|
action = str(msg.get("action") or "").lower()
|
||||||
|
try:
|
||||||
|
if action == "move":
|
||||||
|
# 分辨率可能被改过(投屏切换等),每次移动前刷一下缓存。
|
||||||
|
# 不刷的话,插上投影仪之后鼠标会跑到屏幕外。
|
||||||
|
IN.refresh_screen()
|
||||||
|
IN.move_to(int(msg.get("x", 0)), int(msg.get("y", 0)))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if action == "moveby":
|
||||||
|
IN.move_by(int(msg.get("dx", 0)), int(msg.get("dy", 0)))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if action == "click":
|
||||||
|
IN.click(str(msg.get("button") or "left"),
|
||||||
|
double=bool(msg.get("double")))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if action == "down":
|
||||||
|
IN.down(str(msg.get("button") or "left"))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if action == "up":
|
||||||
|
IN.up(str(msg.get("button") or "left"))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if action == "scroll":
|
||||||
|
IN.scroll(int(msg.get("amount", 0)),
|
||||||
|
horizontal=bool(msg.get("horizontal")))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if action == "desktop":
|
||||||
|
IN.show_desktop()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return {"ok": False, "error": "不认识的鼠标动作:%s" % action}
|
||||||
|
except IN.InputError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
return {"ok": False, "error": "参数不对:%s" % exc}
|
||||||
|
|
||||||
|
'''
|
||||||
|
text = text.replace(anchors["unknown"],
|
||||||
|
handler + "\n" + anchors["unknown"], 1)
|
||||||
|
|
||||||
|
SERVER.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ server.py 加了 _do_mouse")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r'''把「静默自动更新」接进协议和服务端。
|
||||||
|
|
||||||
|
== 加两条命令 ==
|
||||||
|
|
||||||
|
{"cmd":"update","action":"check"} -> 查最新版,不改动任何东西
|
||||||
|
{"cmd":"update","action":"start"} -> 真的要更新(下载+静默安装+重启)
|
||||||
|
|
||||||
|
== 为什么"查"和"做"分成两步 ==
|
||||||
|
|
||||||
|
手表点一下按钮,先看到"有新版本 2.1.5(当前 2.1.4,约 9 MB)",
|
||||||
|
再决定要不要升。直接一步到位的话,用户不知道自己触发了什么。
|
||||||
|
|
||||||
|
== start 为什么要开线程 ==
|
||||||
|
|
||||||
|
安装包 9 MB,在学校的网上下载可能要几十秒。
|
||||||
|
如果同步下载,手表那边的请求会一直挂着;而挂太久用户以为卡死了。
|
||||||
|
|
||||||
|
所以:立刻回一句"开始更新了",然后在后台线程里下载 + 校验 + 应用。
|
||||||
|
进度通过 server 自己的日志广播给正在看的手表。
|
||||||
|
|
||||||
|
== 应用之后进程必须退出 ==
|
||||||
|
|
||||||
|
Windows 上正在跑的 exe 是锁着的,安装器覆盖不了。
|
||||||
|
所以 updater 会先起一个**分离的 .cmd 帮手**(不受本进程退出影响),
|
||||||
|
帮手等本进程退出 -> 静默装 -> 重新拉起。
|
||||||
|
这里的顺序不能反。
|
||||||
|
'''
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
PROTO = ROOT / "windows" / "lanremote" / "protocol.py"
|
||||||
|
SERVER = ROOT / "windows" / "lanremote" / "server.py"
|
||||||
|
|
||||||
|
|
||||||
|
def patch_protocol() -> bool:
|
||||||
|
raw = PROTO.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
if "CMD_UPDATE" in text:
|
||||||
|
print(" protocol.py 已经加过了")
|
||||||
|
return True
|
||||||
|
anchor = 'CMD_INFO = "info"'
|
||||||
|
if anchor not in text:
|
||||||
|
print(" ✗ protocol.py 找不到 CMD_INFO")
|
||||||
|
return False
|
||||||
|
text = text.replace(
|
||||||
|
anchor,
|
||||||
|
anchor + "\n"
|
||||||
|
'# 静默自动更新:从**用户自己的中转站**取新版本包,'
|
||||||
|
'静默装好并重启。\n'
|
||||||
|
'# action=check 只看不动;action=start 真的更新。\n'
|
||||||
|
'CMD_UPDATE = "update"', 1)
|
||||||
|
PROTO.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ protocol.py 加了 CMD_UPDATE")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def patch_server() -> bool:
|
||||||
|
raw = SERVER.read_bytes()
|
||||||
|
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||||
|
text = raw.decode("utf-8-sig")
|
||||||
|
if "_do_update" in text:
|
||||||
|
print(" server.py 已经加过了")
|
||||||
|
return True
|
||||||
|
|
||||||
|
anchors = {
|
||||||
|
"info": 'if cmd == P.CMD_INFO:\n return {"ok": True, "info": self.actions.info()}',
|
||||||
|
"unknown": ' return {"ok": False, "error": f"未知命令: {cmd}"}',
|
||||||
|
"imports": "from . import protocol as P",
|
||||||
|
}
|
||||||
|
missing = [k for k, v in anchors.items() if v not in text]
|
||||||
|
if missing:
|
||||||
|
print(" ✗ server.py 这些锚点找不到:%s" % ", ".join(missing))
|
||||||
|
return False
|
||||||
|
print(" ✓ server.py 锚点全部就位")
|
||||||
|
|
||||||
|
# ---- import updater ----
|
||||||
|
text = text.replace(
|
||||||
|
anchors["imports"],
|
||||||
|
anchors["imports"] + "\nfrom . import updater as U", 1)
|
||||||
|
|
||||||
|
# ---- 命令分发 ----
|
||||||
|
text = text.replace(
|
||||||
|
anchors["info"],
|
||||||
|
anchors["info"] + '''
|
||||||
|
|
||||||
|
if cmd == P.CMD_UPDATE:
|
||||||
|
return self._do_update(msg)''', 1)
|
||||||
|
|
||||||
|
# ---- 实现 ----
|
||||||
|
handler = '''
|
||||||
|
|
||||||
|
# ---------------- 静默自动更新 ----------------
|
||||||
|
|
||||||
|
def _do_update(self, msg: dict) -> dict:
|
||||||
|
"""处理 {"cmd":"update","action":...}。
|
||||||
|
|
||||||
|
取包的地方是**用户自己配的中转站**(store 里的 relay_url),
|
||||||
|
不是任何写死的公网地址 —— 这一点是刻意的,见 updater.py 的说明。
|
||||||
|
"""
|
||||||
|
from . import __version__ as my_version
|
||||||
|
|
||||||
|
action = str(msg.get("action") or "check").lower()
|
||||||
|
relay = (self.store.relay_url() or "").strip()
|
||||||
|
|
||||||
|
if action == "check":
|
||||||
|
try:
|
||||||
|
info = U.check(relay, my_version)
|
||||||
|
except U.UpdateError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
if not info.get("supported"):
|
||||||
|
return {"ok": False,
|
||||||
|
"error": info.get("error") or "不支持自动更新"}
|
||||||
|
if "error" in info:
|
||||||
|
return {"ok": False, "error": info["error"]}
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"current": my_version,
|
||||||
|
"latest": info["latest"],
|
||||||
|
"needUpdate": bool(info["needUpdate"]),
|
||||||
|
"size": info.get("size", 0),
|
||||||
|
"name": info.get("name", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
if action != "start":
|
||||||
|
return {"ok": False, "error": "action 只能是 check 或 start"}
|
||||||
|
|
||||||
|
# ---- 真的更新 ----
|
||||||
|
# 先查一遍(在调用方线程里查,失败能立刻报回去)
|
||||||
|
try:
|
||||||
|
info = U.check(relay, my_version)
|
||||||
|
except U.UpdateError as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
if not info.get("supported") or "error" in info:
|
||||||
|
return {"ok": False,
|
||||||
|
"error": info.get("error") or "不支持自动更新"}
|
||||||
|
if not info["needUpdate"]:
|
||||||
|
return {"ok": True, "alreadyLatest": True, "current": my_version,
|
||||||
|
"message": "电脑端已经是最新版本 %s" % my_version}
|
||||||
|
|
||||||
|
# 下载和应用放到后台线程,先回一句让手表知道开始了
|
||||||
|
threading.Thread(
|
||||||
|
target=self._run_update,
|
||||||
|
args=(info, my_version),
|
||||||
|
name="ykxmz-update", daemon=True).start()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"started": True,
|
||||||
|
"from": my_version,
|
||||||
|
"to": info["latest"],
|
||||||
|
"size": info.get("size", 0),
|
||||||
|
"message": "正在下载 %s(%.1f MB),下完会自动装好并重启,"
|
||||||
|
"设置和配对码都不变"
|
||||||
|
% (info["latest"], (info.get("size") or 0) / 1048576.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _run_update(self, info: dict, my_version: str) -> None:
|
||||||
|
"""后台线程:下载 -> 校验 -> 起帮手 -> 自己退出。"""
|
||||||
|
def log(text: str) -> None:
|
||||||
|
try:
|
||||||
|
self._log(text)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
log("[更新] 开始下载 %s(%.1f MB)"
|
||||||
|
% (info["latest"], (info.get("size") or 0) / 1048576.0))
|
||||||
|
work = Path(tempfile.gettempdir()) / "ykxmz-update"
|
||||||
|
last = [0]
|
||||||
|
|
||||||
|
def progress(got: int, total: int) -> None:
|
||||||
|
# 每 25% 报一次,别把日志刷爆
|
||||||
|
if total:
|
||||||
|
pct = int(got * 100 / total)
|
||||||
|
if pct - last[0] >= 25:
|
||||||
|
last[0] = pct
|
||||||
|
log("[更新] 已下载 %d%%(%.1f/%.1f MB)"
|
||||||
|
% (pct, got / 1048576.0, total / 1048576.0))
|
||||||
|
|
||||||
|
pkg = U.download(info["url"], work,
|
||||||
|
info.get("sha256", ""), info.get("size", 0),
|
||||||
|
progress=progress)
|
||||||
|
log("[更新] 下载完成并校验通过:%s" % pkg.name)
|
||||||
|
|
||||||
|
# 应用:起分离的 .cmd 帮手,然后**本进程必须退出**
|
||||||
|
app_exe = Path(sys.executable if getattr(sys, "frozen", False)
|
||||||
|
else sys.argv[0]).resolve()
|
||||||
|
logfile = U.apply_update(pkg, app_exe)
|
||||||
|
log("[更新] 已交给更新帮手(%s),被控端即将退出并重启" % logfile)
|
||||||
|
U.cleanup_old_downloads()
|
||||||
|
|
||||||
|
# 给日志和网络一点时间落完,再退出
|
||||||
|
time.sleep(2)
|
||||||
|
os._exit(0)
|
||||||
|
except U.UpdateError as exc:
|
||||||
|
log("[更新] 失败:%s(保持当前版本 %s)" % (exc, my_version))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log("[更新] 意外失败:%s: %s" % (type(exc).__name__, exc))
|
||||||
|
|
||||||
|
'''
|
||||||
|
text = text.replace(anchors["unknown"],
|
||||||
|
handler + "\n" + anchors["unknown"], 1)
|
||||||
|
|
||||||
|
# ---- 补 import ----
|
||||||
|
need = []
|
||||||
|
head = text.split("class ", 1)[0]
|
||||||
|
for mod, line in (("tempfile", "import tempfile"),
|
||||||
|
("sys", "import sys"),
|
||||||
|
("threading", "import threading"),
|
||||||
|
("time", "import time"),
|
||||||
|
("os", "import os"),
|
||||||
|
("Path", "from pathlib import Path")):
|
||||||
|
if line not in head:
|
||||||
|
need.append(line)
|
||||||
|
if need:
|
||||||
|
anchor = "from . import protocol as P"
|
||||||
|
text = text.replace(anchor, "\n".join(need) + "\n" + anchor, 1)
|
||||||
|
print(" ✓ 补了 import: %s" % ", ".join(need))
|
||||||
|
|
||||||
|
SERVER.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||||
|
print(" ✓ server.py 加了 _do_update / _run_update")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=" * 58)
|
||||||
|
print(" 接「静默自动更新」进协议和服务端")
|
||||||
|
print("=" * 58)
|
||||||
|
print()
|
||||||
|
ok = patch_protocol()
|
||||||
|
print()
|
||||||
|
ok = patch_server() and ok
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user