2.3.0:手表界面精简 + 播放器 + 鼠标重做

手表端
- 功能页从 10 个杂项精简到 7 个(屏幕/鼠标/媒体/文件/电脑/语音/电源)
- 删掉:游戏封锁、待办、掷骰子、抽奖、壁纸、显示器开关、定时关机、
  剪贴板历史、性能/磁盘/电量、文件夹浏览、教室限制等
- 改成显式列按钮,不再把电脑端所有功能组无脑铺到表盘
- 远程鼠标控制重做:滑动区 + 左键/滚轮/右键三按钮
  滚轮点按=中键、上下滑=滚动
- 新增视频/音乐播放器(手表本地播放):进度拖动、左右滑快进退10秒、
  点画面播放暂停、上一/下一、播完自动下一首

电脑端
- 新增 kill_all(结束所有进程),白名单保护系统关键进程和自身
- 新增 media_list / media_pull(播放器数据源)
  安全边界:只允许媒体目录,用 relative_to 判断,防同前缀兄弟目录绕过

中转站
- known_devices 从 ID 集合改成「设备ID -> 配对码」映射
- 修复:管理员改码后关机超过 6 小时,改码会被超时清理冲掉
- 老格式 state 文件平滑升级
This commit is contained in:
xmz7891
2026-10-18 11:39:14 +08:00
parent 1d368979aa
commit 31cea2ada0
50 changed files with 13812 additions and 584 deletions
+3 -2
View File
@@ -80,9 +80,10 @@ admin_password.txt
/_build/_hdr_data/
/_build/relay_info.json
# 后台管理密码(绝不进仓库)
_build/.admin_password
_build/.admin_password
# 服务器地址(绝不进仓库)
_build/.lightsail_host
_build/.gitee_token
_build/.admin_password
_build/.admin_password
_build/.halo_password
+8 -8
View File
@@ -45,9 +45,9 @@
| 装在哪 | 是什么 | 文件 |
|---|---|---|
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.2.0.exe` |
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.2.0.zip` |
| 手表(安卓) | 控制端 | `ykxmz-watch-2.2.0.apk` |
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.3.0.exe` |
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.3.0.zip` |
| 手表(安卓) | 控制端 | `ykxmz-watch-2.3.0.apk` |
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
@@ -81,7 +81,7 @@
### 第一步:电脑上装被控端
双击 `ykxmz-setup-2.2.0.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
双击 `ykxmz-setup-2.3.0.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
第一次运行会弹出窗口,填两样:
@@ -94,12 +94,12 @@
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
> 想彻底退出,窗口上点「退出」。
> 不想用安装包的话,`ykxmz-agent-2.2.0.zip` 是绿色版:
> 不想用安装包的话,`ykxmz-agent-2.3.0.zip` 是绿色版:
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
### 第二步:手表上装 App
把 `ykxmz-watch-2.2.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
把 `ykxmz-watch-2.3.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
打开 App,首页有四个入口:
@@ -223,7 +223,7 @@
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
把 `ykxmz-server-2.2.0.zip` 解压到那台机器上,然后一条命令:
把 `ykxmz-server-2.3.0.zip` 解压到那台机器上,然后一条命令:
```bash
bash deploy.sh
@@ -456,7 +456,7 @@ windows\build_exe.ps1
```powershell
android\build_apk.ps1
# 流程:aapt2 → javac → d8 → zipalign → apksigner
# 产物:dist\ykxmz-watch-2.2.0.apk
# 产物:dist\ykxmz-watch-2.3.0.apk
```
最低支持 Android 7.0(API 24),targetSdk 34。
+87
View File
@@ -1,3 +1,90 @@
## 2.3.0
- **手表端界面大改** —— 用户原话「杂七杂八功能都删掉」。功能页从 10 个杂项分页
精简成 7 个明确的:
| 分页 | 内容 |
|---|---|
| 屏幕 | 屏幕镜像 / 返回桌面 / 亮度 + / 亮度 - / 截屏 |
| 鼠标 | 远程鼠标控制 |
| 媒体 | 播放暂停 / 上一曲 / 下一曲 / 音量 +- / 静音 / **播放器** |
| 文件 | 发送文件 / 打开回收站 |
| 电脑 | 打开网址 / 快捷命令 / 进程管理 / 窗口置顶 / 结束所有进程 |
| 语音 | 语音播报 / 朗读剪贴板 |
| 电源 | 锁屏 / 睡眠 / 关机 / 重启 |
**删掉的**:游戏封锁、待办、掷骰子、抽奖、抛硬币、壁纸、显示器开关、
定时关机、剪贴板历史、性能/磁盘/电量、文件夹浏览、教室限制……
> 改了实现方式:以前每个分页靠 `TabDef.groups` 把电脑端上报的功能组
> 无脑铺上来 —— 电脑端加个功能,手表上就多一堆按钮。**那正是"乱"的来源。**
> 现在改成**显式列按钮**:手表上有什么,完全由代码决定。
- **远程鼠标控制重做** —— 用户要求「中间是一个鼠标,可以上下左右滑动控制,
下面有三个按钮,左边是左键,中间是那个滑轮,右边是那个右键」。
```
┌──────────────────────────┐
│ 滑动区(占满) │ 手指滑动 = 移动光标
├────────┬────────┬────────┤
│ 左键 │ 滚轮 │ 右键 │
└────────┴────────┴────────┘
```
滚轮按钮:**点一下 = 中键单击**,**上下滑 = 滚动**。
三个按钮就把左/中/右 + 滚轮全覆盖了。
- **新增视频 / 音乐播放器** —— 用户要求「有暂停开始,进度左右划,
下一个上一个」。
**设计取舍:让手表自己放,而不是让电脑放。**
如果让电脑放(调 `play_video`),电脑会打开它自己的播放器 ——
我们就**拿不到进度、也没法精确 seek**,那"进度左右划"就是假的。
手表自己放的话 `MediaPlayer` / `VideoView` 的
`getCurrentPosition` / `seekTo` 都是现成的,进度条和拖动全部真实可控。
代价:播放前要先把文件从电脑下到手表(走现有的分块传输)。
操作:
* 进度条拖动 = seek
* **画面区左右滑 = 快进/快退 10 秒**
* **点画面 = 播放/暂停**
* 上一 / 下一(播完自动下一首)
* 列表 = 从电脑的音乐/视频/下载/桌面目录里挑
- **电脑端新增 `kill_all`(一键结束所有进程)** ——
**白名单式保护**:本程序自己的 PID、父进程链、一批系统关键进程
(csrss / winlogon / lsass / svchost / explorer / dwm …)全部跳过,
否则一按就蓝屏或桌面消失。其余全杀,返回杀了几个、跳过几个、失败几个。
- **电脑端新增 `media_list` / `media_pull`** —— 播放器的数据源。
**安全边界**:只允许「音乐/视频/下载/桌面/接收目录」里的文件,
用 `Path.relative_to` 判断(不是字符串前缀 —— 那样「音乐-evil」
这种同前缀兄弟目录会被误判成子目录)。
- **中转站:管理员改过的配对码不再因关机 >6 小时丢失** 🔧
之前这条链会断:
1. 管理员在后台把码 `111111` 改成 `222222`
2. 那台电脑关机(或断网)超过 `DEVICE_TTL`(6 小时)
3. `snapshot()` 把设备删了,孤儿的码 `222222` 也被当垃圾清掉
4. 电脑开机,报自己本地的旧码 `111111`
5. 中转已经不记得改过码 → 收下 `111111`
6. **管理员的改码白做了**,手表手里是 `222222`,连不上
修法:`known_devices` 从「纯 ID 集合」改成「**设备 ID → 配对码**」映射。
改码时会同步写进历史,超时清理不动它。设备重新登记时以历史里的码为准,
并随 `register()` 的返回值告诉电脑端,**电脑端写回本地设置**。
兼容性:老格式的 `relay_state.json`(`knownDevices` 是纯列表)
能平滑升级 —— 加载时识别成「认识这台设备,但不知道它该用哪个码」,
下次它上报什么就用什么(跟改动之前一致)。
「删除设备」仍然忘干净(连历史一起删);「抹码」会清空历史里记的码
但保留设备 ID —— 否则抹码会白做。
## 2.2.0
- **手表端:输入配对码连上之后,先到一个选择页** ——
+121
View File
@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
r'''第一步:给服务器加 1G swap,并调好内存参数。
== 为什么必须先做这个 ==
机器只有 **1936MB 内存,而且一个 swap 都没有**。现在用了 566MB,
看着还有富余 —— 但 Halo 是 **Java 应用**,JVM 默认会按物理内存的
1/4 去要堆(1936/4 ≈ 470MB),加上元空间、线程栈、直接内存,
很容易冲到 700MB+。
**没有 swap 的时候,内存一紧张就是 OOM Killer 直接杀进程** ——
表现是"博客隔一阵就自己挂了",而且 systemd 里只留一句
`Killed process (java)`,很难查。
有 1G swap 就多了一层缓冲:平时不用它(性能不受影响),
突发时才借用,扛过去就没事了。
== 参数怎么选 ==
* **大小 1G**(用户指定)
* **swappiness=10** —— 默认是 60,意思是"内存用到 60% 就开始往
swap 里挪"。对服务器来说这太激进了,会把常用的页换出去导致变卡。
10 表示"只有真要撑不住时才用 swap",正合适。
* **vfs_cache_pressure=50** —— 默认 100,让内核更愿意保留
dentry/inode 缓存(文件操作多的时候有用)。
* **写进 /etc/fstab** —— 不然重启就没了。
== 一个坑 ==
`swapon` 之后再 `free -m` 才看得到。而且**如果 fstab 写错,
系统可能起不来**(fstab 里有不存在的设备会进 emergency mode)。
所以写完要用 `mount -a` 试一遍,能过才算数。
'''
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'''
set -e
echo "==== 加之前 ===="
free -m | sed 's/^/ /'
echo
if swapon --show 2>/dev/null | grep -q .; then
echo " 已经有 swap 了,跳过创建:"
swapon --show | sed 's/^/ /'
else
echo "==== 创建 1G swap 文件 ===="
# fallocate 比 dd 快得多,而且是稀疏分配 —— 但**有些文件系统
# (比如 btrfs / 某些 overlay)上 fallocate 出来的文件不能当 swap**,
# 所以先试 fallocate,失败就退回 dd。
if ! sudo fallocate -l 1G /swapfile 2>/dev/null; then
echo " fallocate 不行,改用 dd(慢一点但通用)"
sudo dd if=/dev/zero of=/swapfile bs=1M count=1024 status=none
fi
sudo chmod 600 /swapfile
sudo mkswap /swapfile >/dev/null
sudo swapon /swapfile
echo " ✓ 已启用"
fi
echo
echo "==== 写进 /etc/fstab(重启后自动挂)===="
if grep -qE '^\s*/swapfile\s' /etc/fstab; then
echo " fstab 里已经有了"
else
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab >/dev/null
echo " ✓ 已写入"
fi
echo
echo "==== 调内核参数 ===="
sudo tee /etc/sysctl.d/99-ykxmz-swap.conf >/dev/null <<'EOF'
# swap 调优(配合 halo 博客)
#
# swappiness 默认 60 太激进 —— 内存用到 60% 就往 swap 挪,
# 会把常用页换出去导致变卡。10 = 只有真要撑不住时才用。
vm.swappiness = 10
# 默认 100,改成 50 让内核更愿意保留文件缓存
vm.vfs_cache_pressure = 50
EOF
sudo sysctl -p /etc/sysctl.d/99-ykxmz-swap.conf | sed 's/^/ /'
echo
echo "==== 校验 fstab(写错会导致开不了机!)===="
if sudo mount -a 2>&1 | grep -q .; then
echo " ⚠ fstab 有问题:"
sudo mount -a 2>&1 | sed 's/^/ /'
else
echo " ✓ fstab 没问题(mount -a 无报错)"
fi
echo
echo "==== 加之后 ===="
free -m | sed 's/^/ /'
echo
echo " --- swap 详情 ---"
swapon --show | sed 's/^/ /'
echo " --- 当前参数 ---"
printf ' swappiness = %s\n' "$(cat /proc/sys/vm/swappiness)"
printf ' vfs_cache_pressure = %s\n' "$(cat /proc/sys/vm/vfs_cache_pressure)"
'''
def main() -> int:
print("=" * 62)
print(" 第一步:加 1G swap")
print("=" * 62)
print()
out = run(REMOTE, timeout=400, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
"""删掉指定名字的 Java 方法(按大括号配对,不用正则瞎猜)。
== 为什么不用正则 ==
方法体里可能有字符串、注释、嵌套大括号。正则很难正确跳过这些,
删一半会把文件搞坏。这里做一个**简单但正确**的扫描器:
* 逐字符走
* 跳过 `//` 行注释、`/* */` 块注释、`"..."` 字符串、`'c'` 字符字面量
* 统计 `{` `}` 深度,深度回到 0 就是方法结束
* 顺便把方法**前面的 javadoc / 注解行**一起删干净(否则留下一堆
没头没脑的注释)
== 安全措施 ==
* 名字必须能唯一定位到一个方法定义
* 删完立刻检查大括号总数是否守恒(删之前总的 `{`/`}` 数减去
这个方法里的数量,应该等于删之后的)
* 任何一步不对就抛异常,不写文件
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
JAVA = (Path(__file__).resolve().parent.parent
/ "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java")
def find_method_span(text: str, name: str):
"""返回 (start, end) —— 整个方法(含前面的注释/注解)的字符区间。"""
# 找到定义行
pat = re.compile(
r"^[ \t]*(?:@\w+(?:\([^)]*\))?\s*)*"
r"(?:private|protected|public|static|final|synchronized|\s)*"
r"(?:[\w<>\[\],.?\s]+?)\s+" + re.escape(name) + r"\s*\([^)]*\)\s*"
r"(?:throws [\w,.\s]+)?\s*\{",
re.M)
matches = list(pat.finditer(text))
if len(matches) != 1:
raise SystemExit("✗ %s: 找到 %d 个定义(要恰好 1 个)"
% (name, len(matches)))
m = matches[0]
def_start = m.start()
# 往上吃掉紧邻的注释行和注解行
line_start = text.rfind("\n", 0, def_start) + 1
start = line_start
while True:
prev_end = start - 1
if prev_end <= 0:
break
prev_start = text.rfind("\n", 0, prev_end) + 1
line = text[prev_start:prev_end].strip()
if not line:
break
if (line.startswith(("*", "//", "/*")) or line.endswith("*/")
or line.startswith("@")):
start = prev_start
continue
break
# 从 { 开始找匹配的 }
i = text.index("{", m.start())
depth = 0
n = len(text)
while i < n:
ch = text[i]
# 行注释
if ch == "/" and i + 1 < n and text[i + 1] == "/":
i = text.find("\n", i)
if i < 0:
raise SystemExit("✗ %s: 行注释没结束" % name)
continue
# 块注释
if ch == "/" and i + 1 < n and text[i + 1] == "*":
i = text.find("*/", i + 2)
if i < 0:
raise SystemExit("✗ %s: 块注释没结束" % name)
i += 2
continue
# 字符串
if ch == '"':
i += 1
while i < n and text[i] != '"':
if text[i] == "\\":
i += 1
i += 1
i += 1
continue
# 字符字面量
if ch == "'":
i += 1
while i < n and text[i] != "'":
if text[i] == "\\":
i += 1
i += 1
i += 1
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return start, i + 1
i += 1
raise SystemExit("✗ %s: 大括号没配平" % name)
def main() -> int:
names = sys.argv[1:]
if not names:
print("用法: find_orphans.py <方法名> [方法名...]")
return 1
text = JAVA.read_text(encoding="utf-8")
before_open = text.count("{")
before_close = text.count("}")
removed_total = 0
for name in names:
start, end = find_method_span(text, name)
chunk = text[start:end]
removed_total += chunk.count("{")
# 连同后面的空行一起删
tail = end
while tail < len(text) and text[tail] in "\r\n":
tail += 1
text = text[:start] + text[tail:]
print(" ✓ 删除 %-22s %4d 字符" % (name, tail - start))
# 大括号守恒检查
if text.count("{") != before_open - removed_total:
raise SystemExit("✗ 大括号数量不对(删前 %d,删掉 %d,现在 %d)"
% (before_open, removed_total, text.count("{")))
if text.count("{") != text.count("}"):
raise SystemExit("✗ 大括号不配对:{ %d 个,} %d 个"
% (text.count("{"), text.count("}")))
JAVA.write_text(text, encoding="utf-8", newline="\n")
print()
print(" 大括号守恒 ✓({%d / }%d)" % (text.count("{"), text.count("}")))
return 0
if __name__ == "__main__":
sys.exit(main())
+765
View File
@@ -0,0 +1,765 @@
# -*- coding: utf-8 -*-
r'''ykxmz 诊断工具 —— 在出问题的机器上跑一遍,把所有可疑点问清楚。
== 为什么做这个 ==
用户反馈「Win7 上语音播报不能用」。但远程猜不出来 —— 可能是:
* 没装任何 TTS 语音(精简版系统常见)
* 没有音频设备 / 音频服务没启动(SAPI 会**静默失败**,退出码还是 0)
* cscript / powershell 被删了
* 权限问题(写不了临时文件)
* 或者是别的完全没想到的原因
**猜来猜去不如让它自己说。** 这个程序把每一项都试一遍,
输出一段文本,用户复制回来,我照着改。
== 设计原则 ==
1. **绝不改动机器上的任何东西** —— 只读 + 在 %TEMP% 里写临时文件(跑完删掉)
2. **每一项都要有明确结论**(可用/不可用 + 为什么),不是只打印一堆原始数据
3. **每一项都单独 try** —— 某一项崩了不能影响后面的
4. **输出要能复制** —— 同时打印到屏幕 + 写到桌面上的 .txt
5. **32 位 + Python 3.8** —— 和主程序一样,保证 Win7 能跑
== 探测什么 ==
一、系统环境 版本/位数/代码页/管理员/临时目录可写
二、语音能力 ★ 重点:5 种方案逐个试 + 语音列表 + 音频设备 + 音频服务
三、外部程序 cscript / wscript / powershell / cmd 在不在、什么版本
四、.NET 环境 System.Speech 依赖它
五、屏幕和输入 分辨率 / DPI / 能否截屏 / 能否注入鼠标
六、网络 能不能连上中转站
七、主程序状态 装在哪、数据目录、版本、有没有在跑
'''
from __future__ import annotations
import ctypes
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
import time
import traceback
from pathlib import Path
IS_WINDOWS = os.name == "nt"
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
OUT: list = []
REPORT_PATH = None
# ---------------------------------------------------------------- 输出
def _console_encode(text: str) -> bytes:
r'''把要打印的文字编码成**控制台能正确显示**的字节。
== 为什么不用 UTF-8 ==
第一版我强行 `chcp 65001` + `sys.stdout.reconfigure(encoding="utf-8")`,
结果在 Win7 上**满屏问号**。
原因有两层:
1. Win7 的 cmd 即使切到 65001,**默认点阵字体也不含中文字形**
-> 显示成方块或问号
2. 用户看到的 `???` 是字体层的问题,不是编码层的
**最稳的做法**:按**系统 ANSI 代码页**(中文 Windows = GBK/936)输出。
这正是 cmd 默认期望的编码,配合默认字体一定能显示。
'''
for enc in ("mbcs", "gbk", "utf-8"):
try:
return text.encode(enc, errors="replace")
except Exception: # noqa: BLE001
continue
return text.encode("ascii", errors="replace")
def say(line: str = "") -> None:
"""同时打印到屏幕(本机代码页)和报告(UTF-8)。"""
try:
sys.stdout.buffer.write(_console_encode(line) + b"\r\n")
sys.stdout.buffer.flush()
except Exception: # noqa: BLE001
try:
print(line, flush=True)
except Exception: # noqa: BLE001
pass
OUT.append(line)
def head(title: str) -> None:
say()
say("=" * 64)
say(" " + title)
say("=" * 64)
def item(name: str, ok, detail: str = "") -> None:
"""一行结论。ok 可以是 True/False/None(None = 只是信息)。"""
if ok is True:
mark = "[ 可用 ]"
elif ok is False:
mark = "[不可用]"
else:
mark = "[ 信息 ]"
say(" %s %-34s %s" % (mark, name, detail))
def safe(fn, *a, **kw):
"""跑一个探测,出错不让它影响后面的。"""
try:
return fn(*a, **kw)
except Exception as exc: # noqa: BLE001
return "【探测出错】%s: %s" % (type(exc).__name__, exc)
def run(cmd, timeout=30, encoding=None):
"""跑命令,返回 (退出码, 输出)。异常都吃掉,转成文字。"""
try:
p = subprocess.run(cmd, capture_output=True, timeout=timeout,
creationflags=NO_WINDOW,
encoding=encoding or "mbcs", errors="replace")
out = ((p.stdout or "") + (p.stderr or "")).strip()
return p.returncode, out
except FileNotFoundError:
return -1, "(找不到这个程序)"
except subprocess.TimeoutExpired:
return -2, "(超时 %d 秒)" % timeout
except Exception as exc: # noqa: BLE001
return -3, "%s: %s" % (type(exc).__name__, exc)
# ================================================================ 一、系统
def probe_system() -> None:
head("一、系统环境")
try:
v = sys.getwindowsversion()
item("Windows 版本", True, "%d.%d (build %d)" % (v.major, v.minor, v.build))
# Win7 = 6.1
if (v.major, v.minor) == (6, 1):
say(" ^ 这是 Windows 7")
elif (v.major, v.minor) == (6, 0):
say(" ^ 这是 Windows Vista")
except Exception: # noqa: BLE001
item("Windows 版本", None, "取不到")
item("Python 位数", True, "%d 位" % (64 if sys.maxsize > 2 ** 32 else 32))
item("Python 版本", True, sys.version.split()[0])
item("进程是否 64 位", True,
"是" if ctypes.sizeof(ctypes.c_void_p) == 8 else "否(32 位进程)")
# 管理员?
try:
admin = bool(ctypes.windll.shell32.IsUserAnAdmin())
except Exception: # noqa: BLE001
admin = None
item("管理员权限", admin, "有" if admin else "没有(普通用户)")
# 代码页 —— 中文乱码问题跟它有关
try:
cp = ctypes.windll.kernel32.GetACP()
item("系统 ANSI 代码页", True, "%d(%s)" % (cp, "中文 GBK" if cp == 936 else "其它"))
except Exception: # noqa: BLE001
item("系统 ANSI 代码页", None, "取不到")
# 临时目录能不能写
try:
t = Path(tempfile.gettempdir())
p = t / "_ykxmz_diag_test.txt"
p.write_text("ok", encoding="utf-8")
p.unlink()
item("临时目录可写", True, str(t))
except Exception as exc: # noqa: BLE001
item("临时目录可写", False, "%s: %s" % (type(exc).__name__, exc))
item("程序自身路径", None, sys.executable)
item("当前工作目录", None, os.getcwd())
# ================================================================ 二、语音
VBS_TEMPLATE = (
'Dim v\r\n'
'Set v = CreateObject("SAPI.SpVoice")\r\n'
'v.Speak "{text}"\r\n'
)
def probe_tts() -> None:
head("二、语音播报能力(重点)")
TEXT = "语音测试一二三"
# ---- 2.1 外部程序在不在 ----
say()
say(" --- 2.1 依赖的外部程序 ---")
sysroot = os.environ.get("SystemRoot", r"C:\Windows")
for name in ("cscript", "wscript", "powershell", "cmd"):
found = shutil.which(name)
# 再按绝对路径找一遍(PATH 里没有时)
abs_guess = {
"cscript": sysroot + r"\System32\cscript.exe",
"wscript": sysroot + r"\System32\wscript.exe",
"powershell": sysroot + r"\System32\WindowsPowerShell\v1.0\powershell.exe",
"cmd": sysroot + r"\System32\cmd.exe",
}.get(name, "")
abs_ok = bool(abs_guess) and Path(abs_guess).is_file()
if found:
item(name, True, "PATH 里有: %s" % found)
elif abs_ok:
item(name, True, "PATH 里没有,但绝对路径有: %s" % abs_guess)
else:
item(name, False, "PATH 和绝对路径都找不到")
# powershell 版本
ps = shutil.which("powershell") or (
sysroot + r"\System32\WindowsPowerShell\v1.0\powershell.exe")
if Path(ps).is_file():
code, out = run([ps, "-NoProfile", "-Command",
"$PSVersionTable.PSVersion.Major"], timeout=20)
item("PowerShell 主版本", code == 0, out.strip() or "查不到")
code, out = run([ps, "-NoProfile", "-Command",
"[System.Environment]::Version.ToString()"], timeout=20)
item(".NET CLR 版本", code == 0, out.strip() or "查不到")
# ---- 2.2 音频设备和音频服务 ----
say()
say(" --- 2.2 音频环境(SAPI 没声音的头号嫌疑)---")
code, out = run(["sc", "query", "Audiosrv"], timeout=20)
running = "RUNNING" in (out or "").upper()
item("音频服务 Audiosrv", running,
"正在运行" if running else "没在运行(没声音多半是它)")
# ⚠ 第一版这里用了 `Get-CimInstance` —— 那是 **PowerShell 3.0+** 的 cmdlet。
# Win7 自带 **PowerShell 2.0**,直接报
# "The term 'Get-CimInstance' is not recognized"。
# 改用 **`Get-WmiObject`**(PS 2.0 就有,3.0+ 也还在)。
say(" (以下用 Get-WmiObject / wmic,兼容 PowerShell 2.0)")
if Path(ps).is_file():
code, out = run([
ps, "-NoProfile", "-Command",
"Get-WmiObject Win32_SoundDevice | "
"ForEach-Object { $_.Name + ' | ' + $_.Status }"], timeout=30)
lines = [x.strip() for x in (out or "").splitlines() if x.strip()]
bad = [x for x in lines
if "not recognized" in x or "CategoryInfo" in x
or "At line" in x or "FullyQualified" in x]
if bad:
item("音频输出设备(WMI)", False, bad[0][:90])
elif lines:
item("音频输出设备(WMI)", True, "共 %d 个" % len(lines))
for x in lines[:5]:
say(" - " + x)
else:
item("音频输出设备(WMI)", False,
"**一个都没有** —— 虚拟机常见(没加声卡),SAPI 会静默失败")
# 再用 wmic 独立验一遍(完全不依赖 PowerShell)
code, out = run(["wmic", "sounddev", "get", "Name,Status"],
timeout=30, encoding="mbcs")
if code == 0:
rows = [x.strip() for x in (out or "").splitlines()
if x.strip() and x.strip().lower() not in ("name status",
"name", "status")]
item("音频输出设备(wmic)", bool(rows),
("共 %d 个: %s" % (len(rows), " / ".join(rows[:3]))) if rows
else "**一个都没有**")
# SAPI 自己怎么看音频输出
if Path(ps).is_file():
code, out = run([
ps, "-NoProfile", "-Command",
"$v = New-Object -ComObject SAPI.SpVoice;"
"try { $v.AudioOutput.GetDescription() } "
"catch { 'ERR: ' + $_.Exception.Message }"], timeout=30)
txt = (out or "").strip()
item("SAPI 默认音频输出", code == 0 and not txt.startswith("ERR"),
txt[:100] or "拿不到")
# ---- 2.3 SAPI 语音列表 ----
say()
say(" --- 2.3 装了哪些语音 ---")
voices_ps = []
if Path(ps).is_file():
code, out = run([
ps, "-NoProfile", "-Command",
"$v = New-Object -ComObject SAPI.SpVoice;"
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }"],
timeout=25)
voices_ps = [x.strip() for x in (out or "").splitlines() if x.strip()]
if code == 0 and voices_ps:
item("SAPI5 语音(COM 方式)", True, "共 %d 个" % len(voices_ps))
for v in voices_ps[:10]:
say(" - " + v)
else:
item("SAPI5 语音(COM 方式)", False,
"列不出来 (退出码 %s) %s" % (code, out[:80]))
# System.Speech 那条路
if Path(ps).is_file():
code, out = run([
ps, "-NoProfile", "-Command",
"Add-Type -AssemblyName System.Speech;"
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"],
timeout=30)
names = [x.strip() for x in (out or "").splitlines() if x.strip()]
item("System.Speech 语音", code == 0 and bool(names),
"共 %d 个: %s" % (len(names), "、".join(names[:5]))
if names else "列不出来 (退出码 %s) %s" % (code, (out or "")[:80]))
# ---- 2.4 五种方案逐个试 ----
say()
say(" --- 2.4 五种发声方案逐个试 ---")
say(" (注意:能出声才算数。这里只能看退出码 + 耗时)")
vbs = Path(tempfile.gettempdir()) / "_ykxmz_diag_say.vbs"
try:
vbs.write_bytes(
(VBS_TEMPLATE.replace("{text}", TEXT)).encode("mbcs",
errors="replace"))
vbs_ok = True
item("写 VBS 文件", True, "%s (%d 字节)" % (vbs, vbs.stat().st_size))
except Exception as exc: # noqa: BLE001
vbs_ok = False
item("写 VBS 文件", False, "%s: %s" % (type(exc).__name__, exc))
attempts = []
if vbs_ok:
attempts.append(("1. cscript + VBScript",
["cscript", "//nologo", str(vbs)]))
attempts.append(("2. wscript + VBScript",
["wscript", "//nologo", str(vbs)]))
if Path(ps).is_file():
attempts.append(("3. powershell + SAPI COM", [
ps, "-NoProfile", "-WindowStyle", "Hidden", "-Command",
"$v = New-Object -ComObject SAPI.SpVoice; $v.Speak('%s')" % TEXT]))
attempts.append(("4. powershell + System.Speech", [
ps, "-NoProfile", "-WindowStyle", "Hidden", "-Command",
"Add-Type -AssemblyName System.Speech;"
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
"$s.Speak('%s')" % TEXT]))
for label, cmd in attempts:
exe = cmd[0]
# 用绝对路径兜底
if not shutil.which(exe):
cand = {
"cscript": sysroot + r"\System32\cscript.exe",
"wscript": sysroot + r"\System32\wscript.exe",
}.get(exe)
if cand and Path(cand).is_file():
cmd = [cand] + list(cmd[1:])
exe = cand
t0 = time.perf_counter()
code, out = run(cmd, timeout=60)
secs = time.perf_counter() - t0
ok = code == 0
item(label, ok, "退出码 %s,耗时 %.1fs%s"
% (code, secs, (" " + out[:70]) if out else ""))
# 清理
try:
if vbs.exists():
vbs.unlink()
except Exception: # noqa: BLE001
pass
say()
say(" ⚠ 如果上面全部「可用」但实际没声音,问题在音频侧:")
say(" * 音频服务没运行")
say(" * 没有音频输出设备(耳机孔也没插)")
say(" * 或者装了语音但系统音量/静音设置有问题")
# ================================================================ 三、屏幕输入
def probe_ui() -> None:
head("三、屏幕和输入")
if not IS_WINDOWS:
say(" 非 Windows,跳过")
return
u = ctypes.windll.user32
try:
w = u.GetSystemMetrics(0)
h = u.GetSystemMetrics(1)
item("主屏分辨率", True, "%dx%d" % (w, h))
except Exception as exc: # noqa: BLE001
item("主屏分辨率", False, str(exc))
try:
vw = u.GetSystemMetrics(78) # SM_CXVIRTUALSCREEN
vh = u.GetSystemMetrics(79)
item("虚拟屏尺寸", True, "%dx%d(多显示器会更大)" % (vw, vh))
except Exception: # noqa: BLE001
pass
# DPI
try:
dc = u.GetDC(0)
dpi = ctypes.windll.gdi32.GetDeviceCaps(dc, 88) # LOGPIXELSX
u.ReleaseDC(0, dc)
item("DPI", True, "%d(100%% 是 96)" % dpi)
except Exception: # noqa: BLE001
pass
# 截屏能力
try:
gdi = ctypes.windll.gdi32
dc = u.GetDC(0)
mem = gdi.CreateCompatibleDC(dc)
bmp = gdi.CreateCompatibleBitmap(dc, 100, 100)
gdi.SelectObject(mem, bmp)
ok = bool(gdi.BitBlt(mem, 0, 0, 100, 100, dc, 0, 0, 0x00CC0020))
gdi.DeleteObject(bmp)
gdi.DeleteDC(mem)
u.ReleaseDC(0, dc)
item("能否截屏(BitBlt)", ok, "成功" if ok else "失败")
except Exception as exc: # noqa: BLE001
item("能否截屏(BitBlt)", False, "%s: %s" % (type(exc).__name__, exc))
# 输入桌面是不是锁屏
try:
h = u.OpenInputDesktop(0, False, 0x0001)
name = ""
if h:
buf = ctypes.create_unicode_buffer(256)
need = ctypes.c_int(0)
u.GetUserObjectInformationW(h, 2, buf, 512, ctypes.byref(need))
name = buf.value
u.CloseDesktop(h)
item("当前输入桌面", name != "Winlogon",
"%s%s" % (name or "(拿不到)",
"(锁屏中)" if name == "Winlogon" else ""))
except Exception: # noqa: BLE001
pass
# 能不能注入鼠标(只测 API 在不在,不动鼠标)
try:
ctypes.windll.user32.SendInput
item("SendInput 可用", True, "函数在")
except Exception as exc: # noqa: BLE001
item("SendInput 可用", False, str(exc))
# ================================================================ 四、网络
def probe_net() -> None:
head("四、网络")
import socket
for host, port in (("yk.xxmxxs.top", 443), ("1.1.1.1", 53)):
t0 = time.perf_counter()
try:
s = socket.create_connection((host, port), timeout=10)
s.close()
item("连 %s:%d" % (host, port), True,
"通,%.2fs" % (time.perf_counter() - t0))
except Exception as exc: # noqa: BLE001
item("连 %s:%d" % (host, port), False,
"%s(%.2fs)" % (type(exc).__name__,
time.perf_counter() - t0))
# 本机 IP
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("1.1.1.1", 80))
item("本机局域网 IP", True, s.getsockname()[0])
s.close()
except Exception: # noqa: BLE001
pass
# ============================================================ 五、环境与工具链
def probe_env() -> None:
r'''采集**环境与工具链**信息 —— 这是决定"我能怎么写代码"的依据。
== 为什么要这一段 ==
用户要的是:**搞清楚这台 Win7 上有哪些工具/组件可用**,
好在写兼容代码时不踩空。所以这一段不谈某个具体软件,
只回答"这台机器**能提供什么**"。
关注点:
* 系统到底哪个版本、什么 SP、什么架构
* .NET 装到几(System.Speech / WPF 依赖它)
* VC++ 运行库有没有(很多打包程序依赖)
* 命令行工具有哪些(cscript/wscript/powershell/wmic/mshta…)
* 中文语音在不在(SAPI 已在第二节,这里确认语音包)
* 已装程序清单(判断这是不是精简版系统)
* 硬件规模(内存 / 磁盘)—— 决定程序能不能跑
'''
head("五、环境与工具链")
# ---- 5.1 系统细节 ----
say()
say(" --- 5.1 系统细节 ---")
try:
v = sys.getwindowsversion()
item("内核版本", True, "%d.%d build %d" % (v.major, v.minor, v.build))
names = {(6, 1): "Windows 7", (6, 2): "Windows 8",
(6, 3): "Windows 8.1", (10, 0): "Windows 10/11"}
item("对应产品名", None, names.get((v.major, v.minor), "未知"))
except Exception: # noqa: BLE001
pass
item("架构", None, os.environ.get("PROCESSOR_ARCHITECTURE", "?"))
item("处理器标识", None, os.environ.get("PROCESSOR_IDENTIFIER", "?")[:60])
item("CPU 核心数", None, str(os.cpu_count()))
item("系统盘/Windows", None, os.environ.get("SystemRoot", "?"))
item("Program Files", None, os.environ.get("ProgramFiles", "?"))
# 内存 / 磁盘
try:
class MEMORYSTATUSEX(ctypes.Structure):
_fields_ = [("dwLength", ctypes.c_ulong),
("dwMemoryLoad", ctypes.c_ulong),
("ullTotalPhys", ctypes.c_ulonglong),
("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong),
("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong),
("ullAvailVirtual", ctypes.c_ulonglong),
("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
m = MEMORYSTATUSEX()
m.dwLength = ctypes.sizeof(m)
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m)):
item("物理内存", True, "%.2f GB(可用 %.2f GB,占用 %d%%)"
% (m.ullTotalPhys / 2 ** 30, m.ullAvailPhys / 2 ** 30,
m.dwMemoryLoad))
except Exception as exc: # noqa: BLE001
item("物理内存", False, str(exc))
for drive in ("C:\\",):
try:
free = ctypes.c_ulonglong(0)
total = ctypes.c_ulonglong(0)
ctypes.windll.kernel32.GetDiskFreeSpaceExW(
ctypes.c_wchar_p(drive), None, ctypes.byref(total),
ctypes.byref(free))
item("磁盘 %s" % drive, True,
"总 %.1f GB,剩 %.1f GB" % (total.value / 2 ** 30,
free.value / 2 ** 30))
except Exception: # noqa: BLE001
pass
# ---- 5.2 .NET ----
say()
say(" --- 5.2 .NET 运行库(System.Speech 依赖)---")
ps = shutil.which("powershell") or (
os.environ.get("SystemRoot", r"C:\Windows")
+ r"\System32\WindowsPowerShell\v1.0\powershell.exe")
if Path(ps).is_file():
code, out = run([ps, "-NoProfile", "-Command",
"[System.Environment]::Version.ToString()"], timeout=25)
item("CLR 版本", code == 0, (out or "").strip())
# 注册表里的 .NET 版本
for hive in (r"HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full",
r"HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.5",
r"HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.0"):
code, out = run(["reg", "query", hive, "/v", "Version"], timeout=20)
if code == 0 and out:
ver = ""
for ln in out.splitlines():
if "Version" in ln and "REG_SZ" in ln:
ver = ln.split("REG_SZ")[-1].strip()
item(hive.split("\\")[-2] + " / " + hive.split("\\")[-1],
True, ver or "在")
# ---- 5.3 命令行工具 ----
say()
say(" --- 5.3 命令行工具 ---")
sysroot = os.environ.get("SystemRoot", r"C:\Windows")
tools = [
("cscript", r"\System32\cscript.exe"),
("wscript", r"\System32\wscript.exe"),
("cmd", r"\System32\cmd.exe"),
("mshta", r"\System32\mshta.exe"), # 可跑 VBS/JS
("rundll32", r"\System32\rundll32.exe"),
("reg", r"\System32\reg.exe"),
("wmic", r"\System32\wbem\wmic.exe"), # 查硬件
("tasklist", r"\System32\tasklist.exe"),
("netsh", r"\System32\netsh.exe"),
("sc", r"\System32\sc.exe"),
("taskkill", r"\System32\taskkill.exe"),
("certutil", r"\System32\certutil.exe"),
]
for name, rel in tools:
found = shutil.which(name)
abs_p = sysroot + rel
if found:
item(name, True, found)
elif Path(abs_p).is_file():
item(name, True, "PATH 外,绝对路径在: %s" % abs_p)
else:
item(name, False, "找不到")
# ---- 5.4 Windows 组件 ----
say()
say(" --- 5.4 Windows 组件 ---")
# 语音(确认中文语音包)
if Path(ps).is_file():
code, out = run([
ps, "-NoProfile", "-Command",
"$v=New-Object -ComObject SAPI.SpVoice;"
"$v.GetVoices()|ForEach-Object{$_.GetDescription()}"], timeout=25)
vs = [x.strip() for x in (out or "").splitlines() if x.strip()]
cn = [x for x in vs if "Chinese" in x or "Huihui" in x or "中文" in x]
item("中文语音包", bool(cn),
"、".join(cn) if cn else "没有中文语音(只有: %s)"
% ("、".join(vs[:3]) if vs else "一个语音都没有"))
# 媒体功能
code, out = run(["reg", "query",
r"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion",
"/v", "ProductName"], timeout=20)
name = ""
if code == 0:
for ln in (out or "").splitlines():
if "ProductName" in ln and "REG_SZ" in ln:
name = ln.split("REG_SZ")[-1].strip()
item("系统产品名(注册表)", None, name or "查不到")
# ---- 5.5 已装程序(判断是不是精简版)----
say()
say(" --- 5.5 已装程序(看是不是精简版系统)---")
# ⚠ 第一版用 `Get-ItemProperty` 配通配符 + `Select-Object -ExpandProperty`:
# 在 PowerShell 2.0 上会往输出里混进一堆报错文本
# ("Property DisplayName cannot be found"、"CategoryInfo"、"At line:1" …),
# 结果列表里全是垃圾行。**报错和结果混在同一个 stdout 里**,
# 光看行数根本分不出来 —— 这次报告里就出现了。
#
# 改法:
# 1. `Get-WmiObject Win32_Product`(PS 2.0 兼容,但只有 MSI 装的程序)
# 2. `reg query` 兜底 —— **完全不经过 PowerShell**,不受版本影响
# 3. 过滤掉所有已知的报错关键字
apps = set()
if Path(ps).is_file():
code, out = run([
ps, "-NoProfile", "-Command",
"Get-WmiObject -Class Win32_Product -ErrorAction SilentlyContinue | "
"ForEach-Object { $_.Name }"], timeout=90)
for x in (out or "").splitlines():
apps.add(x.strip())
for hive in (r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
r"HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows"
r"\CurrentVersion\Uninstall"):
code, out = run(["reg", "query", hive, "/s", "/v", "DisplayName"],
timeout=60, encoding="mbcs")
for ln in (out or "").splitlines():
if "DisplayName" in ln and "REG_SZ" in ln:
apps.add(ln.split("REG_SZ")[-1].strip())
# 过滤报错噪音
NOISE = ("CategoryInfo", "FullyQualifiedErrorId", "At line:", "+ ",
"PSArgumentException", "ExpandPropertyNotFound", "SelectObject",
"Get-ItemProperty", "Get-WmiObject :", "InvalidArgument",
"PSObject", "cannot be found", "not recognized", "?????")
clean = sorted({a for a in apps
if a and not any(n in a for n in NOISE)
and "HKLM:" not in a and "\\" not in a[:6]})
if clean:
item("已装程序数量", True, "共 %d 个" % len(clean))
for a in clean[:30]:
say(" - " + a)
if len(clean) > 30:
say(" ...(还有 %d 个)" % (len(clean) - 30))
else:
item("已装程序数量", False, "一个都列不出来")
# ---- 5.6 环境变量(关键几个)----
say()
say(" --- 5.6 环境变量 ---")
for k in ("PATH", "TEMP", "TMP", "SystemRoot", "windir",
"ProgramFiles", "ProgramData", "USERPROFILE", "APPDATA",
"LOCALAPPDATA", "COMPUTERNAME", "USERNAME"):
v = os.environ.get(k)
if k == "PATH":
item(k, None, "%d 个路径" % len((v or "").split(";")))
for p in (v or "").split(";")[:8]:
if p.strip():
say(" " + p.strip())
else:
item(k, None, v or "(没设)")
# ================================================================ main
def main() -> int:
say("=" * 64)
say(" ykxmz 诊断报告")
say(" 生成时间: %s" % datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
say(" 机器名: %s" % os.environ.get("COMPUTERNAME", "?"))
say(" 用户名: %s" % os.environ.get("USERNAME", "?"))
say("=" * 64)
say()
say(" ★ 把这一整段复制发回去就行。程序不改动系统任何东西。")
for fn in (probe_system, probe_tts, probe_ui, probe_net, probe_env):
try:
fn()
except Exception: # noqa: BLE001
say()
say(" 【这一段探测崩了】")
for ln in traceback.format_exc().splitlines()[-6:]:
say(" " + ln)
say()
say("=" * 64)
say(" 报告结束")
say("=" * 64)
# 同时写到桌面和 exe 旁边
text = "\r\n".join(OUT)
written = []
targets = []
try:
targets.append(Path(os.path.expanduser("~")) / "Desktop" / "ykxmz诊断.txt")
except Exception: # noqa: BLE001
pass
try:
targets.append(Path(sys.executable).parent / "ykxmz诊断.txt")
except Exception: # noqa: BLE001
pass
targets.append(Path(tempfile.gettempdir()) / "ykxmz诊断.txt")
for t in targets:
# ⚠ 写两份:UTF-8-BOM(记事本能认)+ GBK(老编辑器/控制台重定向能认)。
# Win7 自带的记事本从 Vista 起就支持 UTF-8,但为了万无一失都写。
for enc, suffix in (("utf-8-sig", ""), ("gbk", "_gbk")):
try:
t.parent.mkdir(parents=True, exist_ok=True)
p = t if not suffix else t.with_name(t.stem + suffix + t.suffix)
p.write_text(text, encoding=enc, errors="replace")
written.append(str(p))
except Exception: # noqa: BLE001
pass
print()
print(" 报告已保存到:")
for w in written:
print(" " + w)
print()
print(" 按回车键关闭…")
try:
input()
except Exception: # noqa: BLE001
time.sleep(30)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception: # noqa: BLE001
print(traceback.format_exc())
try:
input(" 出错了,按回车关闭…")
except Exception: # noqa: BLE001
pass
+108
View File
@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
"""找出 MainActivity 里**定义了但没人调用**的私有方法。
为什么需要这个:2.3.0 把一半的分页删了,原来那些按钮背后的方法
就全变成孤儿了。一个个跑测试太慢,先用这个脚本一次性列出来。
排除规则(这些"没人调用"是正常的):
* @Override 的回调(框架会调)
* 生命周期方法(onCreate/onPause/...)
* 构造器
* 只有 setVisibility / setText 之类直接操作的不算
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
JAVA = (Path(__file__).resolve().parent.parent
/ "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java")
# 框架调用的,不算孤儿
LIFECYCLE = {
"onCreate", "onStart", "onResume", "onPause", "onStop", "onDestroy",
"onActivityResult", "onRequestPermissionsResult", "onBackPressed",
"onNewIntent", "onSaveInstanceState", "onRestoreInstanceState",
"onTouchEvent", "onKeyDown", "onConfigurationChanged",
"onWindowFocusChanged", "onCreateOptionsMenu", "onOptionsItemSelected",
"onClick", "run", "onPrepared", "onCompletion", "onError",
"onProgressChanged", "onStartTrackingTouch", "onStopTrackingTouch",
"onSingleTapConfirmed", "onDoubleTap", "onScroll", "onFling",
"onDown", "onLongPress", "onShowPress", "onSingleTapUp",
"onInput", "onPick", "onResponse", "onFailure", "doInBackground",
"onPostExecute", "beforeTextChanged", "onTextChanged",
"afterTextChanged", "onItemClick", "onItemSelected",
"onNothingSelected", "onFocusChange", "onEditorAction",
}
# 定义方法的各种写法
DEF_RE = re.compile(
r"^[ \t]*(?:@\w+(?:\([^)]*\))?\s*)*"
r"(?:private|protected|public|static|final|synchronized|\s)*"
r"(?:[\w<>\[\],.?\s]+?)\s+(\w+)\s*\([^)]*\)\s*(?:throws [\w,.\s]+)?\s*\{",
re.M)
def main() -> int:
text = JAVA.read_text(encoding="utf-8")
lines = text.split("\n")
# 收集定义(带行号)
defs = []
for i, ln in enumerate(lines):
m = DEF_RE.match(ln)
if not m:
continue
name = m.group(1)
# 排除构造器(名字等于类名)和 exclude 集合
if name in ("MainActivity", "if", "for", "while", "switch", "catch"):
continue
# 排除紧跟在 @Override 后面的
prev = [x.strip() for x in lines[max(0, i - 3):i]]
if any("_override_" == p or "@Override" in p for p in prev):
continue
defs.append((name, i + 1))
# 统计每个名字被调用多少次(排除定义行自己)
def_line = {}
for name, ln in defs:
def_line.setdefault(name, set()).add(ln)
orphans = []
for name, ln in defs:
# 统计调用次数:名字 + "(" 出现次数
occurrences = [(i + 1, l) for i, l in enumerate(lines)
if re.search(r"\b" + re.escape(name) + r"\s*\(", l)]
# 减去定义本身那一行
calls = [o for o in occurrences if o[0] != ln]
# 定义行可能是多行签名的第一行,精确匹配名字出现的所有行
if not calls:
if name not in LIFECYCLE:
orphans.append((name, ln))
print(" 定义的方法 %d 个" % len(defs))
print(" 孤儿方法 %d 个:" % len(orphans))
for name, ln in orphans:
print(" 第 %-5d %s()" % (ln, name))
# 也检查被调用但只出现在注释里的(可能是残留引用)
print()
print(" 被删除后仍被引用的符号(编译错误来源):")
deleted = sys.argv[1:] if len(sys.argv) > 1 else []
for sym in deleted:
hits = [(i + 1, l.strip()) for i, l in enumerate(lines)
if re.search(r"\b" + re.escape(sym) + r"\s*\(", l)]
if hits:
# 判断是不是定义行
deflines = {d[1] for d in defs if d[0] == sym}
real = [h for h in hits if h[0] not in deflines]
if real:
print(" %s: %d 处调用" % (sym, len(real)))
for ln, l in real[:5]:
print(" 第 %d: %s" % (ln, l[:100]))
return 0
if __name__ == "__main__":
sys.exit(main())
+115
View File
@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
r'''给 xxmxxs.top 签 Let's Encrypt 证书,并加回 443 块。
== 现在的状态 ==
* `xxmxxs.top` 的 A 记录已经指过来(走 Cloudflare 代理)✓
* `www.xxmxxs.top` **还没解析** —— 先不带它签
* 80 端口上博客已经能用(`http://<服务器IP>/console` 可以进控制台)
== 为什么先不签 www ==
`certbot -d xxmxxs.top -d www.xxmxxs.top` 会**同时校验两个域名**。
www 没解析的话整张证书都签不下来(Let's Encrypt 要求所有域名都通过校验)。
所以先签主域名,等 www 记录加了再 `--expand` 补进去。
== 走 Cloudflare 代理时的一个风险 ==
ACME 的 HTTP-01 挑战要求:
http://xxmxxs.top/.well-known/acme-challenge/<token>
能直接返回文件。
但 Cloudflare 如果开了 **"Always Use HTTPS"**,会把 80 端口的请求
301 到 https —— **certbot 就跟不上那个跳转,签不下来**。
不过 Cloudflare **默认不代理 `.well-known/acme-challenge/`**,
而且我们是 `--nginx` 插件(它会临时插一个 location)。
真出问题的话,报错信息会明确说"challenge 拿不到",
那时候再去 Cloudflare 加一条"绕过规则"。
== 443 块怎么加回来 ==
之前在配置文件里**故意没写** 443 段(没证书时 `nginx -t` 会失败:
"no ssl_certificate is defined")。
现在证书有了,用 `certbot --nginx` 让它**自己写** —— 比我手写靠谱,
因为它会把续期需要的所有行都带上。
'''
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
def main() -> int:
print("=" * 62)
print(" 给 xxmxxs.top 签证书 + 开 HTTPS")
print("=" * 62)
print()
print("── 1. 签证书(certbot 自己改 nginx 配置)──")
out = run(r'''
sudo certbot --nginx -d xxmxxs.top \
--non-interactive --agree-tos --register-unsafely-without-email \
--redirect --keep-until-expiring 2>&1 | tail -20
''', timeout=900, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
print()
print("── 2. 复核 ──")
out = run(r'''
echo " --- 证书文件 ---"
sudo ls -1 /etc/letsencrypt/live/ 2>/dev/null | sed 's/^/ /'
echo
echo " --- 证书信息 ---"
sudo openssl x509 -in /etc/letsencrypt/live/xxmxxs.top/fullchain.pem \
-noout -subject -dates 2>/dev/null | sed 's/^/ /'
echo
echo " --- nginx 配置现在有几个 server 块 ---"
sudo grep -cE "^\s*server \{" /etc/nginx/sites-available/halo | sed 's/^/ halo: /'
echo
echo " --- 443 块有了吗 ---"
sudo grep -c "listen 443 ssl" /etc/nginx/sites-available/halo | sed 's/^/ listen 443: /'
echo
echo " --- nginx 检查 + 重载 ---"
sudo nginx -t 2>&1 | tail -2 | sed 's/^/ /'
sudo systemctl reload nginx && echo " 重载完成" || echo " 重载失败"
echo " nginx: $(systemctl is-active nginx)"
echo
echo " --- 各入口复核 ---"
printf " %-42s " "http://<服务器IP>/console (IP 控制台)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1/console
printf " %-42s " "https://xxmxxs.top/ (博客)"
curl -s -o /dev/null -w "HTTP %{http_code} %{size_download} 字节\n" --max-time 15 https://xxmxxs.top/
printf " %-42s " "https://xxmxxs.top/console (控制台)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 https://xxmxxs.top/console
printf " %-42s " "http://xxmxxs.top/ (应该 301 到 https)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 http://xxmxxs.top/
printf " %-42s " "https://yk.xxmxxs.top/ (中转站没坏)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 https://yk.xxmxxs.top/
printf " %-42s " "http://<服务器IP>:3001/ (直连 Halo)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1:3001/
printf " %-42s " "http://<服务器IP>:9100/ (官网)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1:9100/
echo
echo " --- ACME 挑战路径还是通的吗(关系到 90 天后能不能续)---"
printf " %-42s " "http://127.0.0.1/.well-known/acme-challenge/probe"
sudo sh -c 'echo ok > /var/www/html/.well-known/acme-challenge/probe'
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 8 -H "Host: xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/probe
sudo rm -f /var/www/html/.well-known/acme-challenge/probe
''', timeout=400, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+120
View File
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
r'''配置 Halo:控制台路径改到 /houtai。
== 为什么环境变量没生效 ==
我第一版用了 `HALO_CONSOLE_PATH=/houtai`(按 Spring Boot 的
relaxed binding 习惯猜的),但容器起来后 `/console` 还是 302,
`/houtai` 是 404 —— **说明这个变量名不对**。
从容器里看到真正的线索:
SPRING_CONFIG_LOCATION=optional:classpath:/;optional:file:/root/.halo2/
也就是说 Halo 会去读 **`/root/.halo2/application.yaml`**
(宿主机上就是 `/opt/halo/data/application.yaml`)。
**用配置文件比猜环境变量可靠得多** —— 而且配置项写错了
Halo 会在日志里报"不认识的属性",比静默忽略好。
== Halo 的配置长什么样 ==
Halo 2.x 用 Spring Boot 的 `@ConfigurationProperties`,
前缀是 `halo`,所以键是 `halo.console-path`。
== 要配的几项 ==
* `halo.console-path: /houtai` —— 用户要求
* `halo.external-url` —— 生成文章链接用,设成域名
* `halo.work-dir` —— 数据目录(容器里是 /root/.halo2)
**其余不写** —— 写了反而容易覆盖掉默认值,将来升级出问题。
'''
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, upload # noqa: E402
CONFIG = """# ======================================================================
# Halo 配置
# ======================================================================
#
# 这个文件放在宿主机的 /opt/halo/data/ ,容器里看到的是
# /root/.halo2/application.yaml(Halo 的 SPRING_CONFIG_LOCATION
# 就指着那儿)。
#
# ⚠ 为什么不靠环境变量:
# 我第一版用了 HALO_CONSOLE_PATH,结果没生效(/console 还是默认的)。
# Halo 的配置项是 Spring Boot 的 ConfigurationProperties,
# **写配置文件最可靠** —— 而且写错了它会在日志里报
# "unknown property",比静默忽略强。
halo:
# 数据目录(容器内路径)
work-dir: /root/.halo2
# 对外地址。Halo 用它生成文章永久链接、站点地图、RSS 里的链接。
# 设成域名之后,就算你从 IP:3001 进来,页面里的链接也是域名,一致。
external-url: https://xxmxxs.top
# ★ 控制台路径 —— 用户要求 "xxmxxs.top/houtai 是管理博客的后台"
#
# 默认是 /console。改掉之后:
# * 老路径 /console 不再响应(相当于顺手藏了一下后台入口)
# * 新入口是 https://xxmxxs.top/houtai
console-path: /houtai
"""
def main() -> int:
print("=" * 62)
print(" 配置 Halo:控制台路径 -> /houtai")
print("=" * 62)
print()
tmp = ROOT / "_build" / "_halo_application.yaml"
tmp.write_text(CONFIG, encoding="utf-8", newline="\n")
upload(str(tmp), "/tmp/halo-application.yaml")
tmp.unlink(missing_ok=True)
out = run(r'''
echo "==== 1. 放配置文件 ===="
sudo mv /tmp/halo-application.yaml /opt/halo/data/application.yaml
sudo chown 1000:1000 /opt/halo/data/application.yaml 2>/dev/null || true
sudo ls -la /opt/halo/data/ | sed 's/^/ /'
echo
echo "==== 2. 重启容器让它读到配置 ===="
sudo docker restart halo >/dev/null 2>&1
echo " 重启了,等它起来(最多 90 秒)…"
for i in $(seq 1 45); do
sleep 2
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3001/actuator/health 2>/dev/null)
if [ "$code" = "200" ]; then echo " 第 $((i*2)) 秒:健康检查 200 ✓"; break; fi
done
echo
echo "==== 3. 复核控制台路径 ===="
printf " %-30s %s\n" "/houtai(新,应该 200/302)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/houtai)"
printf " %-30s %s\n" "/console(旧,应该 404)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/console)"
printf " %-30s %s\n" "/(首页)" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/)"
echo
echo "==== 4. 配置有没有被拒绝(日志里找 unknown property)===="
sudo docker logs halo 2>&1 | grep -iE "unknown|not.*recogniz|invalid.*propert|console-path|configuration" | head -10 | sed 's/^/ /' || echo " (没有报错)"
echo
echo "==== 5. 内存现状 ===="
sudo docker stats --no-stream --format ' {{.Name}} 内存 {{.MemUsage}}' halo 2>/dev/null
free -m | sed 's/^/ /'
''', timeout=600, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+80
View File
@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
r'''Halo 登录(第三版):处理 CSRF,然后发文章。
== 前面两次为什么失败 ==
第一版:`Authorization: Basic` -> **302**(Halo 的 console API 不认 Basic,
它要会话 cookie 或 Personal Access Token)
第二版:直接 POST `/login` -> **403**(Spring Security 的 CSRF 保护)
== 这一版 ==
走完整的网页登录流程:
1. GET /login -> 拿 SESSION cookie + HTML 里的 _csrf token
2. POST /login -> 带上 cookie + _csrf + 用户名密码
3. 用返回的会话 cookie 调 console API
CSRF token 通常在登录页的 HTML 里(`<input name="_csrf">` 或 `<meta>`)。
Halo 的登录页是 SPA 还是服务端渲染会影响它在不在 —— 所以脚本会
**两种都找**,找不到就明确报出来,不瞎猜。
== 如果还是不行 ==
那就得让用户在 Halo 后台建一个**个人令牌**(设置 -> 个人令牌),
拿到之后 `Authorization: Bearer <token>` 就能用。
那是最可靠的方式 —— 30 秒的事,而且不用把密码给脚本。
'''
from __future__ import annotations
import json
import re
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
PWFILE = ROOT / "_build" / ".halo_password"
USER = "xmz7891"
def main() -> int:
print("=" * 62)
print(" Halo 登录(带 CSRF)")
print("=" * 62)
print()
pw = PWFILE.read_text(encoding="utf-8").strip()
out = run(r'''
rm -f /tmp/hc.txt /tmp/login.html
echo "── 1. GET /login(拿 cookie + csrf token)──"
curl -s -c /tmp/hc.txt -o /tmp/login.html \
-w " HTTP %{http_code} %{size_download} 字节 Content-Type: %{content_type}\n" \
--max-time 15 http://127.0.0.1:3001/login
echo " cookie:"
grep -v '^#' /tmp/hc.txt 2>/dev/null | while read -r a b c d e f g; do
[ -n "$f" ] && echo " $f"
done
[ -s /tmp/hc.txt ] || echo " (没拿到 cookie)"
echo
echo " HTML 里找 csrf token:"
grep -oiE '(<input[^>]*name="_csrf"[^>]*>|name="_csrf"[^>]*value="[^"]*"|"csrf[a-zA-Z]*"\s*:\s*"[^"]*"|csrf-token[^>]*content="[^"]*")' /tmp/login.html 2>/dev/null | head -5 | sed 's/^/ /'
echo " HTML 前 400 字:"
head -c 400 /tmp/login.html | tr -d '\n' | sed 's/^/ /'
echo
echo
echo " title:"
grep -oiE '<title>[^<]*</title>' /tmp/login.html 2>/dev/null | head -2 | sed 's/^/ /'
''', timeout=240, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+152
View File
@@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
r'''Halo 发文章(最终版):RSA 加密登录 + 建文章。
== 前面六次都失败,原因是这个 ==
Halo 的登录页用 **jsencrypt** 把密码 **RSA 加密**后才提交:
<script src="/webjars/jsencrypt/3.5.4/bin/jsencrypt.min.js"></script>
<form action="/login" method="post">
<input type="hidden" name="_csrf" ... />
<input type="hidden" name="password" /> <- JS 把密文填这里
<input name="username" />
<input id="plainPassword" /> <- 用户输入的明文
</form>
**所以在终端直接发明文密码永远登不上** —— 服务端拿到的是密文,
它会 RSA 解密再比对;我给明文,解密失败,于是 `invalid-credential`。
这个坑很深,因为**报错信息是"凭据无效"** —— 会让人一直怀疑
"是不是密码打错了",而不是"我流程不对"。
== 踩过的另外几个 ==
* `/login` 默认显示**邮箱验证码**表单(`action="/login/email-code"`),
密码表单在 **`/login?method=local`**。我一开始连页面都拿错了。
* CSRF 必须带(Spring Security 默认开),不带直接 403。
* 密码文件被 PowerShell 写了个 **BOM**(`\ufeff`),
读出来多一个不可见字符 —— 又一个"看着没错实际不对"的坑。
* Halo 有**登录失败限流**,试错几次就被挡住,得等。
== jsencrypt 的填充方式 ==
`jsencrypt` 默认用 **PKCS#1 v1.5** padding。加密后的密文是 base64。
公钥在登录页的 JS 里(一个大 base64 串),注意 HTML 里 `\/` 要还原成 `/`。
'''
from __future__ import annotations
import base64
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
PWFILE = ROOT / "_build" / ".halo_password"
USER = "xmz7891"
# 在服务器上跑的 Python:读公钥 -> RSA 加密 -> 输出 base64
ENCRYPT_PY = r'''
import base64, re, sys, json
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
html = open('/tmp/lg9.html', encoding='utf-8', errors='replace').read()
m = re.search(r'publicKey["\']?\s*[:=]\s*["\']([^"\']{40,})["\']', html)
if not m:
print("NO_KEY"); sys.exit(1)
b64 = m.group(1).replace('\\/', '/').replace('\\n', '')
b64 = re.sub(r'\s+', '', b64)
try:
der = base64.b64decode(b64)
pub = serialization.load_der_public_key(der)
except Exception as e1:
try:
pem = b'-----BEGIN PUBLIC KEY-----\n' + \
b'\n'.join(b64[i:i+64].encode() for i in range(0, len(b64), 64)) + \
b'\n-----END PUBLIC KEY-----\n'
pub = serialization.load_pem_public_key(pem)
except Exception as e2:
print("LOAD_FAIL: %s / %s" % (e1, e2)); sys.exit(1)
pw = open('/tmp/halo_pw.txt', 'rb').read().strip()
ct = pub.encrypt(pw, padding.PKCS1v15())
print(base64.b64encode(ct).decode())
'''
def main() -> int:
print("=" * 62)
print(" Halo:RSA 登录 -> 发文章")
print("=" * 62)
print()
pw = PWFILE.read_text(encoding="utf-8").strip()
print(" 用户名: %s" % USER)
print(" 密码长度: %d" % len(pw))
# 把加密脚本和密码传上去
enc = ROOT / "_build" / "_halo_encrypt.py"
enc.write_text(ENCRYPT_PY, encoding="utf-8", newline="\n")
from lssh import upload
upload(str(enc), "/tmp/halo_encrypt.py")
enc.unlink(missing_ok=True)
out = run(r'''
set -e
# 密码写成无 BOM 的文件
printf '%s' '__PW__' > /tmp/halo_pw.txt
echo " 密码文件字节数: $(wc -c < /tmp/halo_pw.txt)"
# 拿登录页(含公钥)
curl -s --max-time 12 "http://127.0.0.1:3001/login?method=local" -o /tmp/lg9.html
echo " 登录页 $(wc -c < /tmp/lg9.html) 字节"
# RSA 加密
CT=$(python3 /tmp/halo_encrypt.py)
if [ -z "$CT" ] || echo "$CT" | grep -qE '^(NO_KEY|LOAD_FAIL)'; then
echo " ✗ 加密失败: $CT"; exit 1
fi
echo " ✓ 密文长度 ${#CT}"
printf '%s' "$CT" > /tmp/halo_ct.txt
# 登录
rm -f /tmp/hc9.txt
curl -s -c /tmp/hc9.txt -o /tmp/lg9b.html --max-time 12 "http://127.0.0.1:3001/login?method=local"
CSRF=$(grep -oE 'name="_csrf" value="[^"]+"' /tmp/lg9b.html | head -1 | sed 's/.*value="//; s/"//')
echo " csrf 长度 ${#CSRF}"
LOC=$(curl -s -b /tmp/hc9.txt -c /tmp/hc9.txt -o /dev/null -w '%{redirect_url}' \
--max-time 20 -X POST "http://127.0.0.1:3001/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "_csrf=$CSRF" \
--data-urlencode "username=__U__" \
--data-urlencode "password=$CT")
echo " 登录跳转: ${LOC:-(空=可能成功)}"
CODE=$(curl -s -b /tmp/hc9.txt -o /dev/null -w '%{http_code}' --max-time 12 \
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=3")
echo " 带 cookie 打 API: HTTP $CODE"
if [ "$CODE" = "200" ]; then
cp /tmp/hc9.txt /tmp/hc_good.txt
echo " ✅ 登录成功"
echo
echo " --- 现在的文章 ---"
curl -s -b /tmp/hc9.txt --max-time 12 \
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
| python3 -c "
import json,sys
d=json.load(sys.stdin)
print(' 共', d.get('total'), '篇')
for p in (d.get('items') or []):
s=p.get('spec',{}); print(' -', repr(s.get('title')), '| publish=', s.get('publish'))
"
fi
'''.replace("__PW__", pw).replace("__U__", USER), timeout=600, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+172
View File
@@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
r'''重写 Halo 的 nginx 配置:**只写 80 端口那部分**。
== 上一版怎么坏的 ==
我想"临时把 443 块注释掉",就用脚本给 443 那几行前面加 `#`。
但那几行里包含 **server 块的结束 `}`** —— 一注释就变成
"unexpected end of file, expecting }",配置直接不合法。
**教训**:注释掉一个配置块要**整块删掉**,不能逐行加 `#`。
逐行注释会破坏"开括号/闭括号配对",而这种错误 nginx 只会报
"文件意外结束",不会告诉你少的是哪个括号。
== 这一版怎么组织 ==
443 块**根本不写** —— 等 DNS 指过来、certbot 签完证书之后,
再作为独立一步加上去。这样每一步的配置都是**自洽且能通过检查的**,
不会出现"半截配置"。
== 顺带 ==
ykxmz 那边原来占着 80 的 default_server,上一版已经把它让出来了
(`listen 80 default_server` -> `listen 80`)。要看它现在是否正常。
'''
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, upload # noqa: E402
CONF = """# xxmxxs.top —— Halo 个人博客(**只有 80 端口**,443 等证书)
#
# 入口:
# http://<服务器IP>/ 博客(IP 直接访问)
# http://<服务器IP>/console 管理后台
# http://xxmxxs.top/ 301 到 https(证书好了之后)
#
# 后端:容器 halo,监听 0.0.0.0:3001,Halo 原生控制台路径是 /console
#
# ⚠ 443 那段**故意不在这里** —— DNS 还没指过来,签不到证书。
# 没证书的 `listen 443 ssl` 会让 nginx -t 直接失败
# ("no ssl_certificate is defined")。
# 等 DNS 生效后跑 certbot,它会自己把 443 和证书加进来。
# ======================================================================
# 80 端口默认站点:用 IP 访问时直接出博客
# ======================================================================
#
# nginx 规则:Host 匹配不上任何 server_name 时,用该端口的 default_server。
# 所以这里设成 default_server -> `http://<服务器IP>/` 就是博客。
#
# ⚠ 不做 https 跳转:IP 没证书,跳过去浏览器报警告,用户会以为网站坏了。
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
access_log /var/log/nginx/halo-access.log;
error_log /var/log/nginx/halo-error.log;
server_tokens off;
client_max_body_size 100m;
location / {
proxy_pass http://127.0.0.1:3001;
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 "";
proxy_buffering off;
proxy_read_timeout 300s;
}
}
# ======================================================================
# 80 端口 + 域名:跳 https,并留出 ACME 挑战的位置
# ======================================================================
server {
listen 80;
listen [::]:80;
server_name xxmxxs.top www.xxmxxs.top;
access_log /var/log/nginx/halo-access.log;
error_log /var/log/nginx/halo-error.log;
server_tokens off;
client_max_body_size 100m;
# ⚠ ACME 挑战必须在 301 之前 ——
# certbot 的 HTTP-01 要在 80 端口直接拿到文件。
# 被 301 跳走就续期失败,而且**最多 90 天后才暴露**(证书到期)。
location /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
"""
def main() -> int:
print("=" * 62)
print(" 重写 Halo 的 nginx 配置(只 80 端口)")
print("=" * 62)
print()
tmp = ROOT / "_build" / "_halo_nginx.conf"
tmp.write_text(CONF, encoding="utf-8", newline="\n")
upload(str(tmp), "/tmp/halo-nginx.conf")
tmp.unlink(missing_ok=True)
out = run(r'''
echo "==== 1. 先把坏掉的备份都清掉,重写 ===="
sudo rm -f /etc/nginx/sites-available/halo.bak.* 2>/dev/null
sudo mv -f /tmp/halo-nginx.conf /etc/nginx/sites-available/halo
sudo ln -sf /etc/nginx/sites-available/halo /etc/nginx/sites-enabled/halo
echo " 写好了"
echo
echo "==== 2. ykxmz 那边现在长什么样(确认 default_server 让出来了)===="
sudo grep -n "default_server" /etc/nginx/sites-available/ykxmz 2>/dev/null | sed 's/^/ /' || echo " 没有 default_server ✓"
echo
echo "==== 3. 检查配置 ===="
sudo nginx -t 2>&1 | tail -3 | sed 's/^/ /'
''', timeout=240, quiet=True)
o = out[1] if isinstance(out, tuple) else str(out)
print(o)
if "successful" not in o:
print(" ✗ 配置有错,恢复 ykxmz 的 default_server 并停用 halo 站点")
run("sudo rm -f /etc/nginx/sites-enabled/halo", timeout=90, quiet=True)
return 1
out = run(r'''
echo "==== 4. 重载 ===="
sudo systemctl reload nginx && sleep 1
echo " nginx: $(systemctl is-active nginx)"
echo
echo "==== 5. 复核(对用户要求逐条)===="
printf " %-46s " "http://127.0.0.1/ (IP -> 博客)"
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
printf " %-46s " "http://127.0.0.1/console (控制台)"
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/console
printf " %-46s " "http://127.0.0.1:3001/ (直连 Halo)"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 10 http://127.0.0.1:3001/
printf " %-46s " "https://yk.xxmxxs.top/ (中转站)"
curl -s -o /dev/null -w "%{http_code} %{size_download} 字节\n" --max-time 12 https://yk.xxmxxs.top/
printf " %-46s " "https://yk.xxmxxs.top/v1/health"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 12 https://yk.xxmxxs.top/v1/health
printf " %-46s " "http://127.0.0.1:9100/ (官网直连通道)"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 10 http://127.0.0.1:9100/
echo
echo "==== 6. 监听端口 ===="
sudo ss -tlnp 2>/dev/null | grep -E ':(80|443|9100|3001) ' | awk '{print " "$4}' | sort -u
''', timeout=300, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+235
View File
@@ -0,0 +1,235 @@
# -*- coding: utf-8 -*-
r'''重写 halo 的 nginx 配置:修掉 301 循环。
== 怎么坏的 ==
我上一版**故意没写 443 块**(没证书时 `nginx -t` 会失败),
打算"等证书好了让 certbot 自己加"。
结果 `certbot --nginx` 找不到一个"纯粹的 443 块",
就把 `listen 443 ssl` 和证书那几行**塞进了那个 80 端口的块里**。
而那个块的 `location /` 内容是:
return 301 https://$host$request_uri;
于是 https 的请求进来也走这条 —— **301 到自己,无限循环**。
浏览器会报 "重定向次数过多"。
**certbot 还另外新建了一个纯 80 的块**(带两条 `if ($host = ...) return 301`),
跟原来的块功能重复。
== 教训 ==
**不要让 certbot 去改一个"结构不完整"的配置。**
`--nginx` 插件是**按 server 块猜**的:它找 `server_name` 匹配的块,
把 ssl 指令加进去。如果那个块本来是 80 的、还带 301,
它就会造出一个"443 + 301"的自环。
正确做法:**自己把 80 / 443 两块都写清楚**(443 先注释掉但不留半截),
证书有了之后**只把证书路径填进去**,而不是让 certbot 重排结构。
== 这一版的结构 ==
server A: 80 default_server -> proxy 到 Halo(IP 访问用)
server B: 80 + 域名 -> ACME 例外 + 301 到 https
server C: 443 + 域名 -> proxy 到 Halo
三个块各司其职,没有重叠,也没有"同一块里既有 301 又有 ssl"。
'''
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, upload # noqa: E402
CONF = """# xxmxxs.top —— Halo 个人博客
#
# 入口:
# http://<服务器IP>/ 博客(IP,HTTP)
# http://<服务器IP>/console 管理后台(IP,HTTP)
# https://xxmxxs.top/ 博客(HTTPS)
# https://xxmxxs.top/console 管理后台(HTTPS)
# https://www.xxmxxs.top/ 同上
# http://<服务器IP>:3001/ 直连 Halo(用户要求)
#
# 后端:容器 halo,监听 0.0.0.0:3001
# 控制台:Halo 原生 /console(2.26.1 没有可配置的路径前缀)
# ======================================================================
# A. 80 端口默认站点 —— 用 IP 访问时直接出博客
# ======================================================================
#
# nginx 规则:Host 匹配不上任何 server_name 时用该端口的 default_server。
#
# ⚠ 这里**不做 https 跳转**:IP 没证书,跳过去浏览器报警告,
# 用户会以为网站坏了。
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
access_log /var/log/nginx/halo-access.log;
error_log /var/log/nginx/halo-error.log;
server_tokens off;
client_max_body_size 100m;
location / {
proxy_pass http://127.0.0.1:3001;
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 "";
proxy_buffering off;
proxy_read_timeout 300s;
}
}
# ======================================================================
# B. 80 端口 + 域名 —— ACME 挑战 + 跳 https
# ======================================================================
#
# ⚠ 这个块**只监听 80**,不含任何 ssl 指令。
# 上一版 certbot 把 443 塞进来,造出了"443 上也 301"的自环。
server {
listen 80;
listen [::]:80;
server_name xxmxxs.top www.xxmxxs.top;
access_log /var/log/nginx/halo-access.log;
error_log /var/log/nginx/halo-error.log;
server_tokens off;
# ACME 挑战必须在 301 之前 ——
# certbot 的 HTTP-01 要在 80 端口直接拿到文件。
# 被 301 跳走就续期失败,而**最多 90 天后才暴露**(证书到期)。
location /.well-known/acme-challenge/ {
root /var/www/html;
default_type "text/plain";
try_files $uri =404;
}
location / {
return 301 https://$host$request_uri;
}
}
# ======================================================================
# C. 443 + 域名 —— 博客(HTTPS)
# ======================================================================
#
# ⚠ `listen ... ssl` 和证书那几行是 certbot 需要的形式,
# 续期时它会检查。**别删**,也别把 80 的 301 混进来。
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name xxmxxs.top www.xxmxxs.top;
access_log /var/log/nginx/halo-access.log;
error_log /var/log/nginx/halo-error.log;
server_tokens off;
client_max_body_size 100m;
ssl_certificate /etc/letsencrypt/live/xxmxxs.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/xxmxxs.top/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:3001;
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;
# 事件总线(SSE)要用,而且不能缓冲
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "";
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 30s;
}
}
"""
def main() -> int:
print("=" * 62)
print(" 重写配置,修掉 301 循环")
print("=" * 62)
print()
tmp = ROOT / "_build" / "_halo_nginx.conf"
tmp.write_text(CONF, encoding="utf-8", newline="\n")
upload(str(tmp), "/tmp/halo-nginx.conf")
tmp.unlink(missing_ok=True)
out = run(r'''
sudo cp /etc/nginx/sites-available/halo /etc/nginx/sites-available/halo.bak.$(date +%s)
sudo mv /tmp/halo-nginx.conf /etc/nginx/sites-available/halo
echo " 写好了"
echo
echo " ---- 检查 ----"
sudo nginx -t 2>&1 | tail -3 | sed 's/^/ /'
''', timeout=240, quiet=True)
o = out[1] if isinstance(out, tuple) else str(out)
print(o)
if "successful" not in o:
print(" ✗ 配置有错,撤回")
run("sudo cp $(sudo ls -t /etc/nginx/sites-available/halo.bak.* | head -1) "
"/etc/nginx/sites-available/halo && sudo nginx -t", timeout=120,
quiet=True)
return 1
out = run(r'''
sudo systemctl reload nginx && sleep 1
echo " nginx: $(systemctl is-active nginx)"
echo
echo "════════ 逐条复核 ════════"
printf " %-44s " "http://<服务器IP>/ (博客)"
curl -s -o /dev/null -w "HTTP %{http_code} %{size_download} 字节\n" --max-time 10 http://127.0.0.1/
printf " %-44s " "http://<服务器IP>/console (控制台)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1/console
echo
printf " %-44s " "https://xxmxxs.top/ (博客)"
curl -sk -o /dev/null -w "HTTP %{http_code} %{size_download} 字节\n" --max-time 15 --resolve xxmxxs.top:443:127.0.0.1 https://xxmxxs.top/
printf " %-44s " "https://xxmxxs.top/console (控制台)"
curl -sk -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 --resolve xxmxxs.top:443:127.0.0.1 https://xxmxxs.top/console
printf " %-44s " "https://www.xxmxxs.top/ (博客)"
curl -sk -o /dev/null -w "HTTP %{http_code}\n" --max-time 15 --resolve www.xxmxxs.top:443:127.0.0.1 https://www.xxmxxs.top/
printf " %-44s " "http://xxmxxs.top/ (应 301 到 https)"
curl -s -o /dev/null -w "HTTP %{http_code} -> %{redirect_url}\n" --max-time 15 -H "Host: xxmxxs.top" http://127.0.0.1/
echo
echo " 不能弄坏的:"
printf " %-44s " "https://yk.xxmxxs.top/ (中转站)"
curl -s -o /dev/null -w "HTTP %{http_code} %{size_download} 字节\n" --max-time 15 https://yk.xxmxxs.top/
printf " %-44s " "http://<服务器IP>:9100/ (官网)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1:9100/
printf " %-44s " "http://<服务器IP>:3001/ (直连 Halo)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 10 http://127.0.0.1:3001/
echo
echo "════════ ACME 挑战路径(续期的命根子)════════"
sudo sh -c 'echo probe > /var/www/html/.well-known/acme-challenge/probe'
printf " %-44s " "http://xxmxxs.top/.well-known/... (应 200)"
curl -s -o /dev/null -w "HTTP %{http_code}\n" --max-time 8 -H "Host: xxmxxs.top" http://127.0.0.1/.well-known/acme-challenge/probe
sudo rm -f /var/www/html/.well-known/acme-challenge/probe
''', timeout=400, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+122
View File
@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
r'''装 Halo 之前先把前提条件查清楚。
== 用户要求 ==
* Halo 个人博客,跑在 `<服务器IP>:3001`
* 加 **1G swap**(机器只有 1936MB 内存,本来没 swap)
* `xxmxxs.top` 和 `www.xxmxxs.top` 都能访问
* `xxmxxs.top/houtai` 是管理后台
* 其他博客配置用户自己弄
== 装之前必须确认的 ==
1. **`xxmxxs.top` 的 DNS 指到服务器了吗?**
之前发现 `xxmxxs.top` 上绑着一个 **Cloudflare Worker**
(用户说要自己去掉)。如果 Worker 还在,或者 A 记录没指过来,
nginx 配得再好也没用 —— 外面根本到不了服务器。
2. **内存和 swap 现状**
1936MB 总内存,现在用了 ~584MB。Halo 是 **Java 应用**,
默认堆可能吃 512MB+。**没有 swap 的话很容易被 OOM 杀掉**,
表现是"博客隔一阵就自己挂了"。
所以用户要的 1G swap 是**必须的**,不是可选项。
3. **Docker 能用吗 / 1Panel 的 MySQL**
机器上有 1Panel + Docker,还有个 `mysql:8.4.11` 容器在跑。
Halo 可以用它,也可以用**内置 H2 数据库**(更省内存、更简单)。
个人博客 H2 完全够用 —— 少一个组件少一处故障点。
4. **Java 有没有**
Halo 2.x 要 **Java 17+**。
'''
from __future__ import annotations
import socket
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
HOSTFILE = ROOT / "_build" / ".lightsail_host"
IP = HOSTFILE.read_text(encoding="utf-8").strip()
def main() -> int:
print("=" * 62)
print(" 装 Halo 前的前提检查")
print("=" * 62)
print()
# ---------------- DNS ----------------
print("── 1. 域名现在解析到哪 ──")
for name in ("xxmxxs.top", "www.xxmxxs.top"):
try:
ips = sorted({sa[4][0] for sa in
socket.getaddrinfo(name, 443, proto=socket.IPPROTO_TCP)})
mark = ""
if IP in ips:
mark = " ← 就是你的服务器 ✓"
else:
mark = " ← ⚠ 不是你的服务器(要改 DNS)"
print(" %-20s %s%s" % (name, ", ".join(ips) or "(无解析)", mark))
except Exception as exc: # noqa: BLE001
print(" %-20s 解析失败: %s" % (name, type(exc).__name__))
print()
print(" ── 直接连服务器 + 带 Host 头,看现在返回什么 ──")
import ssl
import urllib.request
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
for host in ("xxmxxs.top", "www.xxmxxs.top"):
try:
req = urllib.request.Request("https://" + IP + "/",
headers={"Host": host,
"User-Agent": "probe"})
with urllib.request.urlopen(req, timeout=12, context=ctx) as r:
body = r.read(400)
print(" Host:%-20s -> HTTP %s %d 字节"
% (host, r.status, len(body)))
except Exception as exc: # noqa: BLE001
print(" Host:%-20s -> %s" % (host, type(exc).__name__))
# ---------------- 服务器现状 ----------------
print()
print("── 2. 服务器现状 ──")
out = run(r'''
echo " --- 内存 / swap ---"
free -m | sed 's/^/ /'
echo " --- 磁盘 ---"
df -h / | tail -1 | sed 's/^/ /'
echo " --- Java ---"
if command -v java >/dev/null 2>&1; then java -version 2>&1 | head -2 | sed 's/^/ /'; else echo " 没装 Java"; fi
echo " --- Docker ---"
sudo docker --version 2>/dev/null | sed 's/^/ /' || echo " docker 不可用"
echo " --- 3001 端口占了吗 ---"
sudo ss -tlnp 2>/dev/null | grep 3001 | sed 's/^/ /' || echo " 3001 空着 ✓"
echo " --- 现有 systemd 服务 ---"
systemctl list-units --type=service --state=running --no-pager --plain 2>/dev/null \
| awk '{print " "$1}' | head -14
echo " --- 1Panel 的 mysql 容器 ---"
sudo docker ps --format ' {{.Names}} {{.Image}} {{.Ports}}' 2>/dev/null
''', timeout=240, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
print()
print("── 3. 结论 / 下一步 ──")
print(" * swap 现在就加(1G)—— 不加 Halo 容易被 OOM 杀掉")
print(" * Halo 用 Docker 装(机器上已经有 Docker),数据库用内置 H2")
print(" —— 个人博客够用,而且比再挂一个 MySQL 省内存")
print(" * nginx 加 xxmxxs.top + www 两个 server_name,反代到 127.0.0.1:3001")
print(" * 控制台路径设成 /houtai(Halo 2.x 支持 HALO_CONSOLE_PATH)")
print(" * ⚠ 域名 DNS 要指过来(还有那个 Cloudflare Worker 要删)")
print(" * ⚠ 3001 要在 AWS Lightsail 防火墙开")
return 0
if __name__ == "__main__":
sys.exit(main())
+339
View File
@@ -0,0 +1,339 @@
# -*- coding: utf-8 -*-
r'''通过 Halo API 把文章直接写进去。
== 为什么要走 API ==
用户试过复制粘贴,但 Halo 富文本编辑器老把内容吞进代码块。
与其来回折腾,不如**直接调 API 建文章** —— 一次到位,
而且以后想再发文章也能复用这个脚本。
== ⚠ 密码怎么处理 ==
密码存在 `_build/.halo_password`(**已经加进 .gitignore**)。
脚本本身**不含任何密码** —— 从文件读。
这一点很重要:这个密码和用户的中转站管理密码**是同一个**,
写进仓库就等于全泄漏了。
== Halo 2.x 的建文章 API ==
POST /apis/api.console.halo.run/v1alpha1/posts
Authorization: Basic base64(user:pass)
Content-Type: application/json
请求体是 `PostRequest`:
{
"post": { <- Post 资源本身
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {"name": "post-xxx"},
"spec": {
"title": "...",
"slug": "...",
"publish": true,
"publishTime": "...",
"visible": "PUBLIC",
"allowComment": true,
"excerpt": {"autoGenerate": true, "raw": ""},
"categories": [], "tags": [], "htmlMetas": []
}
},
"content": { <- 正文单独放
"raw": "...",
"content": "...",
"rawType": "markdown"
}
}
**注意 `content` 要同时给 `raw`(源码)和 `content`(渲染后的 HTML)**。
`raw` 给 Markdown,`content` 给 HTML —— Halo 前端直接读 `content`。
两个都给的话,编辑器里也能正常显示源码。
'''
from __future__ import annotations
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
PWFILE = ROOT / "_build" / ".halo_password"
# ---------------------------------------------------------------- 文章正文
# 用 Markdown 写(API 的 raw 字段就是 Markdown),Halo 会渲染成网页。
# 内容只提官网,不提任何管理后台 —— 对外材料清洁度要求。
TITLE = "远控软件正式上线"
SLUG = "remote-control-launch"
EXCERPT = ("一个为站在讲台前的人做的远程控制工具。手表抬一下手腕,"
"就能操作教室那台电脑:远程鼠标、投图到大屏、语音播报、"
"静默自动更新。官网 yk.xxmxxs.top")
MD = """一个为「站在讲台前的人」做的远程控制工具。手表抬一下手腕,就能操作教室那台电脑。
## 它是干什么的
很多场景下,人不在电脑前面,但需要操作那台电脑:
- 上课时站在教室中间,想切个课件、放张图
- 坐在办公室,想看看教室那台电脑还开着没
- 手上拿着东西,腾不出手去碰鼠标键盘
**这个软件就是解决这个的。**
电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。
## 能做什么
### 远程鼠标控制
手表变成触摸板:
- **拖动** —— 移动鼠标
- **单击** —— 左键
- **双击** —— 右键
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
### 一键显示桌面
快捷栏上有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
### 电脑端静默自动更新
这是最省心的一点:
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
- 全程**不弹任何窗口**
- 中转地址、配对码、所有设置**一个都不变**
- 更新完自动回来继续跑
不用再跑到电脑前手动装一遍。
### 顶部快捷栏
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
| 可选动作 | 说明 |
|---|---|
| 传文件 | 从手表把文件发到电脑 |
| 屏幕镜像 | 看电脑屏幕 |
| 鼠标控制 | 进触摸板模式 |
| 音效 | 让电脑放一声 |
| 关机 | 锁屏 / 关机 / 重启 |
| 语音播报 | 让电脑念一段字 |
| 投图到电脑 | 手表选图,全屏投到大屏 |
| 显示桌面 | 等于 Win + D |
在设置页里点一下就能换。
### 投图到大屏
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
上课讲题、展示作业、临时放张图,比插 U 盘快得多。
### 语音播报
输入文字,电脑用系统自带的语音念出来。
「上课了」「下课了」「大家看一下黑板」——说一遍不如让电脑说一遍。
### 让电脑放个音效
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
### 其他
- 剪贴板互传(手表和电脑之间)
- 电源控制(锁屏、关机、重启)
- 文件互传
- 静音、音量加减、播放控制
## 几个设计上的取舍
### 电脑端不弹任何窗口
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
这一点是刻意的:要的就是「像不存在一样」。如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 —— 设置里有,默认关着。
### 支持 Windows 7
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
### 静默更新为什么要校验
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
## 怎么装
**官网下载:<https://yk.xxmxxs.top>**
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
装完之后:
1. 手表连上电脑
2. 会先到一个选择页:**远控** / **静默更新被控端**
3. 点「远控」进控制页
## 说在最后
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
**有问题、想要什么功能,欢迎留言。**
"""
def md_to_html(text: str) -> str:
"""把这份固定的 Markdown 转成 HTML。
⚠ 这里**不是在写通用 Markdown 解析器** —— 那样要引第三方库,
而我们的正文是我自己写的、结构已知。用简单的正则按块处理就够了,
而且不会因为库的行为差异出意外。
支持:## / ### 标题、**粗体**、`代码`、- 列表、1. 列表、
表格、> 引用、<https://...> 自动链接、空行分段。
"""
import html as _html
import re as _re
def inline(s: str) -> str:
# 先转义,避免正文里的 < > 破坏 HTML
s = _html.escape(s, quote=False)
s = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
s = _re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = _re.sub(r"&lt;(https?://[^&]+?)&gt;", r'<a href="\1">\1</a>', s)
return s
out = []
lines = text.split("\n")
i = 0
while i < len(lines):
ln = lines[i]
if not ln.strip():
i += 1
continue
# 表格
if ln.startswith("|") and i + 1 < len(lines) and \
set(lines[i + 1].replace("|", "").strip()) <= set("-: "):
head = [c.strip() for c in ln.strip("|").split("|")]
i += 2
rows = []
while i < len(lines) and lines[i].startswith("|"):
rows.append([c.strip() for c in lines[i].strip("|").split("|")])
i += 1
out.append("<table><thead><tr>" +
"".join("<th>%s</th>" % inline(c) for c in head) +
"</tr></thead><tbody>" +
"".join("<tr>" + "".join("<td>%s</td>" % inline(c)
for c in r) + "</tr>"
for r in rows) +
"</tbody></table>")
continue
# 标题
m = _re.match(r"^(#{2,4})\s+(.*)$", ln)
if m:
lvl = len(m.group(1))
out.append("<h%d>%s</h%d>" % (lvl, inline(m.group(2)), lvl))
i += 1
continue
# 列表
if _re.match(r"^[-*]\s+", ln):
items = []
while i < len(lines) and _re.match(r"^[-*]\s+", lines[i]):
items.append(inline(_re.sub(r"^[-*]\s+", "", lines[i])))
i += 1
out.append("<ul>" + "".join("<li>%s</li>" % x for x in items) +
"</ul>")
continue
if _re.match(r"^\d+\.\s+", ln):
items = []
while i < len(lines) and _re.match(r"^\d+\.\s+", lines[i]):
items.append(inline(_re.sub(r"^\d+\.\s+", "", lines[i])))
i += 1
out.append("<ol>" + "".join("<li>%s</li>" % x for x in items) +
"</ol>")
continue
# 引用
if ln.startswith(">"):
out.append("<blockquote>%s</blockquote>"
% inline(ln.lstrip("> ").strip()))
i += 1
continue
# 普通段落(连续的合成一段)
para = [ln.strip()]
i += 1
while i < len(lines) and lines[i].strip() and \
not _re.match(r"^(#{2,4}\s|[-*]\s|\d+\.\s|>|\|)", lines[i]):
para.append(lines[i].strip())
i += 1
out.append("<p>%s</p>" % inline(" ".join(para)))
return "\n".join(out)
def main() -> int:
print("=" * 62)
print(" 通过 Halo API 发布文章")
print("=" * 62)
print()
if not PWFILE.is_file():
print(" ✗ 找不到 %s" % PWFILE)
return 1
pw = PWFILE.read_text(encoding="utf-8").strip()
print(" 密码已从 %s 读到(长度 %d)" % (PWFILE.name, len(pw)))
# 1) 先测认证
print()
print("── 1. 测认证 ──")
out = run(r'''
curl -s -o /dev/null -w " basic auth 打 posts -> HTTP %{http_code}\n" \
--max-time 10 -u "xmz7891:__PW__" \
http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts
curl -s -o /dev/null -w " basic auth 打 users/- -> HTTP %{http_code}\n" \
--max-time 10 -u "xmz7891:__PW__" \
http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/users/-
'''.replace("__PW__", pw), timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
# 2) 看现在有几篇文章
print("── 2. 现在的文章 ──")
out = run(r'''
curl -s --max-time 10 -u "xmz7891:__PW__" \
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
| python3 -c "
import json,sys
try:
d = json.load(sys.stdin)
except Exception as e:
print(' 解析失败:', e); sys.exit()
items = d.get('items') or []
print(' 共', d.get('total', len(items)), '篇')
for p in items:
sp = p.get('spec', {})
print(' -', sp.get('title'), '| publish =', sp.get('publish'),
'| name =', (p.get('metadata') or {}).get('name'))
"
'''.replace("__PW__", pw), timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+107
View File
@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
r'''通过 Halo API 把文章写进去(第二版:先登录拿 cookie)。
== 第一版为什么不行 ==
我直接用 `Authorization: Basic` 打 console API,结果 **302**(跳登录页)。
Halo 2.x 的 console API(`/apis/api.console.halo.run/...`)**不认 Basic**,
它要的是:
* **会话 cookie**(网页登录后拿到的那个),或者
* **Personal Access Token**(`Authorization: Bearer xxx`)
而 `users/-` 返回 200 是**假的** —— 那个端点匿名也放行(返回空)。
== 这一版怎么做 ==
走**网页表单登录**拿会话 cookie:
POST /login (form-urlencoded: username, password)
-> Set-Cookie: SESSION=xxx
然后带着这个 cookie 调 console API 建文章。
== 文章内容怎么给 ==
Halo 建文章的接口要 `PostRequest`:
{"post": {...Post 资源...},
"content": {"raw": "Markdown 源码", "content": "渲染后的 HTML",
"rawType": "markdown"}}
**`content` 和 `raw` 都要给**:
* `content`(HTML)是前台直接渲染的
* `raw`(Markdown)是编辑器里显示/可再编辑的
只给一个的话,要么前台是空的,要么编辑器打开是乱码。
== 顺手 ==
`md_to_html` 是**针对这份固定正文**写的简易转换,不是通用解析器 ——
不引第三方库,行为可控。支持的语法就是我正文里用到的那些。
'''
from __future__ import annotations
import base64
import http.cookiejar
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
PWFILE = ROOT / "_build" / ".halo_password"
BASE = "http://127.0.0.1:3001"
USER = "xmz7891"
def main() -> int:
print("=" * 62)
print(" Halo:表单登录 -> 发文章")
print("=" * 62)
print()
pw = PWFILE.read_text(encoding="utf-8").strip()
# ---------------------------------------------------------------- 登录
print("── 1. 表单登录拿 cookie ──")
out = run(r'''
rm -f /tmp/halo_cookie.txt
echo " 试 POST /login:"
curl -s -c /tmp/halo_cookie.txt -o /tmp/halo_login_out.txt -w " HTTP %{http_code} 跳转-> %{redirect_url}\n" \
--max-time 15 -X POST http://127.0.0.1:3001/login \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "username=__U__" \
--data-urlencode "password=__PW__"
echo " 拿到的 cookie:"
grep -v '^#' /tmp/halo_cookie.txt 2>/dev/null | awk '{print " "$6" = "substr($7,1,20)"..."}' || echo " (没有 cookie)"
echo
echo " 用 cookie 打 console API:"
curl -s -b /tmp/halo_cookie.txt -o /dev/null -w " posts -> HTTP %{http_code}\n" \
--max-time 10 "http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=5"
'''.replace("__PW__", pw).replace("__U__", USER), timeout=240, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
print()
print("── 2. 如果 cookie 有效,列出现在的文章 ──")
out = run(r'''
curl -s -b /tmp/halo_cookie.txt --max-time 10 \
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
> /tmp/halo_posts.json 2>/dev/null
head -c 600 /tmp/halo_posts.json
echo
''', timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+391
View File
@@ -0,0 +1,391 @@
# -*- coding: utf-8 -*-
r'''Halo 发文章(最终版):完整登录流程 + 建文章 + 验证。
== 走通的路 ==
1. GET /login -> 会话 cookie + HTML 里的 _csrf token
2. POST /login -> cookie + _csrf + 用户名密码
3. GET posts -> 确认会话有效 + 看现有文章
4. POST posts -> 建文章(PostRequest 结构)
5. GET 前台首页 -> 确认公开可见
== 三次失败的记录(免得以后再绕)==
① `Authorization: Basic` -> 302
Halo 的 console API 不认 Basic,要会话 cookie 或 Personal Access Token。
② 直接 POST /login -> 403
Spring Security 的 CSRF 保护,必须带上 GET 时拿到的 `_csrf`。
③ 只看 cookie jar 的格式 -> 以为没 cookie
curl 的 jar 是 tab 分隔的,我按空格拆了。实际有。
== 建文章的请求体 ==
POST /apis/api.console.halo.run/v1alpha1/posts
{
"post": {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {"name": "post-<随机>", "generateName": "post-"},
"spec": {
"title": "...", "slug": "...",
"publish": true, "publishTime": "...",
"visible": "PUBLIC", "allowComment": true,
"excerpt": {"autoGenerate": false, "raw": "摘要..."},
"categories": [], "tags": [], "htmlMetas": []
}
},
"content": {"raw": "Markdown 源码", "content": "渲染后的 HTML",
"rawType": "markdown"}
}
'''
from __future__ import annotations
import base64
import html as _html
import json
import re as _re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
PWFILE = ROOT / "_build" / ".halo_password"
USER = "xmz7891"
TITLE = "远控软件正式上线"
SLUG = "remote-control-launch"
EXCERPT = ("一个为站在讲台前的人做的远程控制工具。手表抬一下手腕,就能操作"
"教室那台电脑:远程鼠标、投图到大屏、语音播报、静默自动更新。")
MD = """一个为「站在讲台前的人」做的远程控制工具。手表抬一下手腕,就能操作教室那台电脑。
## 它是干什么的
很多场景下,人不在电脑前面,但需要操作那台电脑:
- 上课时站在教室中间,想切个课件、放张图
- 坐在办公室,想看看教室那台电脑还开着没
- 手上拿着东西,腾不出手去碰鼠标键盘
**这个软件就是解决这个的。**
电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。
## 能做什么
### 远程鼠标控制
手表变成触摸板:
- **拖动** —— 移动鼠标
- **单击** —— 左键
- **双击** —— 右键
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
### 一键显示桌面
快捷栏上有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
### 电脑端静默自动更新
这是最省心的一点:
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
- 全程**不弹任何窗口**
- 中转地址、配对码、所有设置**一个都不变**
- 更新完自动回来继续跑
不用再跑到电脑前手动装一遍。
### 顶部快捷栏
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
| 可选动作 | 说明 |
|---|---|
| 传文件 | 从手表把文件发到电脑 |
| 屏幕镜像 | 看电脑屏幕 |
| 鼠标控制 | 进触摸板模式 |
| 音效 | 让电脑放一声 |
| 关机 | 锁屏 / 关机 / 重启 |
| 语音播报 | 让电脑念一段字 |
| 投图到电脑 | 手表选图,全屏投到大屏 |
| 显示桌面 | 等于 Win + D |
在设置页里点一下就能换。
### 投图到大屏
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
上课讲题、展示作业、临时放张图,比插 U 盘快得多。
### 语音播报
输入文字,电脑用系统自带的语音念出来。
「上课了」「下课了」「大家看一下黑板」——说一遍不如让电脑说一遍。
### 让电脑放个音效
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
### 其他
- 剪贴板互传(手表和电脑之间)
- 电源控制(锁屏、关机、重启)
- 文件互传
- 静音、音量加减、播放控制
## 几个设计上的取舍
### 电脑端不弹任何窗口
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
这一点是刻意的:要的就是「像不存在一样」。如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 —— 设置里有,默认关着。
### 支持 Windows 7
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
### 静默更新为什么要校验
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
## 怎么装
**官网下载:<https://yk.xxmxxs.top>**
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
装完之后:
1. 手表连上电脑
2. 会先到一个选择页:**远控** / **静默更新被控端**
3. 点「远控」进控制页
## 说在最后
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
**有问题、想要什么功能,欢迎留言。**
"""
def md_to_html(text: str) -> str:
"""把这份固定的 Markdown 转成 HTML。
不是通用解析器 —— 只处理我正文里用到的那几种语法。
好处:不引第三方库,行为完全可控,不会有意外。
"""
def inline(s: str) -> str:
s = _html.escape(s, quote=False)
s = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
s = _re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = _re.sub(r"&lt;(https?://[^&]+?)&gt;", r'<a href="\1">\1</a>', s)
return s
out, lines, i = [], text.split("\n"), 0
while i < len(lines):
ln = lines[i]
if not ln.strip():
i += 1
continue
if ln.startswith("|") and i + 1 < len(lines) and \
set(lines[i + 1].replace("|", "").strip()) <= set("-: "):
head = [c.strip() for c in ln.strip("|").split("|")]
i += 2
rows = []
while i < len(lines) and lines[i].startswith("|"):
rows.append([c.strip() for c in lines[i].strip("|").split("|")])
i += 1
out.append("<table><thead><tr>" +
"".join("<th>%s</th>" % inline(c) for c in head) +
"</tr></thead><tbody>" +
"".join("<tr>" + "".join("<td>%s</td>" % inline(c)
for c in r) + "</tr>"
for r in rows) + "</tbody></table>")
continue
m = _re.match(r"^(#{2,4})\s+(.*)$", ln)
if m:
lv = len(m.group(1))
out.append("<h%d>%s</h%d>" % (lv, inline(m.group(2)), lv))
i += 1
continue
if _re.match(r"^[-*]\s+", ln):
items = []
while i < len(lines) and _re.match(r"^[-*]\s+", lines[i]):
items.append(inline(_re.sub(r"^[-*]\s+", "", lines[i])))
i += 1
out.append("<ul>" + "".join("<li>%s</li>" % x for x in items)
+ "</ul>")
continue
if _re.match(r"^\d+\.\s+", ln):
items = []
while i < len(lines) and _re.match(r"^\d+\.\s+", lines[i]):
items.append(inline(_re.sub(r"^\d+\.\s+", "", lines[i])))
i += 1
out.append("<ol>" + "".join("<li>%s</li>" % x for x in items)
+ "</ol>")
continue
if ln.startswith(">"):
out.append("<blockquote>%s</blockquote>"
% inline(ln.lstrip("> ").strip()))
i += 1
continue
para = [ln.strip()]
i += 1
while i < len(lines) and lines[i].strip() and \
not _re.match(r"^(#{2,4}\s|[-*]\s|\d+\.\s|>|\|)", lines[i]):
para.append(lines[i].strip())
i += 1
out.append("<p>%s</p>" % inline(" ".join(para)))
return "\n".join(out)
def main() -> int:
print("=" * 62)
print(" Halo:登录 -> 发文章")
print("=" * 62)
print()
pw = PWFILE.read_text(encoding="utf-8").strip()
body_html = md_to_html(MD)
print(" 正文 Markdown %d 字 -> HTML %d 字" % (len(MD), len(body_html)))
# 请求体
import datetime
now = datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S.000Z")
import uuid
req_body = {
"post": {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {"name": "post-" + uuid.uuid4().hex[:12],
"generateName": "post-"},
"spec": {
"title": TITLE,
"slug": SLUG,
"template": "",
"cover": "",
"deleted": False,
"publish": True,
"publishTime": now,
"pinned": False,
"allowComment": True,
"visible": "PUBLIC",
"priority": 0,
"excerpt": {"autoGenerate": False, "raw": EXCERPT},
"categories": [],
"tags": [],
"htmlMetas": [],
},
},
"content": {"raw": MD, "content": body_html, "rawType": "markdown"},
}
payload = json.dumps(req_body, ensure_ascii=False)
# 把请求体传到服务器(用 base64 避免 shell 转义地狱)
b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii")
print()
print("── 1. 登录(GET /login 拿 csrf+cookie,再 POST)──")
out = run(r'''
set -e
U=__U__
PW=__PW__
BASE=http://127.0.0.1:3001
J=/tmp/hc.txt
H=/tmp/login.html
rm -f $J $H
# 1) GET /login
curl -s -c $J -o $H --max-time 15 "$BASE/login"
CSRF=$(grep -oE 'name="_csrf" value="[^"]+"' $H | head -1 | sed 's/.*value="//; s/"//')
echo " csrf: ${CSRF:0:24}...(长度 ${#CSRF})"
if [ -z "$CSRF" ]; then echo " ✗ 拿不到 csrf"; exit 1; fi
# 2) POST /login
curl -s -b $J -c $J -o /tmp/login_resp.html \
-w " POST /login -> HTTP %{http_code} 跳转-> %{redirect_url}\n" \
--max-time 20 -X POST "$BASE/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "_csrf=$CSRF" \
--data-urlencode "username=$U" \
--data-urlencode "password=$PW"
echo " 会话 cookie:"
grep -v '^#' $J 2>/dev/null | awk -F'\t' '{if($6!="") print " "$6" (长度 "length($7)")"}'
# 3) 验证
curl -s -b $J -o /dev/null -w " 带 cookie 打 posts -> HTTP %{http_code}\n" \
--max-time 10 "$BASE/apis/api.console.halo.run/v1alpha1/posts?page=0&size=3"
'''.replace("__PW__", pw).replace("__U__", USER), timeout=300, quiet=True)
o = out[1] if isinstance(out, tuple) else str(out)
print(o)
if "posts -> HTTP 200" not in o:
print()
print(" ✗ 登录没成功。要么密码不对,要么流程还差一步。")
print(" 替代方案:在 Halo 后台建个「个人令牌」,我用 Bearer 发。")
return 1
print()
print("── 2. 看现有文章 ──")
out = run(r'''
curl -s -b /tmp/hc.txt --max-time 10 \
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
| python3 -c "
import json,sys
d=json.load(sys.stdin)
print(' 共', d.get('total'), '篇')
for p in (d.get('items') or []):
s=p.get('spec',{})
print(' -', repr(s.get('title')), '| publish=', s.get('publish'), '| name=', (p.get('metadata') or {}).get('name'))
"
''', timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
print()
print("── 3. 建文章 ──")
# 把 base64 的请求体写进服务器临时文件,再解码用
out = run("printf '%s' '%s' > /tmp/halo_body.b64 && "
"base64 -d /tmp/halo_body.b64 > /tmp/halo_body.json && "
"echo \" 请求体 $(wc -c < /tmp/halo_body.json) 字节\"" % (b64,),
timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
out = run(r'''
curl -s -b /tmp/hc.txt --max-time 30 \
-w "\n HTTP %{http_code}\n" \
-X POST "http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts" \
-H "Content-Type: application/json" \
--data-binary @/tmp/halo_body.json > /tmp/halo_post_resp.json 2>&1
tail -3 /tmp/halo_post_resp.json
echo " --- 响应前 400 字 ---"
head -c 400 /tmp/halo_post_resp.json
''', timeout=300, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+319
View File
@@ -0,0 +1,319 @@
# -*- coding: utf-8 -*-
r'''用已经拿到的会话 cookie 把文章发出去。
用户 cookie 在服务器 `/tmp/hc_good.txt`(上一步 RSA 登录拿到的)。
== 建文章的接口 ==
POST /apis/api.console.halo.run/v1alpha1/posts
Cookie: 会话
Content-Type: application/json
{
"post": { ...Post 资源... },
"content": {"raw": "Markdown", "content": "HTML", "rawType": "markdown"}
}
== 为什么用 base64 传请求体 ==
正文里有中文、引号、换行、`<` `>` —— 直接塞进 shell 命令会
被转义吃掉(这一天里因为转义翻了好几次车)。
base64 之后是纯 ASCII,绝不会有问题,服务器上再解回来。
'''
from __future__ import annotations
import base64
import datetime
import html as _html
import json
import re as _re
import sys
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
TITLE = "远控软件正式上线"
SLUG = "remote-control-launch"
EXCERPT = ("一个为站在讲台前的人做的远程控制工具。手表抬一下手腕,就能操作"
"教室那台电脑:远程鼠标、投图到大屏、语音播报、静默自动更新。"
"官网 yk.xxmxxs.top")
MD = """一个为「站在讲台前的人」做的远程控制工具。手表抬一下手腕,就能操作教室那台电脑。
## 它是干什么的
很多场景下,人不在电脑前面,但需要操作那台电脑:
- 上课时站在教室中间,想切个课件、放张图
- 坐在办公室,想看看教室那台电脑还开着没
- 手上拿着东西,腾不出手去碰鼠标键盘
**这个软件就是解决这个的。**
电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。
## 能做什么
### 远程鼠标控制
手表变成触摸板:
- **拖动** —— 移动鼠标
- **单击** —— 左键
- **双击** —— 右键
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
### 一键显示桌面
快捷栏上有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
### 电脑端静默自动更新
这是最省心的一点:
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
- 全程**不弹任何窗口**
- 中转地址、配对码、所有设置**一个都不变**
- 更新完自动回来继续跑
不用再跑到电脑前手动装一遍。
### 顶部快捷栏
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
| 可选动作 | 说明 |
|---|---|
| 传文件 | 从手表把文件发到电脑 |
| 屏幕镜像 | 看电脑屏幕 |
| 鼠标控制 | 进触摸板模式 |
| 音效 | 让电脑放一声 |
| 关机 | 锁屏 / 关机 / 重启 |
| 语音播报 | 让电脑念一段字 |
| 投图到电脑 | 手表选图,全屏投到大屏 |
| 显示桌面 | 等于 Win + D |
在设置页里点一下就能换。
### 投图到大屏
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
上课讲题、展示作业、临时放张图,比插 U 盘快得多。
### 语音播报
输入文字,电脑用系统自带的语音念出来。
「上课了」「下课了」「大家看一下黑板」——说一遍不如让电脑说一遍。
### 让电脑放个音效
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
### 其他
- 剪贴板互传(手表和电脑之间)
- 电源控制(锁屏、关机、重启)
- 文件互传
- 静音、音量加减、播放控制
## 几个设计上的取舍
### 电脑端不弹任何窗口
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
这一点是刻意的:要的就是「像不存在一样」。如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 —— 设置里有,默认关着。
### 支持 Windows 7
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
### 静默更新为什么要校验
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
## 怎么装
**官网下载:<https://yk.xxmxxs.top>**
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
装完之后:
1. 手表连上电脑
2. 会先到一个选择页:**远控** / **静默更新被控端**
3. 点「远控」进控制页
## 说在最后
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
**有问题、想要什么功能,欢迎留言。**
"""
def md_to_html(text: str) -> str:
def inline(s: str) -> str:
s = _html.escape(s, quote=False)
s = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
s = _re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = _re.sub(r"&lt;(https?://[^&]+?)&gt;", r'<a href="\1">\1</a>', s)
return s
out, lines, i = [], text.split("\n"), 0
while i < len(lines):
ln = lines[i]
if not ln.strip():
i += 1
continue
if ln.startswith("|") and i + 1 < len(lines) and \
set(lines[i + 1].replace("|", "").strip()) <= set("-: "):
head = [c.strip() for c in ln.strip("|").split("|")]
i += 2
rows = []
while i < len(lines) and lines[i].startswith("|"):
rows.append([c.strip() for c in lines[i].strip("|").split("|")])
i += 1
out.append("<table><thead><tr>" +
"".join("<th>%s</th>" % inline(c) for c in head) +
"</tr></thead><tbody>" +
"".join("<tr>" + "".join("<td>%s</td>" % inline(c)
for c in r) + "</tr>"
for r in rows) + "</tbody></table>")
continue
m = _re.match(r"^(#{2,4})\s+(.*)$", ln)
if m:
lv = len(m.group(1))
out.append("<h%d>%s</h%d>" % (lv, inline(m.group(2)), lv))
i += 1
continue
if _re.match(r"^[-*]\s+", ln):
items = []
while i < len(lines) and _re.match(r"^[-*]\s+", lines[i]):
items.append(inline(_re.sub(r"^[-*]\s+", "", lines[i])))
i += 1
out.append("<ul>" + "".join("<li>%s</li>" % x for x in items)
+ "</ul>")
continue
if _re.match(r"^\d+\.\s+", ln):
items = []
while i < len(lines) and _re.match(r"^\d+\.\s+", lines[i]):
items.append(inline(_re.sub(r"^\d+\.\s+", "", lines[i])))
i += 1
out.append("<ol>" + "".join("<li>%s</li>" % x for x in items)
+ "</ol>")
continue
if ln.startswith(">"):
out.append("<blockquote>%s</blockquote>"
% inline(ln.lstrip("> ").strip()))
i += 1
continue
para = [ln.strip()]
i += 1
while i < len(lines) and lines[i].strip() and \
not _re.match(r"^(#{2,4}\s|[-*]\s|\d+\.\s|>|\|)", lines[i]):
para.append(lines[i].strip())
i += 1
out.append("<p>%s</p>" % inline(" ".join(para)))
return "\n".join(out)
def main() -> int:
print("=" * 62)
print(" 发文章到 Halo")
print("=" * 62)
print()
body_html = md_to_html(MD)
print(" 正文 Markdown %d 字 -> HTML %d 字" % (len(MD), len(body_html)))
now = datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S.000Z")
req = {
"post": {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {"name": "post-" + uuid.uuid4().hex[:12],
"generateName": "post-"},
"spec": {
"title": TITLE, "slug": SLUG, "template": "", "cover": "",
"deleted": False, "publish": True, "publishTime": now,
"pinned": False, "allowComment": True, "visible": "PUBLIC",
"priority": 0,
"excerpt": {"autoGenerate": False, "raw": EXCERPT},
"categories": [], "tags": [], "htmlMetas": [],
},
},
"content": {"raw": MD, "content": body_html, "rawType": "markdown"},
}
b64 = base64.b64encode(json.dumps(req, ensure_ascii=False)
.encode("utf-8")).decode("ascii")
print()
print("── 建文章 ──")
# ⚠ 这里**不能用 % 格式化** —— 命令里那个 `printf '%s'` 的 `%s`
# 会被 Python 当成占位符(报 "not enough arguments for format string")。
# 这是今天第 N 次踩同一类坑了:**要拼进 shell 的字符串里含 % 时,
# 一律用 replace,不用 % 也不用 .format()。**
cmd = ("printf '%s' '__B64__' | base64 -d > /tmp/halo_new.json && "
"echo \" 请求体 $(wc -c < /tmp/halo_new.json) 字节\"")
cmd = cmd.replace("__B64__", b64)
out = run(cmd, timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
out = run(r'''
# 先确认 cookie 还有效
CODE=$(curl -s -b /tmp/hc_good.txt -o /dev/null -w '%{http_code}' --max-time 12 \
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=1")
echo " cookie 状态: HTTP $CODE"
if [ "$CODE" != "200" ]; then echo " ✗ cookie 失效了,要重新登录"; exit 1; fi
echo " --- 发 ---"
curl -s -b /tmp/hc_good.txt --max-time 40 \
-w "\n HTTP %{http_code}\n" \
-X POST "http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts" \
-H "Content-Type: application/json" \
--data-binary @/tmp/halo_new.json > /tmp/halo_new_resp.json 2>&1
tail -2 /tmp/halo_new_resp.json
echo " --- 响应前 300 字 ---"
head -c 300 /tmp/halo_new_resp.json
echo
echo
echo " --- 现在有几篇 ---"
curl -s -b /tmp/hc_good.txt --max-time 12 \
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=10" \
| python3 -c "
import json,sys
d=json.load(sys.stdin)
print(' 共', d.get('total'), '篇')
for p in (d.get('items') or []):
s=p.get('spec') or {}
md=p.get('metadata') or {}
print(' -', repr(s.get('title')), '| publish=', s.get('publish'), '|', md.get('name'))
"
echo
echo " --- 前台首页能看到了吗 ---"
curl -s --max-time 12 http://127.0.0.1:3001/ | grep -oiE '<title>[^<]*</title>' | head -2 | sed 's/^/ /'
curl -s -o /dev/null -w " 首页 HTTP %{http_code}\n" --max-time 12 http://127.0.0.1/
''', timeout=400, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+116
View File
@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
r'''走 Halo 的「邮箱找回密码」流程。
== 流程长什么样 ==
Halo 2.x 的重置密码分两步:
1. POST /password/restore/email {"email": "..."}
-> 服务器往这个邮箱发一个验证码(6 位数字)
2. POST /password/restore/email/verify {"email": "...", "code": "..."}
-> 校验验证码
3. PUT /password/restore {"password": "新密码"}
-> 用上一步换来的临时凭据改密码
**⚠ 但前提是 Halo 配了 SMTP** —— 没配的话邮件根本发不出去,
用户等半天收不到码,白折腾。
所以这个脚本**先查 SMTP 配置**,再决定要不要发。
== 为什么不用令牌 ==
用户先提邮箱这个方案,就按他的来。但两个方案其实可以并行:
* 邮箱重置:能改密码 → 然后我用新密码登录
* 个人令牌:不用改密码,更安全
**改密码有个副作用**:用户自己也得记住新密码。
所以我会把新密码**明确告诉用户**。
== 关于验证码 ==
用户会把验证码发给我。验证码有时效(Halo 默认 10 分钟),
所以拿到之后要**立刻**用。
'''
from __future__ import annotations
import json
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
EMAIL = "3235109770@qq.com"
def main() -> int:
print("=" * 62)
print(" Halo 邮箱找回密码 —— 先确认能不能发邮件")
print("=" * 62)
print()
print("── 1. 重置接口存在吗 ──")
out = run(r'''
BASE=http://127.0.0.1:3001
for ep in \
"/password/restore/email" \
"/apis/api.halo.run/v1alpha1/password/restore/email" \
"/apis/uc.api.auth.halo.run/v1alpha1/password/restore/email" \
"/apis/api.console.halo.run/v1alpha1/indices/-/users" \
; do
printf " %-64s " "$ep"
curl -s -o /dev/null -w "%{http_code}\n" --max-time 8 "$BASE$ep"
done
echo
echo "── 2. 探测一下 POST 的形状(不发真请求,只看方法是否允许)──"
curl -s -o /dev/null -w " POST /password/restore/email -> %{http_code}\n" \
--max-time 10 -X POST "http://127.0.0.1:3001/password/restore/email" \
-H "Content-Type: application/json" -d '{}'
echo
echo "── 3. Halo 的 SMTP 配了吗(数据库里找)──"
sudo docker exec halo sh -c "
find /root/.halo2 -name '*.mv.db' -o -name '*.db' 2>/dev/null | head -3
" 2>/dev/null | sed 's/^/ /'
echo " 从设置接口看(需要登录,先看看会不会泄露结构):"
curl -s --max-time 8 "http://127.0.0.1:3001/apis/api.halo.run/v1alpha1/configmaps" | head -c 200
echo
echo
echo "── 4. 邮件相关的日志(有没有发信记录/报错)──"
sudo docker logs halo 2>&1 | grep -iE "mail|smtp|email|JavaMail" | tail -10 | sed 's/^/ /' || echo " (日志里没提邮件)"
echo
echo "── 5. 用户的邮箱地址(确认收件人)──"
curl -s --max-time 8 "http://127.0.0.1:3001/apis/api.halo.run/v1alpha1/users" | head -c 300
echo
''', timeout=300, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
print()
print("── 6. 试发一次(如果接口在)──")
out = run(r'''
curl -s -w "\n HTTP %{http_code}\n" --max-time 20 \
-X POST "http://127.0.0.1:3001/password/restore/email" \
-H "Content-Type: application/json" \
-d '{"email":"__EMAIL__"}' 2>&1 | head -20
'''.replace("__EMAIL__", EMAIL), timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
print()
print("── 7. 发完之后看日志(有没有真发出去)──")
out = run(r'''
sleep 3
sudo docker logs --tail 40 halo 2>&1 | grep -iE "mail|smtp|email|restore|code|error|exception" | tail -15 | sed 's/^/ /' || echo " (没有相关日志)"
''', timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+131
View File
@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
r'''第二步:装 Halo 博客(Docker)。
== 配置决策(以及为什么)==
**用 Docker 而不是裸 JAR**
机器上已经有 Docker 29.8(1Panel 装的),直接用。好处:
不用在系统里装 Java、升级只要换镜像 tag、环境隔离。
**数据库用内置 H2(不接 MySQL)**
个人博客用 H2 完全够 —— 数据就是一个文件。
接 MySQL 要多挂一个容器,多占 400MB+ 内存,而机器只有 1.9G。
**少一个组件就少一处会坏的地方。**
**端口绑 0.0.0.0:3001**
用户要求 "端口的话就跑在 <服务器IP>:3001" ——
所以要能从外面直接访问 3001。
同时 nginx 也要能反代(走域名)。
如果只绑 127.0.0.1,`IP:3001` 就不通了。
**限制 JVM 堆**
`-Xmx400m`。机器 1.9G,JVM 默认会按 1/4 物理内存要堆(约 470M),
再加元空间/线程栈/直接内存容易冲到 700M+。
显式限到 400M 更可控 —— 博客流量小,400M 绰绰有余。
**外部地址设成 https://xxmxxs.top**
Halo 用它生成文章链接和站点地图。设成域名之后,
就算你从 `IP:3001` 进来,页面里的链接也是域名 —— 这样更一致。
**控制台路径 /houtai**
用户要求 "xxmxxs.top/houtai 是管理博客的后台"。
Halo 2.x 支持自定义控制台路径(社区 PR #2453)。
默认是 /console,这里改成 /houtai。
⚠ 如果这个版本的 env 变量名不对,容器会起不来 —— 脚本里会检查。
== 数据目录 ==
/opt/halo/data -> 容器内 /root/.halo2
里面有 H2 数据库、上传的附件、主题。
**这个目录要备份** —— 它是博客的全部内容。
'''
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. 看看有哪些可用的 Halo 版本 ===="
sudo docker pull halohub/halo:2 >/dev/null 2>&1 && echo " ✓ halohub/halo:2 拉下来了" || echo " ✗ 拉取失败"
sudo docker images halohub/halo --format ' {{.Repository}}:{{.Tag}} {{.Size}} ({{.CreatedSince}})' 2>/dev/null
echo
echo "==== 2. 看看镜像里支持哪些配置项 ===="
# Halo 的配置项在 application.yaml 里,从镜像里抠出来看
sudo docker run --rm --entrypoint sh halohub/halo:2 -c \
"find / -name 'application*.yaml' 2>/dev/null | head -5" 2>/dev/null | sed 's/^/ /'
echo
echo "==== 3. 准备数据目录 ===="
sudo mkdir -p /opt/halo/data
sudo chown -R 1000:1000 /opt/halo 2>/dev/null || true
ls -la /opt/halo | sed 's/^/ /'
echo
echo "==== 4. 起容器 ===="
sudo docker rm -f halo 2>/dev/null >/dev/null || true
sudo docker run -d \
--name halo \
--restart always \
--log-opt max-size=10m --log-opt max-file=3 \
-p 0.0.0.0:3001:8090 \
-v /opt/halo/data:/root/.halo2 \
-e HALO_EXTERNAL_URL=https://xxmxxs.top \
-e HALO_CONSOLE_PATH=/houtai \
-e JAVA_OPTS="-Xms128m -Xmx400m -XX:+UseSerialGC" \
halohub/halo:2 2>&1 | sed 's/^/ /'
echo
echo "==== 5. 等它起来(最多 90 秒)===="
ok=0
for i in $(seq 1 45); do
sleep 2
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:3001/ 2>/dev/null)
st=$(sudo docker inspect -f '{{.State.Status}}' halo 2>/dev/null)
if [ "$code" = "200" ] || [ "$code" = "302" ] || [ "$code" = "301" ]; then
echo " 第 $((i*2)) 秒:HTTP $code ✓ 起来了"
ok=1
break
fi
if [ "$st" != "running" ]; then
echo " 第 $((i*2)) 秒:容器已经退出(状态 $st)"
break
fi
done
if [ "$ok" != "1" ]; then
echo " 容器状态: $(sudo docker inspect -f '{{.State.Status}}' halo 2>/dev/null)"
echo " --- 日志(最后 30 行)---"
sudo docker logs --tail 30 halo 2>&1 | sed 's/^/ /'
fi
echo
echo "==== 6. 复核 ===="
sudo docker ps --filter name=halo --format ' {{.Names}} {{.Status}} {{.Ports}}'
echo " 首页: HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/)"
echo " /houtai: HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/houtai)"
echo " /console: HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/console)"
echo " 从外面直连 3001(本机自测):HTTP $(curl -s -o /dev/null -w '%{http_code}' --max-time 8 http://127.0.0.1:3001/actuator/health 2>/dev/null)"
echo
echo " --- 内存占用 ---"
sudo docker stats --no-stream --format ' {{.Name}} 内存 {{.MemUsage}} CPU {{.CPUPerc}}' halo 2>/dev/null
free -m | sed 's/^/ /'
'''
def main() -> int:
print("=" * 62)
print(" 第二步:装 Halo(Docker)")
print("=" * 62)
print()
out = run(REMOTE, timeout=900, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())
+143
View File
@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
r'''定位"不稳定"到底在哪一段。
== 已经知道的 ==
从本机连 30 次 `https://yk.xxmxxs.top/v1/health`:
成功 25 / 30(**17% 失败**)
失败的那几次都是 TCP 连接阶段就卡住(14~19 秒)
成功的时候中位只要 0.2 秒
**这说明不是服务器慢,是"有时候连不上"。**
== 要区分三段 ==
我 → Cloudflare边缘 → Cloudflare内部 → 源站(<服务器IP>) → nginx → relay
可能的断点:
A. **我 → Cloudflare 边缘**(我这边的网络 / ISP / 到 CF 的线路)
B. **Cloudflare 边缘 → 源站**(CF 回源;源站在 AWS 东京/新加坡)
C. **源站内部**(nginx / relay)
怎么分:
* 直连 `<服务器IP>:443`(绕过 Cloudflare)—— 稳的话说明 C 没问题
* 连 `yk.xxmxxs.top:443`(走 Cloudflare)—— 不稳的话说明 A 或 B 有问题
* 再对比别的走 Cloudflare 的站(比如 cloudflare.com 自己)——
它也抖的话说明是 **A**(我这边到 CF 的网络)
**这一步很关键**:如果是 A,那"网站不稳定"里**有一部分是本地网络的问题**,
不是服务器的问题 —— 用户在学校用可能是完全不同的情况。
'''
from __future__ import annotations
import socket
import ssl
import statistics
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
HOSTFILE = ROOT / "_build" / ".lightsail_host"
IP = HOSTFILE.read_text(encoding="utf-8").strip()
def probe(host, port=443, path="/", use_tls=True, timeout=8, host_hdr=None):
"""测一次。返回 (结果, TCP耗时, 总耗时, 说明)"""
t0 = time.perf_counter()
try:
s = socket.create_connection((host, port), timeout=timeout)
t_tcp = time.perf_counter() - t0
if use_tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
s = ctx.wrap_socket(s, server_hostname=host_hdr or host)
hdr = (host_hdr or host).encode()
s.send(b"GET " + path.encode() + b" HTTP/1.1\r\nHost: " + hdr +
b"\r\nUser-Agent: probe\r\nConnection: close\r\n\r\n")
data = s.recv(300)
t_all = time.perf_counter() - t0
s.close()
code = data.split(b" ")[1].decode() if b" " in data else "?"
return ("ok", t_tcp, t_all, code)
except Exception as exc: # noqa: BLE001
return (type(exc).__name__, time.perf_counter() - t0, None,
str(exc)[:34])
def batch(label, **kw):
print(" ── %s ──" % label)
n = kw.pop("n", 15)
ok = 0
slow = []
fails = []
for i in range(n):
kind, tcp, total, note = probe(**kw)
if kind == "ok":
ok += 1
if tcp > 1.0:
slow.append((i + 1, tcp))
else:
fails.append((i + 1, kind, tcp, note))
print(" %d/%d 成功 失败 %d" % (ok, n, len(fails)))
if slow:
print(" 慢的:%s" % ", ".join("#%d=%.1fs" % x for x in slow))
if fails:
for i, kind, secs, note in fails[:6]:
print(" ✗ #%d %s %.1fs %s" % (i, kind, secs, note))
return ok, len(fails)
def main() -> int:
print("=" * 62)
print(" 定位不稳定在哪一段")
print("=" * 62)
print()
a = batch("A. 直连源站 IP(绕过 Cloudflare)",
host=IP, port=443, use_tls=False, path="/v1/health",
host_hdr="yk.xxmxxs.top", n=15)
print()
b = batch("B. 走域名(经 Cloudflare)",
host="yk.xxmxxs.top", port=443, path="/v1/health", n=15)
print()
c = batch("C. 对照:Cloudflare 自己的站",
host="cloudflare.com", port=443, path="/", n=15)
print()
d = batch("D. 对照:另一个大站(百度)",
host="www.baidu.com", port=443, path="/", n=15)
print()
print("=" * 62)
print(" 判读")
print("=" * 62)
print(" A=直连源站 B=经Cloudflare C/D=对照")
print(" A:%d失败 B:%d失败 C:%d失败 D:%d失败"
% (a[1], b[1], c[1], d[1]))
print()
if a[1] == 0 and b[1] > 0:
print(" → **源站没问题,问题在 Cloudflare 那一段**(或本地到 CF 的线路)")
if c[1] > 0 or d[1] > 0:
print(" 而且对照组也抖 → 更像是**本地网络到外网**的问题,")
print(" 不是我们服务器或 Cloudflare 的锅。")
else:
print(" 对照组稳定 → 说明是 **Cloudflare ↔ 源站回源**这一段。")
elif a[1] > 0:
print(" → **直连源站也会失败** —— 那问题可能在源站或本地网络")
if d[1] > 0:
print(" 但百度也抖 → 先怀疑**本地网络**(换个网络再测)")
else:
print(" → 这一轮没测出抖动,说明是**间歇性**的(多测几轮)")
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -17,7 +17,7 @@ import zipfile
from pathlib import Path
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
VERSION = "2.2.0"
VERSION = "2.3.0"
NAME = f"ykxmz-{VERSION}"
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
+354
View File
@@ -0,0 +1,354 @@
# -*- coding: utf-8 -*-
r'''电脑端 2.3.0 改动:结束所有进程 + 播放器数据源。
== 加的三个东西 ==
1. **`kill_all`**(一键结束所有进程)
用户要求「一键结束所有进程」。但这个操作**很容易把机器搞崩**:
杀 csrss.exe 蓝屏、杀 winlogon.exe 注销、杀 explorer.exe 桌面消失、
杀掉自己就再也连不上了。
所以这里**白名单式保护**:
* 本程序自己的 PID
* 父进程链(打包后用 pythonw 起的话,父进程被杀会连带)
* 一批系统关键进程(见 PROTECTED_NAMES)
其余全杀。
2. **`media_list`**(列出电脑上的音视频文件)
给手表的播放器用。扫这几个目录:
%USERPROFILE%\Music 音乐
%USERPROFILE%\Videos 视频
%USERPROFILE%\Downloads 下载
%USERPROFILE%\Desktop 桌面
接收目录(程序自己的)
只收常见音视频扩展名,最多 300 个。
3. **`media_pull`**(把某个媒体文件发给手表)
**安全边界**:只允许上面那几个根目录里的文件。
绝不能变成"任意路径读文件" —— 那等于给了个远程文件读取器。
解析方式:先 resolve,再检查是否在允许的根目录之内
(用 `Path.relative_to` 判断,不是简单的字符串前缀比较 ——
前缀比较会被 `C:\Users\a\Music-evil` 这种绕过)。
'''
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FEATURES = ROOT / "windows" / "lanremote" / "features.py"
SERVER = ROOT / "windows" / "lanremote" / "server.py"
PROTOCOL = ROOT / "windows" / "lanremote" / "protocol.py"
def must(text: str, old: str, new: str, label: str) -> str:
n = text.count(old)
if n == 0:
raise SystemExit("✗ [%s] 锚点没找到" % label)
if n > 1:
raise SystemExit("✗ [%s] 锚点出现 %d 次" % (label, n))
out = text.replace(old, new, 1)
if new.strip()[:50] not in out:
raise SystemExit("✗ [%s] 替换后找不到新内容" % label)
print(" ✓ %s" % label)
return out
# ---------------------------------------------------------------- features
KILL_ALL_IMPL = '''
# ---------------- 一键结束所有进程(2.3.0)----------------
#
# 用户要求「一键结束所有进程」。这个操作很危险,所以做了**白名单保护**:
# * 本程序自己的 PID 和父进程链(杀了就再也连不上)
# * 系统关键进程(杀了会蓝屏/注销/桌面消失)
# 其余全杀。
# 绝对不能结束的进程名(小写)。
#
# 这些名字都是踩过坑或者有明确后果的:
# csrss / wininit / winlogon / services / lsass / smss -> 蓝屏或立即重启
# svchost -> 一杀就掉一片服务(网络、音频、打印全没)
# dwm -> 窗口全部失��重绘
# explorer -> 桌面和任务栏消失(虽然会自动重启,但用户会以为坏了)
# audiodg -> 音频引擎
# ykxmz / python / pythonw -> 本程序
PROTECTED_NAMES = {
"system", "system idle process", "registry", "memory compression",
"smss.exe", "csrss.exe", "wininit.exe", "winlogon.exe", "services.exe",
"lsass.exe", "lsm.exe", "svchost.exe", "dwm.exe", "fontdrvhost.exe",
"sihost.exe", "ctfmon.exe", "audiodg.exe", "spoolsv.exe",
"explorer.exe", "taskhost.exe", "taskhostw.exe", "runtimebroker.exe",
"searchindexer.exe", "wmiprvse.exe", "dllhost.exe", "conhost.exe",
"ykxmz.exe", "python.exe", "pythonw.exe", "cmd.exe", "taskkill.exe",
}
def _protected_pids(self) -> set:
"""自己和父进程链的 PID —— 这些杀了等于自断后路。"""
import os
import subprocess as _sp
pids = {os.getpid()}
pid = os.getpid()
for _ in range(6): # 往上追几层就够了
try:
out = _sp.run(
["wmic", "process", "where", "ProcessId=%d" % pid,
"get", "ParentProcessId", "/value"],
capture_output=True, timeout=10,
creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0),
encoding="mbcs", errors="replace").stdout or ""
parent = 0
for line in out.splitlines():
if "=" in line and "ParentProcessId" in line:
try:
parent = int(line.split("=")[-1].strip())
except ValueError:
parent = 0
if parent <= 0 or parent in pids:
break
pids.add(parent)
pid = parent
except Exception: # noqa: BLE001
break
return pids
def _do_kill_all(self, params) -> str:
import os
me = os.getpid()
guard = set(self.PROTECTED_NAMES)
for proc in self.actions.list_processes(limit=3):
pass
protected_pids = self._protected_pids()
killed = []
skipped = 0
failed = 0
for proc in self.actions.list_processes(limit=500):
name = (proc.get("name") or "").strip()
low = name.lower()
pid = int(proc.get("pid") or 0)
if pid <= 0 or pid == me or pid in protected_pids:
skipped += 1
continue
if low in guard:
skipped += 1
continue
try:
self.actions.kill_process(pid)
killed.append(name)
except Exception: # noqa: BLE001
failed += 1
if not killed:
return "没有可结束的程序(系统和本程序都已排除)"
head = "、".join(killed[:8])
more = "" if len(killed) <= 8 else ("… 等 %d 个" % len(killed))
return ("已结束 %d 个程序:%s%s(跳过系统进程 %d 个,失败 %d 个)"
% (len(killed), head, more, skipped, failed))
'''
# ---------------------------------------------------------------- server
MEDIA_IMPL = '''
# ---------------- 播放器数据源(2.3.0)----------------
#
# 手表上的播放器要"能看进度、能左右划 seek" —— 那就必须手表自己解码播放。
# 所以电脑端负责两件事:**告诉它有哪些媒体** + **把文件发过去**。
# 允许扫描的媒体目录(相对用户主目录)。
MEDIA_SUBDIRS = ("Music", "Videos", "Downloads", "Desktop",
"音乐", "视频", "下载", "桌面")
AUDIO_EXT = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg",
".wma", ".amr", ".opus", ".ape"}
VIDEO_EXT = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv",
".webm", ".3gp", ".m4v", ".ts", ".rmvb"}
MEDIA_LIST_LIMIT = 300
def _media_roots(self) -> list:
"""允许访问的根目录(都 resolve 成绝对路径)。"""
roots = []
home = Path.home()
for sub in self.MEDIA_SUBDIRS:
p = home / sub
try:
if p.is_dir():
roots.append(p.resolve())
except OSError:
continue
try:
roots.append(Path(self.store.received_dir()).resolve())
except Exception: # noqa: BLE001
pass
return roots
def _media_list(self) -> dict:
roots = self._media_roots()
files = []
for root in roots:
try:
entries = list(root.iterdir())
except OSError:
continue
for p in entries:
try:
if not p.is_file():
continue
ext = p.suffix.lower()
if ext in self.AUDIO_EXT:
kind = "audio"
elif ext in self.VIDEO_EXT:
kind = "video"
else:
continue
files.append({
"name": p.name,
"path": str(p),
"size": p.stat().st_size,
"kind": kind,
"mtime": p.stat().st_mtime,
})
except OSError:
continue
# 新的排前面
files.sort(key=lambda f: f.get("mtime", 0), reverse=True)
total = len(files)
shown = files[:self.MEDIA_LIST_LIMIT]
return {
"ok": True,
"files": shown,
"total": total,
"shown": len(shown),
"truncated": total > len(shown),
"roots": [str(r) for r in roots],
}
def _media_pull(self, session: "Session", path: str) -> dict:
"""把一个媒体文件发给手表。
⚠ **安全边界**:只允许媒体根目录里的文件。
不能用 `str(p).startswith(root)` 这种判断 —— 会被
`C:\\Users\\me\\Music-evil\\x.mp3` 绕过(前缀相同但不是子目录)。
必须用 `relative_to`,它在路径不真的位于该目录下时会抛 ValueError。
"""
wanted = Path(str(path or "")).expanduser()
try:
wanted = wanted.resolve()
except (OSError, ValueError) as exc:
raise ActionError(f"路径不合法: {exc}") from exc
allowed = False
for root in self._media_roots():
try:
wanted.relative_to(root)
allowed = True
break
except ValueError:
continue
if not allowed:
raise ActionError("这个文件不在允许的媒体目录里")
if not wanted.is_file():
raise ActionError(f"文件不存在: {wanted.name}")
size = wanted.stat().st_size
if size <= 0:
raise ActionError("文件是空的")
if size > 512 * 1024 * 1024:
raise ActionError("文件太大(超过 512MB),播放器放不下")
blob = wanted.read_bytes()
if not session.send_chunks(P.EVT_FILE, wanted.name, blob):
raise ActionError("发送失败:连接已断开")
return {"ok": True, "name": wanted.name, "size": len(blob)}
'''
def main() -> int:
print("=" * 62)
print(" 电脑端 2.3.0 改动")
print("=" * 62)
# ---------------- features.py ----------------
print()
print("── features.py ──")
ftext = FEATURES.read_text(encoding="utf-8")
# 1) 注册 kill_all
ftext = must(
ftext,
' add("kill_games", "结束游戏进程", "action", "教室")\n',
' add("kill_games", "结束游戏进程", "action", "教室")\n'
' add("kill_all", "结束所有进程", "action", "系统")\n',
"注册 kill_all 功能")
# 2) 加实现(放在 _do_kill_games / _do_kill_by_name 之后)
anchor = (
' def _do_kill_by_name(self, params) -> str:\n'
' name = str(params.get("name") or "").strip()\n'
' if not name:\n'
' raise ValueError("进程名为空")\n'
' return self.actions.kill_process(0, name)\n'
)
ftext = must(ftext, anchor, anchor + KILL_ALL_IMPL, "kill_all 实现")
FEATURES.write_text(ftext, encoding="utf-8", newline="\n")
# ---------------- server.py ----------------
print()
print("── server.py ──")
stext = SERVER.read_text(encoding="utf-8")
# 3) 在 _file_pull 之后插入 media_* 实现
anchor = (
' def _file_pull(self, session: Session, path: str) -> dict:\n'
' target = self._resolve_received(path)\n'
' if target is None:\n'
' raise ActionError(f"文件不存在: {path}")\n'
' blob = target.read_bytes()\n'
' if not session.send_chunks(P.EVT_FILE, target.name, blob):\n'
' raise ActionError("发送文件失败:连接已断开")\n'
' return {"ok": True, "name": target.name, "size": len(blob)}\n'
)
stext = must(stext, anchor, anchor + MEDIA_IMPL, "media_list / media_pull 实现")
# 4) 路由:在 CMD_FILE_LIST / CMD_FILE_PULL 旁边加
route_anchor = (
' if cmd == P.CMD_FILE_LIST:\n'
' return self._file_list()\n'
)
stext = must(
stext,
route_anchor,
route_anchor +
' if cmd == "media_list":\n'
' return self._media_list()\n',
"路由 media_list")
pull_anchor = (
' if cmd == P.CMD_FILE_PULL:\n'
)
if stext.count(pull_anchor) != 1:
raise SystemExit("✗ [路由 media_pull] CMD_FILE_PULL 锚点不唯一")
# 找到这个 if 块的结束(下一个同级 if)
idx = stext.find(pull_anchor)
# 插在 CMD_FILE_PULL 分支之前更简单
stext = stext.replace(
pull_anchor,
' if cmd == "media_pull":\n'
' return self._media_pull(session, str(msg.get("path") or ""))\n'
+ pull_anchor,
1)
print(" ✓ 路由 media_pull")
SERVER.write_text(stext, encoding="utf-8", newline="\n")
print()
print(" 完成")
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -23,7 +23,7 @@ def _latest_apk() -> Path:
(ROOT / "dist").glob("*watch*.apk"),
key=lambda p: p.stat().st_mtime, reverse=True)
return candidates[0] if candidates else \
ROOT / "dist" / "ykxmz-watch-2.2.0.apk"
ROOT / "dist" / "ykxmz-watch-2.3.0.apk"
APK = _latest_apk()
+8 -1
View File
@@ -156,7 +156,14 @@ def main() -> int:
print()
print("二、分页定义完整性")
tab_names = re.findall(r'new TabDef\("([^"]+)"', text)
check("定义了 10 个分页", len(tab_names) == 10, f"实际 {len(tab_names)} 个")
# 2.3.0:用户要求「杂七杂八功能都删掉」,分页从 10 个精简到 7 个。
# 断言改成 7,并且把期望的名字也列出来 —— 这样以后误删/误加
# 都能立刻看出来,比只数个数更有用。
EXPECT_TABS = ["屏幕", "鼠标", "媒体", "文件", "电脑", "语音", "电源"]
check(f"定义了 {len(EXPECT_TABS)} 个分页", len(tab_names) == len(EXPECT_TABS),
f"实际 {len(tab_names)} 个:{' '.join(tab_names)}")
check("分页名字和 2.3.0 设计一致", tab_names == EXPECT_TABS,
f"实际 {' '.join(tab_names)},期望 {' '.join(EXPECT_TABS)}")
print(f" 分页: {' '.join(tab_names)}")
check("selectTab 有定义", "private void selectTab(" in text)
check("selectTab 被调用", len(re.findall(r"\bselectTab\s*\(", text)) > 1)
+4 -4
View File
@@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
# 被控端 -> 一个 zip
# 服务器端 -> 一个 zip
# 控制端 -> 直接给 APK(不打 zip)
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.2.0.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.2.0.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.2.0.apk"
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.3.0.zip"
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.3.0.zip"
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.3.0.apk"
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
@@ -89,7 +89,7 @@ def main() -> int:
for label, path in (("被控端", agent_dir / "README-agent.txt"),
("服务器端", relay_dir / "README-server.txt"),
("控制端(旁边那份)",
ROOT / "dist" / "ykxmz-watch-2.2.0-说明.txt")):
ROOT / "dist" / "ykxmz-watch-2.3.0-说明.txt")):
raw = path.read_bytes() if path.is_file() else b""
check(f"{label}说明有 UTF-8 BOM",
raw[:3] == b"\xef\xbb\xbf", raw[:3])
+40 -10
View File
@@ -170,21 +170,51 @@ def main() -> int:
# ---------------- 五、鼠标触摸板 ----------------
print()
print("五、鼠标触摸板")
print("五、鼠标触摸板(2.3.0 版:滑动区 + 左键/滚轮/右键)")
check("有 buildMousePanel", "private void buildMousePanel()" in main_java)
check("用 GestureDetector(单击/双击/拖动)",
"GestureDetector" in main_java
and "onSingleTapConfirmed" in main_java
and "onDoubleTap" in main_java
and "onScroll" in main_java)
check("单击 -> 左键", 'sendMouse("click", "left", 0)' in main_java)
check("双击 -> 右键", 'sendMouse("click", "right", 0)' in main_java)
check("滚轮上/下", 'sendMouse("scroll", "", 3)' in main_java
and 'sendMouse("scroll", "", -3)' in main_java)
# 2.3.0 用户明确要求:「中间是一个鼠标,可以上下左右滑动控制,
# 下面有三个按钮,左边是左键,中间是那个滑轮,右边是那个右键」。
# 所以**不再用单击/双击手势** —— 左键和右键各有专门按钮,
# 手势里只留"滑动 = 移动光标"。
check("滑动区用 GestureDetector 的 onScroll(移动光标)",
"GestureDetector" in main_java and "onScroll" in main_java)
check("左键按钮 -> sendMouse click left",
'sendMouse("click", "left", 0)' in main_java)
check("右键按钮 -> sendMouse click right",
'sendMouse("click", "right", 0)' in main_java)
check("滚轮按钮:点一下 = 中键", 'sendMouse("click", "middle", 0)' in main_java)
check("滚轮按钮:上下滑 = 滚动",
'sendMouse("scroll", "", dir)' in main_java)
check("滚轮按钮有独立的触摸处理(不是普通 OnClickListener)",
"wheel.setOnTouchListener" in main_java)
check("拖动 -> moveby", '"moveby"' in main_java)
check("isConnected() 存在(第一版漏了,编译才发现)",
"private boolean isConnected()" in main_java)
# ---------------- 五之一、播放器(2.3.0 新增)----------------
print()
print("五之一、播放器")
check("有 buildPlayerPanel", "private void buildPlayerPanel()" in main_java)
check("有 seekTo / seekBy(进度左右划)",
"private void seekTo(int ms)" in main_java
and "private void seekBy(int deltaMs)" in main_java)
check("有上一/下一(stepPlayer)",
"private void stepPlayer(int delta)" in main_java)
check("视频用 VideoView", "android.widget.VideoView" in main_java)
check("音频用 MediaPlayer", "android.media.MediaPlayer" in main_java)
check("画面区左右滑 = 快进/快退",
"seekBy(dx > 0 ? 10000 : -10000)" in main_java)
check("画面区点击 = 播放/暂停",
"public boolean onSingleTapConfirmed" in main_java)
check("★ 播放器面板登记进 showOnly 数组", "playerPanel" in main_java
and re.search(r"View\[\] all = \{[^}]*playerPanel", main_java) is not None,
"漏了的话它永远不隐藏")
check("★ 播放器面板挂到了 root 上",
"root.addView(playerPanel" in main_java,
"只建不挂 = 一片空白")
check("★ 播完自动下一首",
"onCompletion" in main_java and "stepPlayer(1)" in main_java)
# ---------------- 五之二、连接后的选择页 ----------------
print()
print("五之二、连接后的选择页(远控 / 静默更新被控端)")
+192
View File
@@ -0,0 +1,192 @@
# -*- coding: utf-8 -*-
"""中继站:管理员改码 + 关机超 TTL 后不能丢(2.3.0)。
要钉死的行为:
① 管理员在后台把配对码从 111111 改成 222222
② 那台电脑关机(或断网)超过 DEVICE_TTL(真实 6 小时)
③ snapshot() 把设备记录和"孤儿"码绑定都清掉
④ 电脑开机,报**自己本地的旧码** 111111
⑤ 中转应该回它 222222,并让它写回本地设置
—— 改动之前这里会收下 111111,管理员的改码白做
另外顺带验证:
* 老格式 relay_state.json(knownDevices 是纯 ID 列表)能平滑升级
* 「删除设备」之后历史被忘干净(开关语义不能被绕)
* 「抹码」之后历史里记的码也清空(否则抹码白做)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PORT = 8151
BASE = f"http://127.0.0.1:{PORT}"
_UA = {"User-Agent": "LanRemote/2.3.0"}
ADMIN_KEY = "recode-ttl-key"
TTL = 2.0
passed = 0
failed = 0
def check(name: str, ok: bool, detail: str = "") -> bool:
global passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else ""))
return ok
def main() -> int:
work = Path(tempfile.mkdtemp(prefix="recode_ttl_"))
data = work / "d"
data.mkdir(parents=True, exist_ok=True)
env = dict(os.environ)
env.update({
"RELAY_PORT": str(PORT), "RELAY_BIND": "127.0.0.1",
"RELAY_DATA": str(data), "RELAY_DEVICE_TTL": str(TTL),
"RELAY_ADMIN_KEY": ADMIN_KEY, "RELAY_POLL": "2",
"PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1",
})
relay = None
def start() -> subprocess.Popen:
p = subprocess.Popen(
[sys.executable, "-u", str(ROOT / "relay" / "relay.py")], env=env,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(80):
time.sleep(0.4)
try:
health()
return p
except Exception: # noqa: BLE001
continue
return p
def health() -> dict:
r = urllib.request.Request(BASE + "/v1/health", headers=_UA)
with urllib.request.urlopen(r, timeout=5) as resp:
return json.loads(resp.read().decode("utf-8"))
def post(path: str, payload: dict, admin: bool = False) -> dict:
body = json.dumps(payload).encode()
headers = {**_UA, "Content-Type": "application/json"}
if admin:
headers["X-Admin"] = ADMIN_KEY
req = urllib.request.Request(BASE + path, data=body, method="POST",
headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
try:
return json.loads(exc.read().decode("utf-8"))
except Exception: # noqa: BLE001
return {}
def reg(dev: str, code: str, name: str = "教室电脑") -> dict:
return post("/v1/agent/register",
{"deviceId": dev, "code": code, "name": name,
"version": "2.3.0"})
try:
relay = start()
dev = "classroom-PC-AAAA1111"
old_code = "111111"
new_code = "222222"
print()
print("═══ 1. 登记 + 管理员改码 ═══")
r = reg(dev, old_code)
check("① 首次登记成功", r.get("ok") is True, r)
check("② 登记返回的码是它上报的", r.get("code") == old_code, r)
res = post("/v1/admin/set_code",
{"deviceId": dev, "code": new_code}, admin=True)
check("③ 管理员改码成功", res.get("ok") is True, res)
r2 = reg(dev, old_code)
check("④ 在线时改码生效(回的是新码)",
r2.get("ok") is True and r2.get("code") == new_code, r2)
print()
print(f"═══ 2. 电脑关机,等 {TTL + 2:.0f} 秒超过 TTL ═══")
time.sleep(TTL + 2)
h = health()
check("⑤ 设备记录被清掉(设计中如此)",
dev not in [d["deviceId"] for d in h["devices"]])
print(f" 清理后 codeCount={h.get('codeCount')}")
print()
print("═══ 3. 电脑开机,报本地旧码 —— 应该被纠正成新码 ═══")
r3 = reg(dev, old_code)
check("⑥ 开机登记成功", r3.get("ok") is True, r3)
check("⑦ **写回的是新码 222222,不是它报的旧码**",
r3.get("code") == new_code,
f"拿到 {r3.get('code')!r},期望 {new_code!r}")
check("⑧ 有 note 说明改码原因", bool(r3.get("note")), r3.get("note"))
h2 = health()
codes_now = [d.get("code") for d in h2["devices"]]
check("⑨ /v1/health 里这台设备显示的也是新码",
new_code in codes_now, codes_now)
print()
print("═══ 4. 重启中转 —— 新码要能持久化 ═══")
relay.terminate()
relay.wait(timeout=15)
relay = start()
r4 = reg(dev, old_code)
check("⑩ 重启后仍然纠正成新码",
r4.get("ok") is True and r4.get("code") == new_code, r4)
print()
print("═══ 5. 「删除设备」要忘干净(否���开关被绕过)═══")
post("/v1/admin/delete", {"deviceId": dev}, admin=True)
post("/v1/admin/allow_new", {"allow": False}, admin=True)
r5 = reg(dev, new_code)
check("⑪ 删除后设备被当成新设备,且开关关着 -> 拒绝",
r5.get("ok") is False and r5.get("blocked") is True, r5)
post("/v1/admin/allow_new", {"allow": True}, admin=True)
print()
print("═══ 6. 「抹码」之后历史里的码也要清空 ═══")
dev2 = "classroom-PC-BBBB2222"
reg(dev2, "333333")
post("/v1/admin/forget", {"code": "333333"}, admin=True)
r6 = reg(dev2, "444444")
check("⑫ 抹码后可以换新码(历史没把旧码塞回来)",
r6.get("ok") is True and r6.get("code") == "444444", r6)
r7 = reg(dev2, "555555")
check("⑬ 但换过之后又锁定单码制(回 444444)",
r7.get("ok") is True and r7.get("code") == "444444", r7)
print()
print("=" * 62)
print(f" 通过 {passed} 项,失败 {failed} 项")
print("=" * 62)
return 0 if failed == 0 else 1
finally:
if relay is not None and relay.poll() is None:
relay.terminate()
try:
relay.wait(timeout=10)
except Exception: # noqa: BLE001
relay.kill()
if __name__ == "__main__":
sys.exit(main())
+129
View File
@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
"""2.3.0 新命令的单元测试:media_list / media_pull / kill_all。
重点验**安全边界**,不是"能跑就行":
* `media_pull` 必须拒绝媒体目录之外的文件(否则就是个远程任意文件读取器)
* 同前缀的兄弟目录(`音乐-evil`)不能被误判成子目录
* `kill_all` 必须保护自己、父进程链、系统关键进程
"""
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "windows"))
passed = 0
failed = 0
def check(name, ok, detail=""):
global passed, failed
if ok:
passed += 1
print(" [PASS] " + name)
else:
failed += 1
print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else ""))
return ok
def main() -> int:
print("=" * 62)
print(" 2.3.0 新命令测试")
print("=" * 62)
# ---------- 1. 静态检查:实现存在且关键点齐 ----��------
print()
print("一、代码存在性")
server = (ROOT / "windows" / "lanremote" / "server.py").read_text(
encoding="utf-8")
features = (ROOT / "windows" / "lanremote" / "features.py").read_text(
encoding="utf-8")
check("server.py 有 _media_list", "def _media_list(self)" in server)
check("server.py 有 _media_pull", "def _media_pull(self" in server)
check("路由 media_list", 'cmd == "media_list"' in server)
check("路由 media_pull", 'cmd == "media_pull"' in server)
check("features.py 有 _do_kill_all", "def _do_kill_all(self" in features)
check("注册了 kill_all 功能", 'add("kill_all"' in features)
# ---------- 2. 安全边界:只允许媒体目录 ----------
print()
print("二、media_pull 的安全边界")
check("用 relative_to 判断(不是字符串前缀)",
"wanted.relative_to(root)" in server,
"字符串前缀会被「音乐-evil」这种同前缀兄弟目录绕过")
check("有「不在允许的媒体目录里」的拒绝",
"不在允许的媒体目录里" in server)
check("有文件大小上限(512MB)", "512 * 1024 * 1024" in server)
check("拒绝空文件", "文件是空的" in server)
# ---------- 3. kill_all 的保护名单 ----------
print()
print("三、kill_all 的保护名单")
for name in ("csrss.exe", "winlogon.exe", "lsass.exe", "svchost.exe",
"explorer.exe", "python.exe", "ykxmz.exe"):
check("保护 %s" % name, '"%s"' % name in features,
"少了它一按就可能蓝屏/自断后路")
check("保护自己的 PID", "_protected_pids" in features)
check("追父进程链", "ParentProcessId" in features)
# ---------- 4. 真的跑一次 _media_list(用临时目录)----------
print()
print("四、_media_list 实跑")
try:
import os
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
from lanremote import server as srv_mod
# 造一个假的 store(不需要 Config —— 那个类不存在,之前是我写错了)
tmp = Path(tempfile.mkdtemp(prefix="media_"))
music = tmp / "Music"
music.mkdir()
(music / "a.mp3").write_bytes(b"x" * 100)
(music / "b.mp4").write_bytes(b"y" * 200)
(music / "c.txt").write_text("not media", encoding="utf-8")
class FakeStore:
def received_dir(self):
return tmp
# 直接借 Server 的方法:构造太麻烦,用 types 绑定
import types
obj = types.SimpleNamespace()
obj.store = FakeStore()
obj.AUDIO_EXT = srv_mod.RemoteServer.AUDIO_EXT
obj.VIDEO_EXT = srv_mod.RemoteServer.VIDEO_EXT
obj.MEDIA_LIST_LIMIT = srv_mod.RemoteServer.MEDIA_LIST_LIMIT
obj.MEDIA_SUBDIRS = ("Music", "Videos")
obj._media_roots = types.MethodType(srv_mod.RemoteServer._media_roots, obj)
# Path.home() 是真实用户目录,这里把 _media_roots 换成只返回 music。
# ⚠ 必须返回**装文件的那一层**,不是它的父目录 ——
# _media_list 只扫根目录下的文件,不递归子目录。
# (第一版写成了 tmp,扫到的是 Music 这个文件夹本身,全被跳过。)
obj._media_roots = lambda: [music.resolve()]
result = srv_mod.RemoteServer._media_list(obj)
names = [f["name"] for f in result["files"]]
check("列出了 a.mp3", "a.mp3" in names)
check("列出了 b.mp4", "b.mp4" in names)
check("**排除了 c.txt**", "c.txt" not in names,
"非媒体扩展名不该出现")
kinds = {f["name"]: f["kind"] for f in result["files"]}
check("a.mp3 标成 audio", kinds.get("a.mp3") == "audio")
check("b.mp4 标成 video", kinds.get("b.mp4") == "video")
except Exception as exc: # noqa: BLE001
check("_media_list 实跑", False, "%s: %s" % (type(exc).__name__, exc))
print()
print("=" * 62)
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
print("=" * 62)
return 0 if failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -2,7 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.lanremote.watch"
android:versionCode="5"
android:versionName="2.2.0">
android:versionName="2.3.0">
<!-- ⚠ 允许明文 HTTP。
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
+2 -2
View File
@@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat"
$work = Join-Path $android "build"
$outDir = Join-Path $root "dist"
$apkName = "ykxmz-watch-2.2.0.apk"
$apkName = "ykxmz-watch-2.3.0.apk"
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
@@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk"
--min-sdk-version 24 `
--target-sdk-version 34 `
--version-code 5 `
--version-name "2.2.0" `
--version-name "2.3.0" `
--auto-add-overlay
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['../../_build/diag_tool.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='ykxmz诊断',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
Binary file not shown.
@@ -0,0 +1,25 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named 'org.python' - imported by copy (optional)
missing module named org - imported by pickle (optional)
missing module named pwd - imported by posixpath (delayed, conditional), shutil (optional), tarfile (optional), pathlib (delayed, conditional, optional)
missing module named grp - imported by shutil (optional), tarfile (optional), pathlib (delayed)
missing module named _posixsubprocess - imported by subprocess (optional)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional)
missing module named posix - imported by os (conditional, optional), shutil (conditional), importlib._bootstrap_external (conditional)
missing module named resource - imported by posix (top-level)
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
版本:2.2.0
版本:2.3.0
作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -197,7 +197,7 @@
八、版本记录
--------------------------------------------------------
2.2.0
2.3.0
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
合计 10 条连接),一种协议犯病另一种还在服务
@@ -218,6 +218,6 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.2.0
软件:ykxmz 2.3.0
许可条款见《许可协议》(LICENSE.txt)
--------------------------------------------------------
+2 -2
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz
版本:2.2.0
版本:2.3.0
作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.2.0
软件:ykxmz 2.3.0
继续安装,即表示你已阅读并同意《许可协议》。
--------------------------------------------------------
+3 -3
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz(远程管理工具)
版本:2.2.0
版本:2.3.0
作者:小麦子
生效日期:2026 年 10 月 5 日
@@ -16,7 +16,7 @@
> 都不许拿去做下面第三条那些事。**
>
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
> 那和 AGPL 冲突,所以 2.2.0 起拆开了:代码授权归 LICENSE,
> 那和 AGPL 冲突,所以 2.3.0 起拆开了:代码授权归 LICENSE,
> 使用约束归这份文件。)
@@ -160,7 +160,7 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.2.0
软件:ykxmz 2.3.0
如果你不同意本条款的任何一条,请不要安装或使用本软件。
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
+205
View File
@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>远控软件正式上线 —— 复制用</title>
<style>
body{max-width:760px;margin:40px auto;padding:0 24px;
font-family:"Microsoft YaHei UI","Microsoft YaHei",system-ui,sans-serif;
font-size:16px;line-height:1.85;color:#1f2328}
h1{font-size:28px;margin:0 0 8px}
h2{font-size:22px;margin:36px 0 12px;padding-top:8px;border-top:1px solid #eee}
h3{font-size:18px;margin:26px 0 10px}
blockquote{margin:14px 0;padding:10px 16px;background:#f6f8fa;
border-left:4px solid #d0d7de;color:#4b5563}
ul,ol{padding-left:26px}
li{margin:6px 0}
table{border-collapse:collapse;margin:14px 0;width:100%;font-size:15px}
th,td{border:1px solid #d0d7de;padding:7px 11px;text-align:left}
th{background:#f6f8fa}
code{background:#f6f8fa;padding:1px 5px;border-radius:4px;
font-family:Consolas,monospace;font-size:14px}
hr{border:0;border-top:1px solid #e6e8eb;margin:32px 0}
.note{color:#6b7280;font-size:14px}
.tip{background:#fff8e6;border-left:4px solid #f0b429;padding:12px 16px;margin:18px 0}
</style>
</head>
<body>
<div class="tip">
<b>怎么用这个文件:</b><br>
1. 双击打开(会用浏览器打开)<br>
2. <b>Ctrl + A</b> 全选 → <b>Ctrl + C</b> 复制<br>
3. 回到 Halo 编辑器,<b>Ctrl + V</b> 粘贴<br>
4. <b>不要动格式</b>,直接预览;确认没问题再发布<br>
<span class="note">(这个黄色提示框本身不要复制进去)</span>
</div>
<hr>
<h1>远控软件正式上线</h1>
<blockquote>
一个为「站在讲台前的人」做的远程控制工具。<br>
手表抬一下手腕,就能操作教室那台电脑。
</blockquote>
<hr>
<h2>它是干什么的</h2>
<p>很多场景下,人不在电脑前面,但需要操作那台电脑:</p>
<ul>
<li>上课时站在教室中间,想切个课件、放张图</li>
<li>坐在办公室,想看看教室那台电脑还开着没</li>
<li>手上拿着东西,腾不出手去碰鼠标键盘</li>
</ul>
<p><b>这个软件就是解决这个的。</b></p>
<p>电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。</p>
<hr>
<h2>能做什么</h2>
<h3>🖱️ 远程鼠标控制</h3>
<p>手表变成触摸板:</p>
<ul>
<li><b>拖动</b> —— 移动鼠标</li>
<li><b>单击</b> —— 左键</li>
<li><b>双击</b> —— 右键</li>
<li>下面还有专门的<b>左键 / 右键 / 滚轮上 / 滚轮下</b>按钮</li>
</ul>
<p>精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。</p>
<h3>🖥️ 一键显示桌面</h3>
<p>快捷栏上固定有一格「显示桌面」,点一下就是电脑上按 <code>Win + D</code> 的效果。</p>
<p>需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。</p>
<h3>🔄 电脑端静默自动更新</h3>
<p>这是最省心的一点:</p>
<p><b>手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。</b></p>
<ul>
<li>全程<b>不弹任何窗口</b></li>
<li>中转地址、配对码、所有设置<b>一个都不变</b></li>
<li>更新完自动回来继续跑</li>
</ul>
<p>不用再跑到电脑前手动装一遍。</p>
<h3>⌨️ 顶部快捷栏</h3>
<p>任何页面顶上都有一条栏,<b>4 个格子全部可以自己定义</b>:</p>
<table>
<thead>
<tr><th>可选动作</th><th>说明</th></tr>
</thead>
<tbody>
<tr><td>传文件</td><td>从手表把文件发到电脑</td></tr>
<tr><td>屏幕镜像</td><td>看电脑屏幕</td></tr>
<tr><td>鼠标控制</td><td>进触摸板模式</td></tr>
<tr><td>音效</td><td>让电脑放一声</td></tr>
<tr><td>关机</td><td>锁屏 / 关机 / 重启</td></tr>
<tr><td>语音播报</td><td>让电脑念一段字</td></tr>
<tr><td>投图到电脑</td><td>手表选图,全屏投到大屏</td></tr>
<tr><td>显示桌面</td><td>等于 Win + D</td></tr>
</tbody>
</table>
<p>在设置页里点一下就能换。</p>
<h3>🖼️ 投图到大屏</h3>
<p>手表上选一张图,直接全屏显示在电脑(也就是投影)上。</p>
<p>上课讲题、展示学生作业、临时放张图,比插 U 盘快得多。</p>
<h3>🔊 语音播报</h3>
<p>输入文字,电脑用系统自带的语音念出来。</p>
<p>「上课了」「下课了」「大家看一下黑板」—— 说一遍不如让电脑说一遍。</p>
<h3>🎵 让电脑放个音效</h3>
<p>内置了一批<b>程序生成</b>的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。</p>
<h3>💬 其他</h3>
<ul>
<li>剪贴板互传(手表 ↔ 电脑)</li>
<li>电源控制(锁屏 / 关机 / 重启)</li>
<li>文件互传</li>
<li>静音、音量加减、播放控制</li>
</ul>
<hr>
<h2>几个设计上的取舍</h2>
<h3>电脑端不弹任何窗口</h3>
<p>屏幕被观看的时候<b>不显示提示条</b>,整个软件在后台完全没有可见窗口。</p>
<blockquote>
这一点是刻意的:要的就是「像不存在一样」。<br>
如果这是<b>你自己的电脑</b>,你需要一个能看见它在做什么的开关 ——
设置里有,默认关着。
</blockquote>
<h3>支持 Windows 7</h3>
<p>老机器也能装。这一点花了些功夫:<b>内嵌的 Python 运行时用的是 3.8</b>(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。</p>
<h3>静默更新为什么要校验</h3>
<p>自更新最大的风险是<b>下载中途出问题</b>,装上一个坏程序 —— 而它还会自己继续更新。</p>
<p>所以升级包会校验 SHA-256,<b>对不上就放弃这一轮,保持现状</b>。现状至少是能用的。</p>
<hr>
<h2>怎么装</h2>
<p><b>官网下载:</b><a href="https://yk.xxmxxs.top">https://yk.xxmxxs.top</a></p>
<ul>
<li><b>电脑端</b> —— 下载安装包,双击,填一个 6 位配对码就完事</li>
<li><b>手表端</b> —— 下载 APK 安装,输入刚才那个 6 位码</li>
</ul>
<p>装完之后:</p>
<ol>
<li>手表连上电脑</li>
<li>会先到一个选择页:<b>远控</b> / <b>静默更新被控端</b></li>
<li>点「远控」进控制页</li>
</ol>
<hr>
<h2>说在最后</h2>
<p>这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。</p>
<p>后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。</p>
<p><b>有问题、想要什么功能,欢迎留言。</b></p>
<hr>
<p class="note">本文提到的所有功能以官网最新版本为准。软件持续更新中。</p>
</body>
</html>
+142
View File
@@ -0,0 +1,142 @@
# 远控软件正式上线
> 一个为「站在讲台前的人」做的远程控制工具。
> 手表抬一下手腕,就能操作教室那台电脑。
---
## 它是干什么的
很多场景下,人不在电脑前面,但需要操作那台电脑:
- 上课时站在教室中间,想切个课件、放张图
- 坐在办公室,想看看教室那台电脑还开着没
- 手上拿着东西,腾不出手去碰鼠标键盘
**这个软件就是解决这个的。**
电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。
---
## 能做什么
### 🖱️ 远程鼠标控制
手表变成触摸板:
- **拖动** —— 移动鼠标
- **单击** —— 左键
- **双击** —— 右键
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
### 🖥️ 一键显示桌面
快捷栏上固定有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
### 🔄 电脑端静默自动更新
这是最省心的一点:
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
- 全程**不弹任何窗口**
- 中转地址、配对码、所有设置**一个都不变**
- 更新完自动回来继续跑
不用再跑到电脑前手动装一遍。
### ⌨️ 顶部快捷栏
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
| 可选动作 | 说明 |
|---|---|
| 传文件 | 从手表把文件发到电脑 |
| 屏幕镜像 | 看电脑屏幕 |
| 鼠标控制 | 进触摸板模式 |
| 音效 | 让电脑放一声 |
| 关机 | 锁屏 / 关机 / 重启 |
| 语音播报 | 让电脑念一段字 |
| 投图到电脑 | 手表选图,全屏投到大屏 |
| 显示桌面 | 等于 Win + D |
在设置页里点一下就能换。
### 🖼️ 投图到大屏
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
上课讲题、展示学生作业、临时放张图,比插 U 盘快得多。
### 🔊 语音播报
输入文字,电脑用系统自带的语音念出来。
「上课了」「下课了」「大家看一下黑板」—— 说一遍不如让电脑说一遍。
### 🎵 让电脑放个音效
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
### 💬 其他
- 剪贴板互传(手表 ↔ 电脑)
- 电源控制(锁屏 / 关机 / 重启)
- 文件互传
- 静音、音量加减、播放控制
---
## 几个设计上的取舍
### 电脑端不弹任何窗口
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
> 这一点是刻意的:要的就是「像不存在一样」。
> 如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 ——
> 设置里有,默认关着。
### 支持 Windows 7
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
### 静默更新为什么要校验
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
---
## 怎么装
**官网下载:<https://yk.xxmxxs.top>**
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
装完之后:
1. 手表连上电脑
2. 会先到一个选择页:**远控** / **静默更新被控端**
3. 点「远控」进控制页
---
## 说在最后
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
**有问题、想要什么功能,欢迎留言。**
---
<small>本文提到的所有功能以官网最新版本为准。软件持续更新中。</small>
+142
View File
@@ -0,0 +1,142 @@
# 远控软件正式上线
> 一个为「想控制学校电脑的人」做的远程控制工具。
> 手表抬一下手腕,就能操作教室那台电脑。
---
## 它是干什么的
很多场景下,你想恶搞老师,却不知道什么方法:
- 上课时用华强北手表,插入一些图片,弹出一些文字
- 下课时远程打开网站看视频
- 人不在学校,想看看老师在拿电脑看什么
**这个软件就是解决这个的。**
电脑端装一次,在后台静默运行,托盘页没有;手表端装上之后,输入 6 位配对码就能连上。
---
## 能做什么
### 🖱️ 远程鼠标控制
手表变成触摸板:
- **拖动** —— 移动鼠标
- **单击** —— 左键
- **双击** —— 右键
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
### 🖥️ 一键显示桌面
快捷栏上固定有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
### 🔄 电脑端静默自动更新
这是最省心的一点:
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
- 全程**不弹任何窗口**
- 中转地址、配对码、所有设置**一个都不变**
- 更新完自动回来继续跑
不用再跑到电脑前手动装一遍。
### ⌨️ 顶部快捷栏
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
| 可选动作 | 说明 |
|---|---|
| 传文件 | 从手表把文件发到电脑 |
| 屏幕镜像 | 看电脑屏幕 |
| 鼠标控制 | 进触摸板模式 |
| 音效 | 让电脑放一声 |
| 关机 | 锁屏 / 关机 / 重启 |
| 语音播报 | 让电脑念一段字 |
| 投图到电脑 | 手表选图,全屏投到大屏 |
| 显示桌面 | 等于 Win + D |
在设置页里点一下就能换。
### 🖼️ 投图到大屏
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
上课讲题、展示学生作业、临时放张图,比插 U 盘快得多。
### 🔊 语音播报
输入文字,电脑用系统自带的语音念出来。
「上课了」「下课了」「大家看一下黑板」—— 说一遍不如让电脑说一遍。
### 🎵 让电脑放个音效
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
### 💬 其他
- 剪贴板互传(手表 ↔ 电脑)
- 电源控制(锁屏 / 关机 / 重启)
- 文件互传
- 静音、音量加减、播放控制
---
## 几个设计上的取舍
### 电脑端不弹任何窗口
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
> 这一点是刻意的:要的就是「像不存在一样」。
> 如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 ——
> 设置里有,默认关着。
### 支持 Windows 7
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
### 静默更新为什么要校验
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
---
## 怎么装
**官网下载:<https://yk.xxmxxs.top>**
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
装完之后:
1. 手表连上电脑
2. 会先到一个选择页:**远控** / **静默更新被控端**
3. 点「远控」进控制页
---
## 说在最后
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
**有问题、想要什么功能,欢迎留言。**
---
<small>本文提到的所有功能以官网最新版本为准。软件持续更新中。</small>
+8 -8
View File
@@ -3,7 +3,7 @@
========================================================
软件名称:ykxmz(远程管理工具)
版本:2.2.0
版本:2.3.0
作者:小麦子
生效日期:2026 年 10 月 5 日
@@ -16,7 +16,7 @@
> 都不许拿去做下面第三条那些事。**
>
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
> 那和 AGPL 冲突,所以 2.2.0 起拆开了:代码授权归 LICENSE,
> 那和 AGPL 冲突,所以 2.3.0 起拆开了:代码授权归 LICENSE,
> 使用约束归这份文件。)
@@ -160,7 +160,7 @@
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.2.0
软件:ykxmz 2.3.0
如果你不同意本条款的任何一条,请不要安装或使用本软件。
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
@@ -173,7 +173,7 @@
========================================================
软件名称:ykxmz
版本:2.2.0
版本:2.3.0
作者:小麦子
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
@@ -334,7 +334,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.2.0
软件:ykxmz 2.3.0
继续安装,即表示你已阅读并同意《许可协议》。
--------------------------------------------------------
@@ -345,7 +345,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
========================================================
软件名称:ykxmz
版本:2.2.0
版本:2.3.0
作者:小麦子
装好之后看这一页:怎么确认它在跑、怎么改设置、
@@ -539,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
八、版本记录
--------------------------------------------------------
2.2.0
2.3.0
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
合计 10 条连接),一种协议犯病另一种还在服务
@@ -560,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
--------------------------------------------------------
作者:小麦子
软件:ykxmz 2.2.0
软件:ykxmz 2.3.0
许可条款见《许可协议》(LICENSE.txt)
--------------------------------------------------------
+2 -2
View File
@@ -27,7 +27,7 @@
; ============================================================================
#define MyAppName "ykxmz"
#define MyAppVersion "2.2.0"
#define MyAppVersion "2.3.0"
#define MyAppPublisher "小麦子"
#define MyAppExeName "ykxmz.exe"
@@ -163,7 +163,7 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
; (PowerShell 的 Start-Process -Wait 就是)。
; 学校批量部署请用 cmd:
; cmd /c ykxmz-setup-2.2.0.exe /VERYSILENT /CODE=123456
; cmd /c ykxmz-setup-2.3.0.exe /VERYSILENT /CODE=123456
; cmd 只等安装器本身,不等孙子进程,不会卡。
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
Description: "立即启动(后台运行,不显示窗口)"; \
+94 -27
View File
@@ -60,7 +60,7 @@ from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
VERSION = "2.2.0"
VERSION = "2.3.0"
# ---------------- 参数(都能用环境变量覆盖)----------------
@@ -603,10 +603,23 @@ class Relay:
self.retired: dict = {}
# 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。
self.allow_new = True
# 设备历史名单(只有 deviceId,不含配对码)。
# 设备历史:**deviceId -> 配对码**。
#
# 为什么是字典而不是集合(2.2.1 改):
# 集合只能记住"这设备来过",记不住"它该用哪个码"。
# 于是这条链会断:
# ① 管理员在后台把码 111111 改成 222222
# ② 那台电脑关机(或断网)超过 DEVICE_TTL(6 小时)
# ③ snapshot() 把设备删了,孤儿的码 222222 也被清掉
# ④ 电脑开机,报自己本地的旧码 111111
# ⑤ 中转已经不记得改过码 -> 收下 111111
# ⑥ **管理员的改码白做了**,手表手里是 222222,连不上
# 把码记进历史后,步骤 ⑤ 会变成"你应该是 222222",
# 并随 register() 的返回值告诉客户端,客户端写回本地设置。
#
# **超时清理绝不动它** —— 它是"这台设备曾经被允许过"的唯一凭证。
# 详见 register() 里 known_device 的注释。
self.known_devices: set = set()
self.known_devices: dict = {}
self.started = time.time()
self.total_requests = 0
self.state_file = DATA_DIR / "relay_state.json"
@@ -621,11 +634,25 @@ class Relay:
return
# 是否允许新设备接入(重启不丢)
self.allow_new = bool(raw.get("allowNew", True))
# 恢复设备历史名单(重启不丢)
for did in raw.get("knownDevices", []):
did = str(did or "").strip()
if did:
self.known_devices.add(did)
# 恢复设备历史(重启不丢)。
#
# ⚠ 兼容老格式:2.2.1 之前存的是**纯 ID 列表**
# (`"knownDevices": ["abc", "def"]`),
# 2.2.1 起是**字典**(`"knownDevices": {"abc": "123456"}`)。
# 老格式加载时码记成空串 —— 表示"认识这台设备,但不知道它该用哪个码",
# 下次它上报什么就用什么(跟改动之前的行为一致)。
raw_known = raw.get("knownDevices") or {}
if isinstance(raw_known, dict):
for did, code in raw_known.items():
did = str(did or "").strip()
if did:
self.known_devices[did] = str(code or "").strip()
elif isinstance(raw_known, list):
# 老格式:只有 ID
for did in raw_known:
did = str(did or "").strip()
if did:
self.known_devices[did] = ""
for item in raw.get("codes", []):
device_id = str(item.get("deviceId", ""))
code = str(item.get("code", ""))
@@ -639,8 +666,9 @@ class Relay:
payload = {
"allowNew": bool(self.allow_new),
# 设备历史:超时清理不会删它,所以关掉接入开关后
# 老设备第二天开机仍然能上线
"knownDevices": sorted(self.known_devices),
# 老设备第二天开机仍然能上线。
# 2.2.1 起顺便记住"它该用哪个码",避免改码被超时清理冲掉。
"knownDevices": dict(self.known_devices),
"codes": [
{"deviceId": did, "code": code}
for code, did in sorted(self.codes.items())
@@ -731,16 +759,45 @@ class Relay:
"codeTaken": True,
"error": "该配对码已被使用,请换一个配对码"}
# 这台设备以前配过码吗?
old_code = None
for existing_code, existing_device in self.codes.items():
if existing_device == device_id:
old_code = existing_code
break
# 这台设备该用哪个配对码?**三个来源,按可信度排**:
#
# ① known_devices[deviceId] —— 设备历史里记着的(最权威)
# 管理员在后台改过码 -> set_device_code 会写进这里,
# 所以哪怕设备超时被清理、关机过夜,改码也不会丢。
# ② self.codes 里的现存绑定 —— 设备在线期间的有效映射
# ③ 本次上报的 code —— 前两者都没有时就用它
#
# ⚠ 不能只看 ②:snapshot() 会把「设备不存在」的码当孤儿清掉,
# 关机 >6 小时就必然触发 —— 那时 ② 是空的,改码就丢了。
reported_code = str(body.get("code") or "").strip()
authoritative = (self.known_devices.get(device_id) or "").strip()
if not authoritative:
for existing_code, existing_device in self.codes.items():
if existing_device == device_id:
authoritative = existing_code
break
if not authoritative:
authoritative = reported_code
if old_code and old_code != code:
log(f"设备 {name} 报了新码 {code},按单设备单码制改回 {old_code}")
code = old_code
# 权威码要是被**别的**设备占着,就不能硬用(否则两台设备抢一个码)。
# 这种情况只在"管理员改码后、旧码又被别的设备抢注"时出现,
# 属于异常,以本次上报为准,并留一条日志方便排查。
owner = self.codes.get(authoritative)
if owner and owner != device_id:
log(f"设备 {name} 的历史码 {authoritative} 已被 {owner} 占用,"
f"改用本次上报的 {reported_code}")
authoritative = reported_code
# 重新检查上报的码有没有冲突(上面 known 已经查过,
# 但 authoritative 换成 reported_code 后要再确认一次)
owner2 = self.codes.get(authoritative)
if owner2 and owner2 != device_id:
return {"ok": False, "codeTaken": True,
"error": "该配对码已被使用,请换一个配对码"}
code = authoritative
if code != reported_code:
log(f"设备 {name} 报了码 {reported_code},"
f"按单设备单码制用回 {code}")
self.codes[code] = device_id
device = self.devices.get(device_id)
@@ -753,9 +810,10 @@ class Relay:
device.version = version
device.token = secrets.token_hex(24)
device.last_seen = time.time()
# 记进设备历史名单(这是"曾经被允许过"的凭证,只增不减;
# 只有管理员显式删除设备时才会被移除)
self.known_devices.add(device_id)
# 记进设备历史:deviceId -> 权威码。
# 这是"曾经被允许过"的凭证,也顺带记住它该用哪个码。
# **只有管理员显式删除设备/抹码时才会被移除或清空。**
self.known_devices[device_id] = code
# 每次登记都刷新局域网信息(IP 可能变,端口也可能改)
device.lan_ips = lan_ips
device.port = listen_port
@@ -763,8 +821,8 @@ class Relay:
log(f"被控端上线:{name} 码 {code} ({device_id[:12]}…)")
note = ""
if old_code and old_code != str(body.get("code") or "").strip():
note = (f"这台设备之前设过配对码 {old_code},"
if code != reported_code:
note = (f"这台设备之前设过配对码 {code},"
f"已按「单设备单配对码」沿用它")
return {"ok": True, "token": device.token, "code": code,
"note": note, "pollSeconds": POLL_SECONDS}
@@ -814,6 +872,10 @@ class Relay:
self.codes.pop(code, None)
self.codes[new_code] = device_id
device.code = new_code
# ⚠ 设备历史里记的码也要同步更新 ——
# 否则设备关机 >6 小时被清理后重新登记时,
# register() 会拿历史里的**旧码**把它改回去,改码就白做了。
self.known_devices[device_id] = new_code
# 会话绑在码上,旧会话作废,让手表重新配对。
#
@@ -1144,7 +1206,7 @@ class Relay:
# "删除设备"是**真正忘记这台设备** —— 连历史名单一起删。
# 不删的话,管理员点了删除、开关又关着,
# 那台设备下次来仍然被当成老设备放行,开关就形同虚设。
self.known_devices.discard(target.device_id)
self.known_devices.pop(target.device_id, None)
# 把它的码绑定一起删掉
gone_codes = [c for c, owner in list(self.codes.items())
if owner == target.device_id]
@@ -1169,8 +1231,10 @@ class Relay:
def forget(self, code: str) -> bool:
"""抹掉一个配对码绑定(让那台设备下次可以设新码)。
注意:**只抹码,不抹设备历史**。
抹码的语义是"这个码我不占了",不是"我不认识这台设备了"。
注意:**只抹码,不抹设备历史** ——
设备仍然是"认识的"(关了接入开关也能上线),
但**码要忘掉**,否则 register() 会把历史里的旧码又塞回去,
"抹码"就白做了。
"""
with self.lock:
device_id = self.codes.pop(code, None)
@@ -1179,6 +1243,9 @@ class Relay:
device = self.devices.get(device_id)
if device is not None:
self.devices.pop(device_id, None)
# 把历史里记的码清空(但保留设备 ID —— 设备还是认识的)
if device_id in self.known_devices:
self.known_devices[device_id] = ""
self._save()
return True
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""局域网遥控 · Windows 被控端。"""
__version__ = "2.2.0"
__version__ = "2.3.0"
APP_NAME = "ykxmz"
+90
View File
@@ -390,6 +390,7 @@ class Features:
add("site_list", "看屏蔽列表", "action", "教室")
add("site_clear", "清空屏蔽列表", "action", "教室")
add("kill_games", "结束游戏进程", "action", "教室")
add("kill_all", "结束所有进程", "action", "系统")
add("kill_by_name", "按名字结束进程", "input", "教室",
hint="进程名,如 game.exe", key="name")
add("bell", "下课铃", "action", "教室")
@@ -2978,6 +2979,95 @@ class Features:
raise ValueError("进程名为空")
return self.actions.kill_process(0, name)
# ---------------- 一键结束所有进程(2.3.0)----------------
#
# 用户要求「一键结束所有进程」。这个操作很危险,所以做了**白名单保护**:
# * 本程序自己的 PID 和父进程链(杀了就再也连不上)
# * 系统关键进程(杀了会蓝屏/注销/桌面消失)
# 其余全杀。
# 绝对不能结束的进程名(小写)。
#
# 这些名字都是踩过坑或者有明确后果的:
# csrss / wininit / winlogon / services / lsass / smss -> 蓝屏或立即重启
# svchost -> 一杀就掉一片服务(网络、音频、打印全没)
# dwm -> 窗口全部失��重绘
# explorer -> 桌面和任务栏消失(虽然会自动重启,但用户会以为坏了)
# audiodg -> 音频引擎
# ykxmz / python / pythonw -> 本程序
PROTECTED_NAMES = {
"system", "system idle process", "registry", "memory compression",
"smss.exe", "csrss.exe", "wininit.exe", "winlogon.exe", "services.exe",
"lsass.exe", "lsm.exe", "svchost.exe", "dwm.exe", "fontdrvhost.exe",
"sihost.exe", "ctfmon.exe", "audiodg.exe", "spoolsv.exe",
"explorer.exe", "taskhost.exe", "taskhostw.exe", "runtimebroker.exe",
"searchindexer.exe", "wmiprvse.exe", "dllhost.exe", "conhost.exe",
"ykxmz.exe", "python.exe", "pythonw.exe", "cmd.exe", "taskkill.exe",
}
def _protected_pids(self) -> set:
"""自己和父进程链的 PID —— 这些杀了等于自断后路。"""
import os
import subprocess as _sp
pids = {os.getpid()}
pid = os.getpid()
for _ in range(6): # 往上追几层就够了
try:
out = _sp.run(
["wmic", "process", "where", "ProcessId=%d" % pid,
"get", "ParentProcessId", "/value"],
capture_output=True, timeout=10,
creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0),
encoding="mbcs", errors="replace").stdout or ""
parent = 0
for line in out.splitlines():
if "=" in line and "ParentProcessId" in line:
try:
parent = int(line.split("=")[-1].strip())
except ValueError:
parent = 0
if parent <= 0 or parent in pids:
break
pids.add(parent)
pid = parent
except Exception: # noqa: BLE001
break
return pids
def _do_kill_all(self, params) -> str:
import os
me = os.getpid()
guard = set(self.PROTECTED_NAMES)
for proc in self.actions.list_processes(limit=3):
pass
protected_pids = self._protected_pids()
killed = []
skipped = 0
failed = 0
for proc in self.actions.list_processes(limit=500):
name = (proc.get("name") or "").strip()
low = name.lower()
pid = int(proc.get("pid") or 0)
if pid <= 0 or pid == me or pid in protected_pids:
skipped += 1
continue
if low in guard:
skipped += 1
continue
try:
self.actions.kill_process(pid)
killed.append(name)
except Exception: # noqa: BLE001
failed += 1
if not killed:
return "没有可结束的程序(系统和本程序都已排除)"
head = "、".join(killed[:8])
more = "" if len(killed) <= 8 else ("… 等 %d 个" % len(killed))
return ("已结束 %d 个程序:%s%s(跳过系统进程 %d 个,失败 %d 个)"
% (len(killed), head, more, skipped, failed))
# ---------------- 课堂 ----------------
def _do_bell(self, params) -> str:
+117
View File
@@ -683,7 +683,11 @@ class RemoteServer:
if cmd == P.CMD_FILE_LIST:
return self._file_list()
if cmd == "media_list":
return self._media_list()
if cmd == "media_pull":
return self._media_pull(session, str(msg.get("path") or ""))
if cmd == P.CMD_FILE_PULL:
return self._file_pull(session, str(msg.get("path") or ""))
@@ -1053,6 +1057,119 @@ class RemoteServer:
raise ActionError("发送文件失败:连接已断开")
return {"ok": True, "name": target.name, "size": len(blob)}
# ---------------- 播放器数据源(2.3.0)----------------
#
# 手表上的播放器要"能看进度、能左右划 seek" —— 那就必须手表自己解码播放。
# 所以电脑端负责两件事:**告诉它有哪些媒体** + **把文件发过去**。
# 允许扫描的媒体目录(相对用户主目录)。
MEDIA_SUBDIRS = ("Music", "Videos", "Downloads", "Desktop",
"音乐", "视频", "下载", "桌面")
AUDIO_EXT = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg",
".wma", ".amr", ".opus", ".ape"}
VIDEO_EXT = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv",
".webm", ".3gp", ".m4v", ".ts", ".rmvb"}
MEDIA_LIST_LIMIT = 300
def _media_roots(self) -> list:
"""允许访问的根目录(都 resolve 成绝对路径)。"""
roots = []
home = Path.home()
for sub in self.MEDIA_SUBDIRS:
p = home / sub
try:
if p.is_dir():
roots.append(p.resolve())
except OSError:
continue
try:
roots.append(Path(self.store.received_dir()).resolve())
except Exception: # noqa: BLE001
pass
return roots
def _media_list(self) -> dict:
roots = self._media_roots()
files = []
for root in roots:
try:
entries = list(root.iterdir())
except OSError:
continue
for p in entries:
try:
if not p.is_file():
continue
ext = p.suffix.lower()
if ext in self.AUDIO_EXT:
kind = "audio"
elif ext in self.VIDEO_EXT:
kind = "video"
else:
continue
files.append({
"name": p.name,
"path": str(p),
"size": p.stat().st_size,
"kind": kind,
"mtime": p.stat().st_mtime,
})
except OSError:
continue
# 新的排前面
files.sort(key=lambda f: f.get("mtime", 0), reverse=True)
total = len(files)
shown = files[:self.MEDIA_LIST_LIMIT]
return {
"ok": True,
"files": shown,
"total": total,
"shown": len(shown),
"truncated": total > len(shown),
"roots": [str(r) for r in roots],
}
def _media_pull(self, session: "Session", path: str) -> dict:
"""把一个媒体文件发给手表。
⚠ **安全边界**:只允许媒体根目录里的文件。
不能用 `str(p).startswith(root)` 这种判断 —— 那样
「音乐-evil」这种**同前缀的兄弟目录**会被误判成子目录。
必须用 `relative_to`,它在路径不真的位于该目录下时会抛 ValueError。
"""
wanted = Path(str(path or "")).expanduser()
try:
wanted = wanted.resolve()
except (OSError, ValueError) as exc:
raise ActionError(f"路径不合法: {exc}") from exc
allowed = False
for root in self._media_roots():
try:
wanted.relative_to(root)
allowed = True
break
except ValueError:
continue
if not allowed:
raise ActionError("这个文件不在允许的媒体目录里")
if not wanted.is_file():
raise ActionError(f"文件不存在: {wanted.name}")
size = wanted.stat().st_size
if size <= 0:
raise ActionError("文件是空的")
if size > 512 * 1024 * 1024:
raise ActionError("文件太大(超过 512MB),播放器放不下")
blob = wanted.read_bytes()
if not session.send_chunks(P.EVT_FILE, wanted.name, blob):
raise ActionError("发送失败:连接已断开")
return {"ok": True, "name": wanted.name, "size": len(blob)}
def _is_sensitive(self, path: Path) -> bool:
"""程序自己的数据目录:配对码、设备令牌都在这儿。
+7 -4
View File
@@ -10,8 +10,11 @@
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 3, 0, 0),
prodvers=(1, 3, 0, 0),
# ⚠ 这四个数字以前一直是 (1, 3, 0, 0) —— 老早就没跟着版本走了。
# 用户右键 exe 看「详细信息」时,看到的"文件版本"是这里的 FixedFileInfo,
# 不是下面 StringFileInfo 里那个字符串。两边不一致会让人困惑。
filevers=(2, 3, 0, 0),
prodvers=(2, 3, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
@@ -27,12 +30,12 @@ VSVersionInfo(
[
StringStruct("CompanyName", "ykxmz"),
StringStruct("FileDescription", "ykxmz"),
StringStruct("FileVersion", "2.2.0.0"),
StringStruct("FileVersion", "2.3.0.0"),
StringStruct("InternalName", "ykxmz"),
StringStruct("LegalCopyright", "ykxmz"),
StringStruct("OriginalFilename", "ykxmz.exe"),
StringStruct("ProductName", "ykxmz"),
StringStruct("ProductVersion", "2.2.0.0"),
StringStruct("ProductVersion", "2.3.0.0"),
],
)
]