mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 18:20:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2061f9525f |
@@ -86,3 +86,4 @@ _build/.admin_password
|
|||||||
_build/.lightsail_host
|
_build/.lightsail_host
|
||||||
_build/.gitee_token
|
_build/.gitee_token
|
||||||
_build/.admin_password
|
_build/.admin_password
|
||||||
|
_build/.halo_password
|
||||||
|
|||||||
@@ -45,9 +45,9 @@
|
|||||||
|
|
||||||
| 装在哪 | 是什么 | 文件 |
|
| 装在哪 | 是什么 | 文件 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.2.0.exe` |
|
| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.3.0.exe` |
|
||||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.2.0.zip` |
|
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.3.0.zip` |
|
||||||
| 手表(安卓) | 控制端 | `ykxmz-watch-2.2.0.apk` |
|
| 手表(安卓) | 控制端 | `ykxmz-watch-2.3.0.apk` |
|
||||||
|
|
||||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 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`,整个文件夹拷走就能换台机器用,不写注册表。
|
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||||
|
|
||||||
### 第二步:手表上装 App
|
### 第二步:手表上装 App
|
||||||
|
|
||||||
把 `ykxmz-watch-2.2.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
把 `ykxmz-watch-2.3.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||||
|
|
||||||
打开 App,首页有四个入口:
|
打开 App,首页有四个入口:
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@
|
|||||||
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||||
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||||
|
|
||||||
把 `ykxmz-server-2.2.0.zip` 解压到那台机器上,然后一条命令:
|
把 `ykxmz-server-2.3.0.zip` 解压到那台机器上,然后一条命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash deploy.sh
|
bash deploy.sh
|
||||||
@@ -456,7 +456,7 @@ windows\build_exe.ps1
|
|||||||
```powershell
|
```powershell
|
||||||
android\build_apk.ps1
|
android\build_apk.ps1
|
||||||
# 流程:aapt2 → javac → d8 → zipalign → apksigner
|
# 流程:aapt2 → javac → d8 → zipalign → apksigner
|
||||||
# 产物:dist\ykxmz-watch-2.2.0.apk
|
# 产物:dist\ykxmz-watch-2.3.0.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||||
|
|||||||
@@ -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
|
## 2.2.0
|
||||||
|
|
||||||
- **手表端:输入配对码连上之后,先到一个选择页** ——
|
- **手表端:输入配对码连上之后,先到一个选择页** ——
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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"<(https?://[^&]+?)>", 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())
|
||||||
@@ -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())
|
||||||
@@ -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"<(https?://[^&]+?)>", 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())
|
||||||
@@ -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"<(https?://[^&]+?)>", 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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -17,7 +17,7 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||||
VERSION = "2.2.0"
|
VERSION = "2.3.0"
|
||||||
NAME = f"ykxmz-{VERSION}"
|
NAME = f"ykxmz-{VERSION}"
|
||||||
|
|
||||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -23,7 +23,7 @@ def _latest_apk() -> Path:
|
|||||||
(ROOT / "dist").glob("*watch*.apk"),
|
(ROOT / "dist").glob("*watch*.apk"),
|
||||||
key=lambda p: p.stat().st_mtime, reverse=True)
|
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
return candidates[0] if candidates else \
|
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()
|
APK = _latest_apk()
|
||||||
|
|||||||
@@ -156,7 +156,14 @@ def main() -> int:
|
|||||||
print()
|
print()
|
||||||
print("二、分页定义完整性")
|
print("二、分页定义完整性")
|
||||||
tab_names = re.findall(r'new TabDef\("([^"]+)"', text)
|
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)}")
|
print(f" 分页: {' '.join(tab_names)}")
|
||||||
check("selectTab 有定义", "private void selectTab(" in text)
|
check("selectTab 有定义", "private void selectTab(" in text)
|
||||||
check("selectTab 被调用", len(re.findall(r"\bselectTab\s*\(", text)) > 1)
|
check("selectTab 被调用", len(re.findall(r"\bselectTab\s*\(", text)) > 1)
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||||||
# 被控端 -> 一个 zip
|
# 被控端 -> 一个 zip
|
||||||
# 服务器端 -> 一个 zip
|
# 服务器端 -> 一个 zip
|
||||||
# 控制端 -> 直接给 APK(不打 zip)
|
# 控制端 -> 直接给 APK(不打 zip)
|
||||||
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.2.0.zip"
|
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.3.0.zip"
|
||||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.2.0.zip"
|
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.3.0.zip"
|
||||||
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.2.0.apk"
|
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.3.0.apk"
|
||||||
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
||||||
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
||||||
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
||||||
@@ -89,7 +89,7 @@ def main() -> int:
|
|||||||
for label, path in (("被控端", agent_dir / "README-agent.txt"),
|
for label, path in (("被控端", agent_dir / "README-agent.txt"),
|
||||||
("服务器端", relay_dir / "README-server.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""
|
raw = path.read_bytes() if path.is_file() else b""
|
||||||
check(f"{label}说明有 UTF-8 BOM",
|
check(f"{label}说明有 UTF-8 BOM",
|
||||||
raw[:3] == b"\xef\xbb\xbf", raw[:3])
|
raw[:3] == b"\xef\xbb\xbf", raw[:3])
|
||||||
|
|||||||
+40
-10
@@ -170,21 +170,51 @@ def main() -> int:
|
|||||||
|
|
||||||
# ---------------- 五、鼠标触摸板 ----------------
|
# ---------------- 五、鼠标触摸板 ----------------
|
||||||
print()
|
print()
|
||||||
print("五、鼠标触摸板")
|
print("五、鼠标触摸板(2.3.0 版:滑动区 + 左键/滚轮/右键)")
|
||||||
check("有 buildMousePanel", "private void buildMousePanel()" in main_java)
|
check("有 buildMousePanel", "private void buildMousePanel()" in main_java)
|
||||||
check("用 GestureDetector(单击/双击/拖动)",
|
# 2.3.0 用户明确要求:「中间是一个鼠标,可以上下左右滑动控制,
|
||||||
"GestureDetector" in main_java
|
# 下面有三个按钮,左边是左键,中间是那个滑轮,右边是那个右键」。
|
||||||
and "onSingleTapConfirmed" in main_java
|
# 所以**不再用单击/双击手势** —— 左键和右键各有专门按钮,
|
||||||
and "onDoubleTap" in main_java
|
# 手势里只留"滑动 = 移动光标"。
|
||||||
and "onScroll" in main_java)
|
check("滑动区用 GestureDetector 的 onScroll(移动光标)",
|
||||||
check("单击 -> 左键", 'sendMouse("click", "left", 0)' in main_java)
|
"GestureDetector" in main_java and "onScroll" in main_java)
|
||||||
check("双击 -> 右键", 'sendMouse("click", "right", 0)' in main_java)
|
check("左键按钮 -> sendMouse click left",
|
||||||
check("滚轮上/下", 'sendMouse("scroll", "", 3)' in main_java
|
'sendMouse("click", "left", 0)' in main_java)
|
||||||
and 'sendMouse("scroll", "", -3)' 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("拖动 -> moveby", '"moveby"' in main_java)
|
||||||
check("isConnected() 存在(第一版漏了,编译才发现)",
|
check("isConnected() 存在(第一版漏了,编译才发现)",
|
||||||
"private boolean isConnected()" in main_java)
|
"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()
|
||||||
print("五之二、连接后的选择页(远控 / 静默更新被控端)")
|
print("五之二、连接后的选择页(远控 / 静默更新被控端)")
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.lanremote.watch"
|
package="com.lanremote.watch"
|
||||||
android:versionCode="5"
|
android:versionCode="5"
|
||||||
android:versionName="2.2.0">
|
android:versionName="2.3.0">
|
||||||
|
|
||||||
<!-- ⚠ 允许明文 HTTP。
|
<!-- ⚠ 允许明文 HTTP。
|
||||||
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
|
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat"
|
|||||||
|
|
||||||
$work = Join-Path $android "build"
|
$work = Join-Path $android "build"
|
||||||
$outDir = Join-Path $root "dist"
|
$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 }
|
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk"
|
|||||||
--min-sdk-version 24 `
|
--min-sdk-version 24 `
|
||||||
--target-sdk-version 34 `
|
--target-sdk-version 34 `
|
||||||
--version-code 5 `
|
--version-code 5 `
|
||||||
--version-name "2.2.0" `
|
--version-name "2.3.0" `
|
||||||
--auto-add-overlay
|
--auto-add-overlay
|
||||||
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
|
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,7 +3,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz
|
软件名称:ykxmz
|
||||||
版本:2.2.0
|
版本:2.3.0
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
|
|
||||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
八、版本记录
|
八、版本记录
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|
||||||
2.2.0
|
2.3.0
|
||||||
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
||||||
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
||||||
合计 10 条连接),一种协议犯病另一种还在服务
|
合计 10 条连接),一种协议犯病另一种还在服务
|
||||||
@@ -218,6 +218,6 @@
|
|||||||
|
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
软件:ykxmz 2.2.0
|
软件:ykxmz 2.3.0
|
||||||
许可条款见《许可协议》(LICENSE.txt)
|
许可条款见《许可协议》(LICENSE.txt)
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz
|
软件名称:ykxmz
|
||||||
版本:2.2.0
|
版本:2.3.0
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
|
|
||||||
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
||||||
@@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
|||||||
|
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
软件:ykxmz 2.2.0
|
软件:ykxmz 2.3.0
|
||||||
继续安装,即表示你已阅读并同意《许可协议》。
|
继续安装,即表示你已阅读并同意《许可协议》。
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz(远程管理工具)
|
软件名称:ykxmz(远程管理工具)
|
||||||
版本:2.2.0
|
版本:2.3.0
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
生效日期:2026 年 10 月 5 日
|
生效日期: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)。
|
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz(远程管理工具)
|
软件名称:ykxmz(远程管理工具)
|
||||||
版本:2.2.0
|
版本:2.3.0
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
生效日期:2026 年 10 月 5 日
|
生效日期: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)。
|
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
========================================================
|
========================================================
|
||||||
|
|
||||||
软件名称:ykxmz
|
软件名称: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
|
软件名称:ykxmz
|
||||||
版本:2.2.0
|
版本:2.3.0
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
|
|
||||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||||
@@ -539,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
|||||||
八、版本记录
|
八、版本记录
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|
||||||
2.2.0
|
2.3.0
|
||||||
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
||||||
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
||||||
合计 10 条连接),一种协议犯病另一种还在服务
|
合计 10 条连接),一种协议犯病另一种还在服务
|
||||||
@@ -560,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
|||||||
|
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
作者:小麦子
|
作者:小麦子
|
||||||
软件:ykxmz 2.2.0
|
软件:ykxmz 2.3.0
|
||||||
许可条款见《许可协议》(LICENSE.txt)
|
许可条款见《许可协议》(LICENSE.txt)
|
||||||
--------------------------------------------------------
|
--------------------------------------------------------
|
||||||
|
|||||||
+2
-2
@@ -27,7 +27,7 @@
|
|||||||
; ============================================================================
|
; ============================================================================
|
||||||
|
|
||||||
#define MyAppName "ykxmz"
|
#define MyAppName "ykxmz"
|
||||||
#define MyAppVersion "2.2.0"
|
#define MyAppVersion "2.3.0"
|
||||||
#define MyAppPublisher "小麦子"
|
#define MyAppPublisher "小麦子"
|
||||||
#define MyAppExeName "ykxmz.exe"
|
#define MyAppExeName "ykxmz.exe"
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \
|
|||||||
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
|
; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住
|
||||||
; (PowerShell 的 Start-Process -Wait 就是)。
|
; (PowerShell 的 Start-Process -Wait 就是)。
|
||||||
; 学校批量部署请用 cmd:
|
; 学校批量部署请用 cmd:
|
||||||
; cmd /c ykxmz-setup-2.2.0.exe /VERYSILENT /CODE=123456
|
; cmd /c ykxmz-setup-2.3.0.exe /VERYSILENT /CODE=123456
|
||||||
; cmd 只等安装器本身,不等孙子进程,不会卡。
|
; cmd 只等安装器本身,不等孙子进程,不会卡。
|
||||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
||||||
Description: "立即启动(后台运行,不显示窗口)"; \
|
Description: "立即启动(后台运行,不显示窗口)"; \
|
||||||
|
|||||||
+89
-22
@@ -60,7 +60,7 @@ from collections import deque
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "2.2.0"
|
VERSION = "2.3.0"
|
||||||
|
|
||||||
# ---------------- 参数(都能用环境变量覆盖)----------------
|
# ---------------- 参数(都能用环境变量覆盖)----------------
|
||||||
|
|
||||||
@@ -603,10 +603,23 @@ class Relay:
|
|||||||
self.retired: dict = {}
|
self.retired: dict = {}
|
||||||
# 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。
|
# 是否允许新设备接入。False 时只有已登记过的 deviceId 能上线。
|
||||||
self.allow_new = True
|
self.allow_new = True
|
||||||
# 设备历史名单(只有 deviceId,不含配对码)。
|
# 设备历史:**deviceId -> 配对码**。
|
||||||
|
#
|
||||||
|
# 为什么是字典而不是集合(2.2.1 改):
|
||||||
|
# 集合只能记住"这设备来过",记不住"它该用哪个码"。
|
||||||
|
# 于是这条链会断:
|
||||||
|
# ① 管理员在后台把码 111111 改成 222222
|
||||||
|
# ② 那台电脑关机(或断网)超过 DEVICE_TTL(6 小时)
|
||||||
|
# ③ snapshot() 把设备删了,孤儿的码 222222 也被清掉
|
||||||
|
# ④ 电脑开机,报自己本地的旧码 111111
|
||||||
|
# ⑤ 中转已经不记得改过码 -> 收下 111111
|
||||||
|
# ⑥ **管理员的改码白做了**,手表手里是 222222,连不上
|
||||||
|
# 把码记进历史后,步骤 ⑤ 会变成"你应该是 222222",
|
||||||
|
# 并随 register() 的返回值告诉客户端,客户端写回本地设置。
|
||||||
|
#
|
||||||
# **超时清理绝不动它** —— 它是"这台设备曾经被允许过"的唯一凭证。
|
# **超时清理绝不动它** —— 它是"这台设备曾经被允许过"的唯一凭证。
|
||||||
# 详见 register() 里 known_device 的注释。
|
# 详见 register() 里 known_device 的注释。
|
||||||
self.known_devices: set = set()
|
self.known_devices: dict = {}
|
||||||
self.started = time.time()
|
self.started = time.time()
|
||||||
self.total_requests = 0
|
self.total_requests = 0
|
||||||
self.state_file = DATA_DIR / "relay_state.json"
|
self.state_file = DATA_DIR / "relay_state.json"
|
||||||
@@ -621,11 +634,25 @@ class Relay:
|
|||||||
return
|
return
|
||||||
# 是否允许新设备接入(重启不丢)
|
# 是否允许新设备接入(重启不丢)
|
||||||
self.allow_new = bool(raw.get("allowNew", True))
|
self.allow_new = bool(raw.get("allowNew", True))
|
||||||
# 恢复设备历史名单(重启不丢)
|
# 恢复设备历史(重启不丢)。
|
||||||
for did in raw.get("knownDevices", []):
|
#
|
||||||
|
# ⚠ 兼容老格式: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()
|
did = str(did or "").strip()
|
||||||
if did:
|
if did:
|
||||||
self.known_devices.add(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", []):
|
for item in raw.get("codes", []):
|
||||||
device_id = str(item.get("deviceId", ""))
|
device_id = str(item.get("deviceId", ""))
|
||||||
code = str(item.get("code", ""))
|
code = str(item.get("code", ""))
|
||||||
@@ -639,8 +666,9 @@ class Relay:
|
|||||||
payload = {
|
payload = {
|
||||||
"allowNew": bool(self.allow_new),
|
"allowNew": bool(self.allow_new),
|
||||||
# 设备历史:超时清理不会删它,所以关掉接入开关后
|
# 设备历史:超时清理不会删它,所以关掉接入开关后
|
||||||
# 老设备第二天开机仍然能上线
|
# 老设备第二天开机仍然能上线。
|
||||||
"knownDevices": sorted(self.known_devices),
|
# 2.2.1 起顺便记住"它该用哪个码",避免改码被超时清理冲掉。
|
||||||
|
"knownDevices": dict(self.known_devices),
|
||||||
"codes": [
|
"codes": [
|
||||||
{"deviceId": did, "code": code}
|
{"deviceId": did, "code": code}
|
||||||
for code, did in sorted(self.codes.items())
|
for code, did in sorted(self.codes.items())
|
||||||
@@ -731,16 +759,45 @@ class Relay:
|
|||||||
"codeTaken": True,
|
"codeTaken": True,
|
||||||
"error": "该配对码已被使用,请换一个配对码"}
|
"error": "该配对码已被使用,请换一个配对码"}
|
||||||
|
|
||||||
# 这台设备以前配过码吗?
|
# 这台设备该用哪个配对码?**三个来源,按可信度排**:
|
||||||
old_code = None
|
#
|
||||||
|
# ① 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():
|
for existing_code, existing_device in self.codes.items():
|
||||||
if existing_device == device_id:
|
if existing_device == device_id:
|
||||||
old_code = existing_code
|
authoritative = existing_code
|
||||||
break
|
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
|
self.codes[code] = device_id
|
||||||
device = self.devices.get(device_id)
|
device = self.devices.get(device_id)
|
||||||
@@ -753,9 +810,10 @@ class Relay:
|
|||||||
device.version = version
|
device.version = version
|
||||||
device.token = secrets.token_hex(24)
|
device.token = secrets.token_hex(24)
|
||||||
device.last_seen = time.time()
|
device.last_seen = time.time()
|
||||||
# 记进设备历史名单(这是"曾经被允许过"的凭证,只增不减;
|
# 记进设备历史:deviceId -> 权威码。
|
||||||
# 只有管理员显式删除设备时才会被移除)
|
# 这是"曾经被允许过"的凭证,也顺带记住它该用哪个码。
|
||||||
self.known_devices.add(device_id)
|
# **只有管理员显式删除设备/抹码时才会被移除或清空。**
|
||||||
|
self.known_devices[device_id] = code
|
||||||
# 每次登记都刷新局域网信息(IP 可能变,端口也可能改)
|
# 每次登记都刷新局域网信息(IP 可能变,端口也可能改)
|
||||||
device.lan_ips = lan_ips
|
device.lan_ips = lan_ips
|
||||||
device.port = listen_port
|
device.port = listen_port
|
||||||
@@ -763,8 +821,8 @@ class Relay:
|
|||||||
log(f"被控端上线:{name} 码 {code} ({device_id[:12]}…)")
|
log(f"被控端上线:{name} 码 {code} ({device_id[:12]}…)")
|
||||||
|
|
||||||
note = ""
|
note = ""
|
||||||
if old_code and old_code != str(body.get("code") or "").strip():
|
if code != reported_code:
|
||||||
note = (f"这台设备之前设过配对码 {old_code},"
|
note = (f"这台设备之前设过配对码 {code},"
|
||||||
f"已按「单设备单配对码」沿用它")
|
f"已按「单设备单配对码」沿用它")
|
||||||
return {"ok": True, "token": device.token, "code": code,
|
return {"ok": True, "token": device.token, "code": code,
|
||||||
"note": note, "pollSeconds": POLL_SECONDS}
|
"note": note, "pollSeconds": POLL_SECONDS}
|
||||||
@@ -814,6 +872,10 @@ class Relay:
|
|||||||
self.codes.pop(code, None)
|
self.codes.pop(code, None)
|
||||||
self.codes[new_code] = device_id
|
self.codes[new_code] = device_id
|
||||||
device.code = new_code
|
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())
|
gone_codes = [c for c, owner in list(self.codes.items())
|
||||||
if owner == target.device_id]
|
if owner == target.device_id]
|
||||||
@@ -1169,8 +1231,10 @@ class Relay:
|
|||||||
def forget(self, code: str) -> bool:
|
def forget(self, code: str) -> bool:
|
||||||
"""抹掉一个配对码绑定(让那台设备下次可以设新码)。
|
"""抹掉一个配对码绑定(让那台设备下次可以设新码)。
|
||||||
|
|
||||||
注意:**只抹码,不抹设备历史**。
|
注意:**只抹码,不抹设备历史** ——
|
||||||
抹码的语义是"这个码我不占了",不是"我不认识这台设备了"。
|
设备仍然是"认识的"(关了接入开关也能上线),
|
||||||
|
但**码要忘掉**,否则 register() 会把历史里的旧码又塞回去,
|
||||||
|
"抹码"就白做了。
|
||||||
"""
|
"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
device_id = self.codes.pop(code, None)
|
device_id = self.codes.pop(code, None)
|
||||||
@@ -1179,6 +1243,9 @@ class Relay:
|
|||||||
device = self.devices.get(device_id)
|
device = self.devices.get(device_id)
|
||||||
if device is not None:
|
if device is not None:
|
||||||
self.devices.pop(device_id, None)
|
self.devices.pop(device_id, None)
|
||||||
|
# 把历史里记的码清空(但保留设备 ID —— 设备还是认识的)
|
||||||
|
if device_id in self.known_devices:
|
||||||
|
self.known_devices[device_id] = ""
|
||||||
self._save()
|
self._save()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""局域网遥控 · Windows 被控端。"""
|
"""局域网遥控 · Windows 被控端。"""
|
||||||
|
|
||||||
__version__ = "2.2.0"
|
__version__ = "2.3.0"
|
||||||
APP_NAME = "ykxmz"
|
APP_NAME = "ykxmz"
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ class Features:
|
|||||||
add("site_list", "看屏蔽列表", "action", "教室")
|
add("site_list", "看屏蔽列表", "action", "教室")
|
||||||
add("site_clear", "清空屏蔽列表", "action", "教室")
|
add("site_clear", "清空屏蔽列表", "action", "教室")
|
||||||
add("kill_games", "结束游戏进程", "action", "教室")
|
add("kill_games", "结束游戏进程", "action", "教室")
|
||||||
|
add("kill_all", "结束所有进程", "action", "系统")
|
||||||
add("kill_by_name", "按名字结束进程", "input", "教室",
|
add("kill_by_name", "按名字结束进程", "input", "教室",
|
||||||
hint="进程名,如 game.exe", key="name")
|
hint="进程名,如 game.exe", key="name")
|
||||||
add("bell", "下课铃", "action", "教室")
|
add("bell", "下课铃", "action", "教室")
|
||||||
@@ -2978,6 +2979,95 @@ class Features:
|
|||||||
raise ValueError("进程名为空")
|
raise ValueError("进程名为空")
|
||||||
return self.actions.kill_process(0, name)
|
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:
|
def _do_bell(self, params) -> str:
|
||||||
|
|||||||
@@ -683,7 +683,11 @@ class RemoteServer:
|
|||||||
|
|
||||||
if cmd == P.CMD_FILE_LIST:
|
if cmd == P.CMD_FILE_LIST:
|
||||||
return self._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:
|
if cmd == P.CMD_FILE_PULL:
|
||||||
return self._file_pull(session, str(msg.get("path") or ""))
|
return self._file_pull(session, str(msg.get("path") or ""))
|
||||||
|
|
||||||
@@ -1053,6 +1057,119 @@ class RemoteServer:
|
|||||||
raise ActionError("发送文件失败:连接已断开")
|
raise ActionError("发送文件失败:连接已断开")
|
||||||
return {"ok": True, "name": target.name, "size": len(blob)}
|
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:
|
def _is_sensitive(self, path: Path) -> bool:
|
||||||
"""程序自己的数据目录:配对码、设备令牌都在这儿。
|
"""程序自己的数据目录:配对码、设备令牌都在这儿。
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
|
|
||||||
VSVersionInfo(
|
VSVersionInfo(
|
||||||
ffi=FixedFileInfo(
|
ffi=FixedFileInfo(
|
||||||
filevers=(1, 3, 0, 0),
|
# ⚠ 这四个数字以前一直是 (1, 3, 0, 0) —— 老早就没跟着版本走了。
|
||||||
prodvers=(1, 3, 0, 0),
|
# 用户右键 exe 看「详细信息」时,看到的"文件版本"是这里的 FixedFileInfo,
|
||||||
|
# 不是下面 StringFileInfo 里那个字符串。两边不一致会让人困惑。
|
||||||
|
filevers=(2, 3, 0, 0),
|
||||||
|
prodvers=(2, 3, 0, 0),
|
||||||
mask=0x3f,
|
mask=0x3f,
|
||||||
flags=0x0,
|
flags=0x0,
|
||||||
OS=0x40004,
|
OS=0x40004,
|
||||||
@@ -27,12 +30,12 @@ VSVersionInfo(
|
|||||||
[
|
[
|
||||||
StringStruct("CompanyName", "ykxmz"),
|
StringStruct("CompanyName", "ykxmz"),
|
||||||
StringStruct("FileDescription", "ykxmz"),
|
StringStruct("FileDescription", "ykxmz"),
|
||||||
StringStruct("FileVersion", "2.2.0.0"),
|
StringStruct("FileVersion", "2.3.0.0"),
|
||||||
StringStruct("InternalName", "ykxmz"),
|
StringStruct("InternalName", "ykxmz"),
|
||||||
StringStruct("LegalCopyright", "ykxmz"),
|
StringStruct("LegalCopyright", "ykxmz"),
|
||||||
StringStruct("OriginalFilename", "ykxmz.exe"),
|
StringStruct("OriginalFilename", "ykxmz.exe"),
|
||||||
StringStruct("ProductName", "ykxmz"),
|
StringStruct("ProductName", "ykxmz"),
|
||||||
StringStruct("ProductVersion", "2.2.0.0"),
|
StringStruct("ProductVersion", "2.3.0.0"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user