From 2061f9525f0b2b90835e3375e66a0c76cc7cf943 Mon Sep 17 00:00:00 2001 From: xmz7891 Date: Sun, 18 Oct 2026 11:39:09 +0800 Subject: [PATCH] =?UTF-8?q?2.3.0=EF=BC=9A=E6=89=8B=E8=A1=A8=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E7=B2=BE=E7=AE=80=20+=20=E6=92=AD=E6=94=BE=E5=99=A8?= =?UTF-8?q?=20+=20=E9=BC=A0=E6=A0=87=E9=87=8D=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手表端 - 功能页从 10 个杂项精简到 7 个(屏幕/鼠标/媒体/文件/电脑/语音/电源) - 删掉:游戏封锁、待办、掷骰子、抽奖、壁纸、显示器开关、定时关机、 剪贴板历史、性能/磁盘/电量、文件夹浏览、教室限制等 - 改成显式列按钮,不再把电脑端所有功能组无脑铺到表盘 - 远程鼠标控制重做:滑动区 + 左键/滚轮/右键三按钮 滚轮点按=中键、上下滑=滚动 - 新增视频/音乐播放器(手表本地播放):进度拖动、左右滑快进退10秒、 点画面播放暂停、上一/下一、播完自动下一首 电脑端 - 新增 kill_all(结束所有进程),白名单保护系统关键进程和自身 - 新增 media_list / media_pull(播放器数据源) 安全边界:只允许媒体目录,用 relative_to 判断,防同前缀兄弟目录绕过 中转站 - known_devices 从 ID 集合改成「设备ID -> 配对码」映射 - 修复:管理员改码后关机超过 6 小时,改码会被超时清理冲掉 - 老格式 state 文件平滑升级 --- .gitignore | 5 +- README.md | 16 +- _build/CHANGELOG.md | 87 + _build/add_swap_1g.py | 121 + _build/android_v230.py | 1171 +++ _build/delete_methods.py | 149 + _build/diag_tool.py | 765 ++ _build/find_orphans.py | 108 + _build/halo_cert.py | 115 + _build/halo_console_path.py | 120 + _build/halo_login3.py | 80 + _build/halo_login_rsa.py | 152 + _build/halo_nginx.py | 172 + _build/halo_nginx_fix.py | 235 + _build/halo_precheck.py | 122 + _build/halo_publish.py | 339 + _build/halo_publish2.py | 107 + _build/halo_publish3.py | 391 + _build/halo_publish_final.py | 319 + _build/halo_reset_email.py | 116 + _build/install_halo.py | 131 + _build/locate_instability.py | 143 + _build/package_remote.py | 2 +- _build/windows_v230.py | 354 + _tests/test_apk_contents.py | 2 +- _tests/test_no_orphan_methods.py | 9 +- _tests/test_package_remote.py | 8 +- _tests/test_quickbar.py | 50 +- _tests/test_recode_survives_ttl.py | 192 + _tests/test_v230_new_commands.py | 129 + android/AndroidManifest.xml | 2 +- android/build_apk.ps1 | 4 +- .../src/com/lanremote/watch/MainActivity.java | 1352 ++-- build/diag_tool/ykxmz诊断.spec | 38 + build/diag_tool/ykxmz诊断/base_library.zip | Bin 0 -> 843910 bytes build/diag_tool/ykxmz诊断/warn-ykxmz诊断.txt | 25 + build/diag_tool/ykxmz诊断/xref-ykxmz诊断.html | 6399 +++++++++++++++++ docs/AFTER-INSTALL.txt | 6 +- docs/BEFORE-INSTALL.txt | 4 +- docs/LICENSE.txt | 6 +- docs/文章-复制粘贴版.html | 205 + docs/文章-远控软件正式上线.md | 142 + docs/新建文本文档.txt | 142 + installer/license-combined.txt | 16 +- installer/ykxmz.iss | 4 +- relay/relay.py | 121 +- windows/lanremote/__init__.py | 2 +- windows/lanremote/features.py | 90 + windows/lanremote/server.py | 117 + windows/version_info.txt | 11 +- 50 files changed, 13812 insertions(+), 584 deletions(-) create mode 100644 _build/add_swap_1g.py create mode 100644 _build/android_v230.py create mode 100644 _build/delete_methods.py create mode 100644 _build/diag_tool.py create mode 100644 _build/find_orphans.py create mode 100644 _build/halo_cert.py create mode 100644 _build/halo_console_path.py create mode 100644 _build/halo_login3.py create mode 100644 _build/halo_login_rsa.py create mode 100644 _build/halo_nginx.py create mode 100644 _build/halo_nginx_fix.py create mode 100644 _build/halo_precheck.py create mode 100644 _build/halo_publish.py create mode 100644 _build/halo_publish2.py create mode 100644 _build/halo_publish3.py create mode 100644 _build/halo_publish_final.py create mode 100644 _build/halo_reset_email.py create mode 100644 _build/install_halo.py create mode 100644 _build/locate_instability.py create mode 100644 _build/windows_v230.py create mode 100644 _tests/test_recode_survives_ttl.py create mode 100644 _tests/test_v230_new_commands.py create mode 100644 build/diag_tool/ykxmz诊断.spec create mode 100644 build/diag_tool/ykxmz诊断/base_library.zip create mode 100644 build/diag_tool/ykxmz诊断/warn-ykxmz诊断.txt create mode 100644 build/diag_tool/ykxmz诊断/xref-ykxmz诊断.html create mode 100644 docs/文章-复制粘贴版.html create mode 100644 docs/文章-远控软件正式上线.md create mode 100644 docs/新建文本文档.txt diff --git a/.gitignore b/.gitignore index cd26539..0acdf52 100644 --- a/.gitignore +++ b/.gitignore @@ -80,9 +80,10 @@ admin_password.txt /_build/_hdr_data/ /_build/relay_info.json # 后台管理密码(绝不进仓库) -_build/.admin_password +_build/.admin_password # 服务器地址(绝不进仓库) _build/.lightsail_host _build/.gitee_token -_build/.admin_password +_build/.admin_password +_build/.halo_password diff --git a/README.md b/README.md index e884dc6..0562357 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ | 装在哪 | 是什么 | 文件 | |---|---|---| -| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.2.0.exe` | -| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.2.0.zip` | -| 手表(安卓) | 控制端 | `ykxmz-watch-2.2.0.apk` | +| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.3.0.exe` | +| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.3.0.zip` | +| 手表(安卓) | 控制端 | `ykxmz-watch-2.3.0.apk` | **只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。 @@ -81,7 +81,7 @@ ### 第一步:电脑上装被控端 -双击 `ykxmz-setup-2.2.0.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。 +双击 `ykxmz-setup-2.3.0.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。 第一次运行会弹出窗口,填两样: @@ -94,12 +94,12 @@ > 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。 > 想彻底退出,窗口上点「退出」。 -> 不想用安装包的话,`ykxmz-agent-2.2.0.zip` 是绿色版: +> 不想用安装包的话,`ykxmz-agent-2.3.0.zip` 是绿色版: > 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。 ### 第二步:手表上装 App -把 `ykxmz-watch-2.2.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 +把 `ykxmz-watch-2.3.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 打开 App,首页有四个入口: @@ -223,7 +223,7 @@ 中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行, Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。 -把 `ykxmz-server-2.2.0.zip` 解压到那台机器上,然后一条命令: +把 `ykxmz-server-2.3.0.zip` 解压到那台机器上,然后一条命令: ```bash bash deploy.sh @@ -456,7 +456,7 @@ windows\build_exe.ps1 ```powershell android\build_apk.ps1 # 流程:aapt2 → javac → d8 → zipalign → apksigner -# 产物:dist\ykxmz-watch-2.2.0.apk +# 产物:dist\ykxmz-watch-2.3.0.apk ``` 最低支持 Android 7.0(API 24),targetSdk 34。 diff --git a/_build/CHANGELOG.md b/_build/CHANGELOG.md index 55d4078..c04089b 100644 --- a/_build/CHANGELOG.md +++ b/_build/CHANGELOG.md @@ -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 - **手表端:输入配对码连上之后,先到一个选择页** —— diff --git a/_build/add_swap_1g.py b/_build/add_swap_1g.py new file mode 100644 index 0000000..57c1cbe --- /dev/null +++ b/_build/add_swap_1g.py @@ -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()) diff --git a/_build/android_v230.py b/_build/android_v230.py new file mode 100644 index 0000000..cc74569 --- /dev/null +++ b/_build/android_v230.py @@ -0,0 +1,1171 @@ +# -*- coding: utf-8 -*- +r'''安卓端 2.3.0:UI 精简 + 播放器 + 鼠标面板重做。 + +== 用户要求(原话整理)== + +「杂七杂八功能都删掉」,只保留: + + 音量加减 / 播放 屏幕镜像 立即返回桌面 + 调整屏幕亮度 发送文件 打开回收站 + 打开各种程序 语音播报 打开网址 + 快捷命令 进程管理 关机/重启/睡眠/锁屏 + 当前窗口置顶 一键结束所有进程 + +再加: + + * 远程鼠标控制(中间滑动区 + 下面三个按钮:左键 / 滚轮 / 右键) + * 视频播放器 + 音乐播放器(暂停开始、进度左右划、上一个下一个) + +== 改动方式 == + +**全部用精确锚点替换,替换前后都做断言。** + +这个项目里"脚本说改了、其实锚点没匹配上"的坑踩过不止一次 +(最典型的是 nginx 那次逐行注释把括号配平搞坏)。 +所以这里的规矩是: + + * 锚点必须**唯一** + * 替换后必须能在结果里找到新内容 + * 任何一步对不上就抛异常,**绝不跳过** + +== 为什么不用 renderGroups 了 == + +原来每个分页靠 `TabDef.groups` 动态渲染电脑端上报的功能组。 +那样"电脑端有什么功能,手表上就出一堆按钮"——正是用户嫌乱的东西。 +现在改成**显式列按钮**:手表上有什么,完全由这份代码决定。 +(电脑端功能还在,只是不再无脑铺到表盘上。) +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +JAVA = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java" + + +def must_replace(text: str, old: str, new: str, label: str) -> str: + """精确替换,锚点必须唯一出现。""" + count = text.count(old) + if count == 0: + raise SystemExit("✗ [%s] 锚点没找到" % label) + if count > 1: + raise SystemExit("✗ [%s] 锚点出现 %d 次,不唯一" % (label, count)) + out = text.replace(old, new, 1) + if new.strip() and new.strip()[:60] not in out: + raise SystemExit("✗ [%s] 替换后找不到新内容" % label) + print(" ✓ %s" % label) + return out + + +# ====================================================================== +# 1. 新的分页定义 +# ====================================================================== + +NEW_TABS = r''' /** 定义所有分页(2.3.0 精简版)。 + * + * 用户要求「杂七杂八功能都删掉」,只留这些。所以这里**显式列按钮**, + * 不再用 groups 把电脑端所有功能无脑铺上来 —— 那正是"乱"的来源。 + * + * 每页按钮数控制在 7 个以内,一屏放得下不用滚。 + */ + private void initTabs() { + tabDefs = new TabDef[]{ + new TabDef("屏幕", new Act[]{ + act("屏幕镜像", new Runnable() { + public void run() { openMirrorPanel(); } + }), + act("返回桌面", new Runnable() { + public void run() { + sendCommand(Json.obj("cmd", "show_desktop"), "返回桌面"); + } + }), + act("亮度 +", new Runnable() { + public void run() { changeBrightness(10); } + }), + act("亮度 -", new Runnable() { + public void run() { changeBrightness(-10); } + }), + act("截屏", new Runnable() { + public void run() { doScreenshot(); } + }), + }), + + new TabDef("鼠标", new Act[]{ + act("鼠标控制", new Runnable() { + public void run() { showMousePanel(); } + }), + }), + + new TabDef("媒体", new Act[]{ + act("播放/暂停", new Runnable() { + public void run() { media("playpause"); } + }), + act("上一曲", new Runnable() { + public void run() { media("prev"); } + }), + act("下一曲", new Runnable() { + public void run() { media("next"); } + }), + act("音量 +", new Runnable() { + public void run() { media("volup"); } + }), + act("音量 -", new Runnable() { + public void run() { media("voldown"); } + }), + act("静音", new Runnable() { + public void run() { media("mute"); } + }), + act("播放器", new Runnable() { + public void run() { showPlayerPanel(); } + }), + }), + + new TabDef("文件", new Act[]{ + act("发送文件", new Runnable() { + public void run() { pickFile(REQ_FILE, "*/*"); } + }), + act("打开回收站", new Runnable() { + public void run() { + runFeature("open_recycle", Json.obj(), "打开回收站"); + } + }), + }), + + new TabDef("电脑", new Act[]{ + act("打开网址", new Runnable() { + public void run() { promptOpenUrl(); } + }), + act("快捷命令", new Runnable() { + public void run() { pickQuickApp(); } + }), + act("进程管理", new Runnable() { + public void run() { pickProcess(); } + }), + act("窗口置顶", new Runnable() { + public void run() { + runFeature("window_top", Json.obj(), "窗口置顶"); + } + }), + act("结束所有进程", new Runnable() { + public void run() { + confirmKillAll(); + } + }), + }), + + new TabDef("语音", new Act[]{ + act("语音播报", new Runnable() { + public void run() { promptSay(); } + }), + act("朗读剪贴板", new Runnable() { + public void run() { + sendCommand(Json.obj("cmd", "say_clip"), "朗读剪贴板"); + } + }), + }), + + new TabDef("电源", new Act[]{ + act("锁屏", new Runnable() { + public void run() { confirmPower("lock", "锁屏"); } + }), + act("睡眠", new Runnable() { + public void run() { confirmPower("sleep", "睡眠"); } + }), + act("关机", new Runnable() { + public void run() { confirmPower("shutdown", "关机"); } + }), + act("重启", new Runnable() { + public void run() { confirmPower("restart", "重启"); } + }), + }), + }; + + tabBar.removeAllViews(); + for (int i = 0; i < tabDefs.length; i++) { + final int index = i; + TextView tab = new TextView(this); + tab.setText(tabDefs[i].name); + tab.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13); + tab.setGravity(Gravity.CENTER); + tab.setPadding(dp(14), dp(8), dp(14), dp(8)); + tab.setBackgroundColor(0x00000000); + tab.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + selectTab(index); + } + }); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.MATCH_PARENT); + params.setMargins(0, 0, 0, 0); + tab.setLayoutParams(params); + tabBar.addView(tab); + } + selectTab(0); + } +''' + +# ====================================================================== +# 2. 新的鼠标面板(中间滑动区 + 下面三个按钮) +# ====================================================================== + +NEW_MOUSE = r''' private void showMousePanel() { + if (mousePanel == null) { + buildMousePanel(); + } + showOnly(mousePanel); + appendLog("鼠标:中间滑动移动光标,下面左键/滚轮/右键"); + } + + /** 远程鼠标控制面板(2.3.0 版)。 + * + * 用户要求:「中间是一个鼠标,可以上下左右滑动控制, + * 下面有三个按钮,左边是左键,中间是那个滑轮,右边是那个右键」。 + * + * 布局: + * + * ┌──────────────────────────┐ + * │ 返回(右上角) │ + * │ │ + * │ 滑动区(占满) │ <- 手指滑动 = 移动光标 + * │ │ + * ├────────┬────────┬────────┤ + * │ 左键 │ 滚轮 │ 右键 │ + * └────────┴────────┴────────┘ + * + * 滚轮按钮:**点一下 = 中键单击**,**上下滑 = 滚动**。 + * 这样三个按钮就把鼠标的左/中/右 + 滚轮全覆盖了。 + */ + private void buildMousePanel() { + mousePanel = new FrameLayout(this); + mousePanel.setBackgroundColor(0xFF101418); + + LinearLayout col = new LinearLayout(this); + col.setOrientation(LinearLayout.VERTICAL); + col.setPadding(dp(6), dp(34), dp(6), dp(6)); + mousePanel.addView(col, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + // ---- 滑动区 ---- + mousePad = new View(this); + mousePad.setBackgroundColor(0xFF1B2028); + LinearLayout.LayoutParams padLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f); + padLp.setMargins(0, 0, 0, dp(6)); + col.addView(mousePad, padLp); + + // ---- 底部三个按钮 ---- + LinearLayout keys = new LinearLayout(this); + keys.setOrientation(LinearLayout.HORIZONTAL); + col.addView(keys, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(46))); + + keys.addView(mouseKey("左键", new Runnable() { + public void run() { sendMouse("click", "left", 0); } + }), keyLp()); + + // 滚轮:点=中键,上下滑=滚动 + final Button wheel = new Button(this); + wheel.setText("滚轮"); + wheel.setTextSize(12f); + wheel.setAllCaps(false); + wheel.setPadding(0, 0, 0, 0); + wheel.setMinWidth(0); + wheel.setMinimumWidth(0); + wheel.setLayoutParams(keyLp()); + wheel.setOnTouchListener(new View.OnTouchListener() { + private float downY = 0f; + private int accumulated = 0; + private boolean pressed = false; + + @Override + public boolean onTouch(View v, android.view.MotionEvent e) { + switch (e.getActionMasked()) { + case android.view.MotionEvent.ACTION_DOWN: + downY = e.getY(); + accumulated = 0; + pressed = true; + v.setPressed(true); + return true; + case android.view.MotionEvent.ACTION_MOVE: + if (!pressed) { + return true; + } + // 每滑过一个"格子"滚一格。 + // 手表屏幕小,一格取 18dp 比较好按。 + float cell = dp(18); + float dy = e.getY() - downY; + accumulated = (int) (dy / cell); + // 每积累一格发一次,发完把基准挪过去 + while (accumulated != 0) { + int dir = accumulated > 0 ? -3 : 3; // 往下滑=向下滚 + sendMouse("scroll", "", dir); + downY += (accumulated > 0 ? cell : -cell); + accumulated = (int) ((e.getY() - downY) / cell); + } + return true; + case android.view.MotionEvent.ACTION_UP: + case android.view.MotionEvent.ACTION_CANCEL: + v.setPressed(false); + if (pressed) { + pressed = false; + // 没滑动就是一次中键单击 + float moved = Math.abs(e.getY() - downY); + if (moved < dp(8)) { + sendMouse("click", "middle", 0); + } + } + return true; + default: + return true; + } + } + }); + keys.addView(wheel); + + keys.addView(mouseKey("右键", new Runnable() { + public void run() { sendMouse("click", "right", 0); } + }), keyLp()); + + // ---- 返回 ---- + Button back = new Button(this); + back.setText("返回"); + back.setTextSize(11f); + back.setAllCaps(false); + FrameLayout.LayoutParams backLp = new FrameLayout.LayoutParams( + dp(58), dp(26)); + backLp.gravity = android.view.Gravity.TOP | android.view.Gravity.END; + backLp.setMargins(0, dp(4), dp(4), 0); + back.setLayoutParams(backLp); + back.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + showControlPanel(); + } + }); + mousePanel.addView(back); + + // ---- 触摸:滑动移动光标 ---- + final android.view.GestureDetector detector = + new android.view.GestureDetector(this, + new android.view.GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onScroll(android.view.MotionEvent e1, + android.view.MotionEvent e2, + float dx, float dy) { + // 手指往哪滑,光标往哪走:dx/dy 是"内容移动量", + // 也就是手指移动的反方向,所以取负。 + sendMouseMove(-dx, -dy); + return true; + } + }); + + mousePad.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, android.view.MotionEvent e) { + detector.onTouchEvent(e); + return true; + } + }); + } + + private LinearLayout.LayoutParams keyLp() { + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + 0, ViewGroup.LayoutParams.MATCH_PARENT, 1f); + lp.setMargins(dp(2), 0, dp(2), 0); + return lp; + } +''' + +# ====================================================================== +# 3. 播放器(面板 + 逻辑) +# ====================================================================== + +PLAYER_CODE = r''' + // ================================================================== + // 播放器(2.3.0 新增) + // ================================================================== + // + // 用户要求:「再加一个视频播放器和音乐播放器,有暂停开始, + // 进度左右划,下一个上一个」。 + // + // 设计取舍:**让手表自己放**,而不是让电脑放。 + // + // 如果让电脑放(调 play_video),电脑会打开它自己的播放器 —— + // 我们就**没法拿到进度、也没法精确 seek**,只能发媒体键。 + // 那"进度左右划"就是假的。 + // + // 手表自己放的话:MediaPlayer / VideoView 的 + // getCurrentPosition / seekTo 都是现成的, + // 进度条、拖动、上下一首全部真实可控。 + // + // 代价:要先从电脑把文件下到手表上(几百 KB 到几十 MB)。 + // 文件走现有的分块传输(EVT_FILE),下完自动播。 + + private FrameLayout playerPanel; + private android.widget.VideoView playerVideo; + private android.media.MediaPlayer playerAudio; + private android.widget.SeekBar playerSeek; + private TextView playerTitle; + private TextView playerClock; + private TextView playerEmpty; + private Button playerPlayBtn; + private LinearLayout playerStage; + + /** 电脑上的媒体文件清单(media_list 返回)。 */ + private final java.util.List> playerFiles = + new java.util.ArrayList>(); + private int playerIndex = -1; + private boolean playerIsVideo = false; + private boolean playerLoaded = false; + private boolean playerDragging = false; + /** 正在等播放器文件下载完 —— 下完 handleIncomingFile 会直接播。 */ + private boolean playerPendingPlay = false; + + private final android.os.Handler playerTick = new android.os.Handler(); + + private final Runnable playerTicker = new Runnable() { + @Override + public void run() { + if (playerPanel == null || playerPanel.getVisibility() != View.VISIBLE) { + return; + } + if (!playerDragging) { + try { + int pos = playerPosition(); + int dur = playerDuration(); + if (dur > 0) { + playerSeek.setMax(dur); + playerSeek.setProgress(pos); + playerClock.setText(fmtTime(pos) + " / " + fmtTime(dur)); + } + } catch (Exception ignored) { + } + } + playerTick.postDelayed(this, 400); + } + }; + + private void buildPlayerPanel() { + playerPanel = new FrameLayout(this); + playerPanel.setBackgroundColor(0xFF0E1216); + + LinearLayout col = new LinearLayout(this); + col.setOrientation(LinearLayout.VERTICAL); + col.setPadding(dp(6), dp(34), dp(6), dp(6)); + playerPanel.addView(col, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + // ---- 标题 ---- + playerTitle = new TextView(this); + playerTitle.setText("播放器"); + playerTitle.setTextColor(0xFFFFFFFF); + playerTitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); + playerTitle.setSingleLine(true); + playerTitle.setEllipsize(android.text.TextUtils.TruncateAt.MIDDLE); + playerTitle.setPadding(dp(4), 0, dp(4), dp(4)); + col.addView(playerTitle, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + // ---- 画面 / 封面区 ---- + playerStage = new LinearLayout(this); + playerStage.setOrientation(LinearLayout.VERTICAL); + playerStage.setGravity(Gravity.CENTER); + playerStage.setBackgroundColor(0xFF10151A); + LinearLayout.LayoutParams stageLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f); + stageLp.setMargins(0, 0, 0, dp(4)); + col.addView(playerStage, stageLp); + + playerEmpty = new TextView(this); + playerEmpty.setText("点「列表」选歌/视频\n(从电脑的音乐、视频目录读)"); + playerEmpty.setTextColor(0xFF8B96A3); + playerEmpty.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12); + playerEmpty.setGravity(Gravity.CENTER); + playerStage.addView(playerEmpty, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + + // ---- 进度条 ---- + playerSeek = new android.widget.SeekBar(this); + playerSeek.setMax(1000); + playerSeek.setProgress(0); + col.addView(playerSeek, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(28))); + + playerSeek.setOnSeekBarChangeListener( + new android.widget.SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(android.widget.SeekBar bar, + int progress, boolean fromUser) { + if (fromUser) { + playerClock.setText(fmtTime(progress) + " / " + + fmtTime(playerDuration())); + } + } + + @Override + public void onStartTrackingTouch(android.widget.SeekBar bar) { + playerDragging = true; + } + + @Override + public void onStopTrackingTouch(android.widget.SeekBar bar) { + playerDragging = false; + seekTo(bar.getProgress()); + } + }); + + // ---- 时间 ---- + playerClock = new TextView(this); + playerClock.setText("00:00 / 00:00"); + playerClock.setTextColor(0xFF9AA4B2); + playerClock.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10); + playerClock.setGravity(Gravity.CENTER); + col.addView(playerClock, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT)); + + // ---- 上一首 / 播放 / 下一首 ---- + LinearLayout ctl = new LinearLayout(this); + ctl.setOrientation(LinearLayout.HORIZONTAL); + ctl.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout.LayoutParams ctlLp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(52)); + ctlLp.setMargins(0, dp(4), 0, 0); + col.addView(ctl, ctlLp); + + ctl.addView(playerBtn("上一", new Runnable() { + public void run() { stepPlayer(-1); } + }), new LinearLayout.LayoutParams(0, dp(50), 1f)); + + playerPlayBtn = playerBtn("播放", new Runnable() { + public void run() { togglePlayer(); } + }); + ctl.addView(playerPlayBtn, new LinearLayout.LayoutParams(0, dp(50), 1.4f)); + + ctl.addView(playerBtn("下一", new Runnable() { + public void run() { stepPlayer(1); } + }), new LinearLayout.LayoutParams(0, dp(50), 1f)); + + ctl.addView(playerBtn("列表", new Runnable() { + public void run() { pickPlayerFile(); } + }), new LinearLayout.LayoutParams(0, dp(50), 1f)); + + // ---- 返回 ---- + Button back = new Button(this); + back.setText("返回"); + back.setTextSize(11f); + back.setAllCaps(false); + FrameLayout.LayoutParams backLp = new FrameLayout.LayoutParams( + dp(58), dp(26)); + backLp.gravity = android.view.Gravity.TOP | android.view.Gravity.END; + backLp.setMargins(0, dp(4), dp(4), 0); + back.setLayoutParams(backLp); + back.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + showControlPanel(); + } + }); + playerPanel.addView(back); + + // ---- 画面区左右划 = 快进/快退 10 秒 ---- + final android.view.GestureDetector swipe = + new android.view.GestureDetector(this, + new android.view.GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onFling(android.view.MotionEvent e1, + android.view.MotionEvent e2, + float vx, float vy) { + if (e1 == null || e2 == null) { + return false; + } + float dx = e2.getX() - e1.getX(); + if (Math.abs(dx) < dp(30) || Math.abs(dx) < Math.abs(vy)) { + return false; + } + // 右滑 = 前进 10 秒,左滑 = 后退 10 秒 + seekBy(dx > 0 ? 10000 : -10000); + return true; + } + + @Override + public boolean onSingleTapConfirmed(android.view.MotionEvent e) { + togglePlayer(); // 点画面 = 播放/暂停 + return true; + } + }); + playerStage.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(View v, android.view.MotionEvent e) { + swipe.onTouchEvent(e); + return true; + } + }); + } + + private Button playerBtn(String text, final Runnable action) { + Button b = new Button(this); + b.setText(text); + b.setTextSize(12f); + b.setAllCaps(false); + b.setPadding(0, 0, 0, 0); + b.setMinWidth(0); + b.setMinimumWidth(0); + b.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + action.run(); + } + }); + return b; + } + + private void showPlayerPanel() { + if (playerPanel == null) { + buildPlayerPanel(); + } + showOnly(playerPanel); + startPlayerTicker(); + if (!playerLoaded) { + loadPlayerList(); + } + } + + private void startPlayerTicker() { + playerTick.removeCallbacks(playerTicker); + playerTick.post(playerTicker); + } + + private void stopPlayerTicker() { + playerTick.removeCallbacks(playerTicker); + } + + /** 拉电脑上的媒体清单。 */ + private void loadPlayerList() { + playerLoaded = true; + playerTitle.setText("读取电脑媒体…"); + io.execute(new Runnable() { + @Override + public void run() { + try { + final Map reply = + client.call(Json.obj("cmd", "media_list"), 25000); + final List files = Json.list(reply, "files"); + ui.post(new Runnable() { + @Override + public void run() { + playerFiles.clear(); + for (Object o : files) { + Map m = Json.asMap(o); + if (m != null) { + playerFiles.add(m); + } + } + if (playerFiles.isEmpty()) { + playerTitle.setText("播放器"); + playerEmpty.setText("电脑的音乐/视频目录里没有文件\n" + + "把文件放进「音乐」「视频」或「下载」文件夹"); + playerEmpty.setVisibility(View.VISIBLE); + } else { + playerTitle.setText("播放器 · 共 " + playerFiles.size() + " 个"); + pickPlayerFile(); + } + } + }); + } catch (final IOException e) { + ui.post(new Runnable() { + @Override + public void run() { + playerLoaded = false; + playerTitle.setText("播放器"); + playerEmpty.setText("读不到电脑媒体列表:" + e.getMessage()); + playerEmpty.setVisibility(View.VISIBLE); + } + }); + } + } + }); + } + + private void pickPlayerFile() { + if (playerFiles.isEmpty()) { + loadPlayerList(); + return; + } + String[] labels = new String[playerFiles.size()]; + for (int i = 0; i < playerFiles.size(); i++) { + Map f = playerFiles.get(i); + String kind = Json.str(f, "kind", "audio"); + labels[i] = ("video".equals(kind) ? "▶ " : "♪ ") + + Json.str(f, "name", "?") + + "\n" + humanSize(Json.integer(f, "size", 0)); + } + new AlertDialog.Builder(MainActivity.this) + .setTitle("电脑上的媒体") + .setItems(labels, new android.content.DialogInterface.OnClickListener() { + @Override + public void onClick(android.content.DialogInterface dialog, + int which) { + openPlayerItem(which); + } + }) + .setNegativeButton("关闭", null) + .show(); + } + + /** 下载并播放第 index 个文件。 */ + private void openPlayerItem(final int index) { + if (index < 0 || index >= playerFiles.size()) { + return; + } + playerIndex = index; + final Map f = playerFiles.get(index); + final String name = Json.str(f, "name", "media"); + final String path = Json.str(f, "path", ""); + playerIsVideo = "video".equals(Json.str(f, "kind", "audio")); + + // 先看手表上是不是已经有了(可能刚播过) + File cached = mediaCacheFile(name); + if (cached != null && cached.isFile() && cached.length() > 0) { + startPlayback(cached, name); + return; + } + + playerTitle.setText("下载 " + name + " …"); + playerEmpty.setText("正在从电脑下载…"); + playerEmpty.setVisibility(View.VISIBLE); + playerPendingPlay = true; + incoming = new ByteArrayOutputStream(); + incomingName = name; + + io.execute(new Runnable() { + @Override + public void run() { + try { + client.call(Json.obj("cmd", "media_pull", "path", path), + 600000); + } catch (final IOException e) { + playerPendingPlay = false; + ui.post(new Runnable() { + @Override + public void run() { + playerTitle.setText("播放器"); + playerEmpty.setText("下载失败:" + e.getMessage()); + playerEmpty.setVisibility(View.VISIBLE); + } + }); + } + } + }); + } + + private File mediaCacheFile(String name) { + File dir = getExternalFilesDir(null); + if (dir == null) { + dir = getFilesDir(); + } + File media = new File(dir, "media"); + if (!media.isDirectory()) { + //noinspection ResultOfMethodCallIgnored + media.mkdirs(); + } + // 文件名里可能有路径分隔符之类,清一下 + String safe = name.replaceAll("[\\\\/:*?\"<>|]", "_"); + return new File(media, safe); + } + + /** 真正开始播放本地文件。 */ + private void startPlayback(final File file, final String name) { + stopPlayback(); + playerTitle.setText(name); + playerEmpty.setVisibility(View.GONE); + playerStage.removeAllViews(); + + if (playerIsVideo) { + playerVideo = new android.widget.VideoView(this); + playerVideo.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + playerVideo.setVideoPath(file.getAbsolutePath()); + playerVideo.setOnPreparedListener(new android.media.MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(android.media.MediaPlayer mp) { + playerVideo.start(); + playerPlayBtn.setText("暂停"); + try { + playerSeek.setMax(mp.getDuration()); + } catch (Exception ignored) { + } + } + }); + playerVideo.setOnCompletionListener(new android.media.MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(android.media.MediaPlayer mp) { + // 播完自动下一首(用户要的"下一个") + stepPlayer(1); + } + }); + playerStage.addView(playerVideo); + playerVideo.requestFocus(); + } else { + try { + playerAudio = new android.media.MediaPlayer(); + playerAudio.setDataSource(file.getAbsolutePath()); + playerAudio.prepare(); + playerAudio.start(); + playerPlayBtn.setText("暂停"); + playerSeek.setMax(playerAudio.getDuration()); + playerAudio.setOnCompletionListener( + new android.media.MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(android.media.MediaPlayer mp) { + stepPlayer(1); + } + }); + + // 音频没有画面,摆个大音符 + TextView art = new TextView(this); + art.setText("♪\n" + name); + art.setTextColor(0xFFFFFFFF); + art.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + art.setGravity(Gravity.CENTER); + playerStage.addView(art, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); + } catch (Exception e) { + playerTitle.setText(name); + playerEmpty.setText("播放失败:" + e.getMessage()); + playerEmpty.setVisibility(View.VISIBLE); + } + } + startPlayerTicker(); + } + + private void togglePlayer() { + try { + if (playerIsVideo && playerVideo != null) { + if (playerVideo.isPlaying()) { + playerVideo.pause(); + playerPlayBtn.setText("播放"); + } else { + playerVideo.start(); + playerPlayBtn.setText("暂停"); + } + } else if (playerAudio != null) { + if (playerAudio.isPlaying()) { + playerAudio.pause(); + playerPlayBtn.setText("播放"); + } else { + playerAudio.start(); + playerPlayBtn.setText("暂停"); + } + } + } catch (Exception ignored) { + } + } + + private void stepPlayer(int delta) { + if (playerFiles.isEmpty()) { + pickPlayerFile(); + return; + } + int next = playerIndex + delta; + if (next < 0) { + next = playerFiles.size() - 1; + } + if (next >= playerFiles.size()) { + next = 0; + } + openPlayerItem(next); + } + + private int playerPosition() { + try { + if (playerIsVideo && playerVideo != null) { + return playerVideo.getCurrentPosition(); + } + if (playerAudio != null) { + return playerAudio.getCurrentPosition(); + } + } catch (Exception ignored) { + } + return 0; + } + + private int playerDuration() { + try { + if (playerIsVideo && playerVideo != null) { + return playerVideo.getDuration(); + } + if (playerAudio != null) { + return playerAudio.getDuration(); + } + } catch (Exception ignored) { + } + return 0; + } + + private void seekTo(int ms) { + try { + if (playerIsVideo && playerVideo != null) { + playerVideo.seekTo(ms); + } else if (playerAudio != null) { + playerAudio.seekTo(ms); + } + } catch (Exception ignored) { + } + } + + private void seekBy(int deltaMs) { + int dur = playerDuration(); + int target = playerPosition() + deltaMs; + if (target < 0) { + target = 0; + } + if (dur > 0 && target > dur) { + target = dur; + } + seekTo(target); + toast((deltaMs > 0 ? "快进 " : "快退 ") + + Math.abs(deltaMs) / 1000 + " 秒"); + } + + private void stopPlayback() { + try { + if (playerVideo != null) { + playerVideo.stopPlayback(); + playerVideo = null; + } + } catch (Exception ignored) { + } + try { + if (playerAudio != null) { + playerAudio.release(); + playerAudio = null; + } + } catch (Exception ignored) { + } + playerStage.removeAllViews(); + playerEmpty.setVisibility(View.VISIBLE); + playerPlayBtn.setText("播放"); + } + + private void releasePlayer() { + stopPlayerTicker(); + stopPlayback(); + } + + private static String fmtTime(int ms) { + if (ms < 0) { + ms = 0; + } + int total = ms / 1000; + int m = total / 60; + int s = total % 60; + return String.format("%02d:%02d", m, s); + } + + // ---- 亮度 / 结束所有进程(2.3.0 新增的显式入口)---- + + /** 调屏幕亮度。电脑端 brightness 是 10~100 的绝对值, + * 所以这里先问电脑当前值,再 ±delta。 */ + private void changeBrightness(final int delta) { + io.execute(new Runnable() { + @Override + public void run() { + try { + Map reply = + client.call(Json.obj("cmd", "feature_run", + "name", "brightness", + "params", Json.obj()), 15000); + int now = Json.integer(reply, "value", 0); + if (now <= 0) { + now = 70; + } + int target = Math.max(10, Math.min(100, now + delta)); + client.call(Json.obj("cmd", "feature_run", + "name", "brightness", + "params", Json.obj("value", target)), 15000); + final int shown = target; + ui.post(new Runnable() { + @Override + public void run() { + toast("亮度 " + shown + "%"); + } + }); + } catch (final IOException e) { + postError("亮度", e); + } + } + }); + } + + private void confirmKillAll() { + new AlertDialog.Builder(MainActivity.this) + .setTitle("结束所有进程") + .setMessage("会结束这台电脑上所有**用户程序**" + + "(系统进程和本程序会保留)。继续?") + .setPositiveButton("结束", new android.content.DialogInterface.OnClickListener() { + @Override + public void onClick(android.content.DialogInterface dialog, int which) { + runFeature("kill_all", Json.obj(), "结束所有进程"); + } + }) + .setNegativeButton("取消", null) + .show(); + } +''' + +# ====================================================================== +# 入口 +# ====================================================================== + +def main() -> int: + if not JAVA.is_file(): + print("✗ 找不到 %s" % JAVA) + return 1 + text = JAVA.read_text(encoding="utf-8") + original_len = len(text) + print("=" * 62) + print(" 安卓端 2.3.0 改动") + print("=" * 62) + print(" 原文 %d 字符" % original_len) + print() + + # ---- 1. initTabs 整体替换 ---- + # 锚点:从 " /** 定义所有分页" 到 initTabs 的收尾 " selectTab(0);\n }" + start = text.find(" /** 定义所有分页") + if start < 0: + print("✗ 找不到 initTabs 的注释锚点") + return 1 + end_mark = " selectTab(0);\n }\n" + end = text.find(end_mark, start) + if end < 0: + print("✗ 找不到 initTabs 的结束锚点") + return 1 + end += len(end_mark) + old_tabs = text[start:end] + if "new TabDef(\"媒体\"" not in old_tabs or "new TabDef(\"电源\"" in old_tabs: + print("✗ initTabs 锚点范围不对(%d 字符)" % len(old_tabs)) + return 1 + text = text[:start] + NEW_TABS + text[end:] + print(" ✓ 替换 initTabs(%d -> %d 字符)" % (len(old_tabs), len(NEW_TABS))) + + # ---- 2. 鼠标面板整体替换 ---- + m_start = text.find(" private void showMousePanel() {") + if m_start < 0: + print("✗ 找不到 showMousePanel") + return 1 + # 结束:sendMouseMove 方法之后、选择页注释之前 + m_end_mark = " // ==================================================================\n // 连接后的选择页(远控 / 静默更新)" + m_end = text.find(m_end_mark, m_start) + if m_end < 0: + print("✗ 找不到鼠标面板结束锚点") + return 1 + old_mouse = text[m_start:m_end] + if "sendMouseMove" not in old_mouse: + print("✗ 鼠标面板锚点范围不对") + return 1 + # 保留 sendMouse 和 sendMouseMove(它们被别处调用) + keep_start = old_mouse.find(" private void sendMouse(String action") + if keep_start < 0: + print("✗ 找不到 sendMouse") + return 1 + keep = old_mouse[keep_start:] + text = text[:m_start] + NEW_MOUSE + "\n" + keep + text[m_end:] + print(" ✓ 替换鼠标面板(%d -> %d 字符)" % (len(old_mouse), len(NEW_MOUSE) + len(keep))) + + # ---- 3. 插入播放器代码 ---- + # 放在鼠标面板之后、选择页之前 + anchor = " // ==================================================================\n // 连接后的选择页(远控 / 静默更新)" + if text.count(anchor) != 1: + print("✗ 播放器插入锚点不唯一") + return 1 + text = text.replace(anchor, PLAYER_CODE + "\n" + anchor, 1) + print(" ✓ 插入播放器代码(%d 字符)" % len(PLAYER_CODE)) + + # ---- 4. showOnly 登记新面板 ---- + text = must_replace( + text, + " View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,\n" + " settingsPanel, mousePanel, choicePanel};", + " View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,\n" + " settingsPanel, mousePanel, choicePanel, playerPanel};", + "showOnly 登记播放器面板") + + # ---- 5. 面板注册:addView + topMargin ---- + text = must_replace( + text, + " applyPanelTopMargin(mousePanel);\n" + " applyPanelTopMargin(choicePanel);", + " applyPanelTopMargin(mousePanel);\n" + " applyPanelTopMargin(choicePanel);\n" + " applyPanelTopMargin(playerPanel);", + "播放器面板让出快捷栏高度") + + # ---- 6. 建面板 + 挂到 root ---- + text = must_replace( + text, + " buildChoicePanel(); // 同上:连上之后才显示\n" + " buildQuickBar();", + " buildChoicePanel(); // 同上:连上之后才显示\n" + " buildPlayerPanel(); // 播放器,先建不显示\n" + " buildQuickBar();", + "构建播放器面板") + + # 挂到 root:在 choicePanel 的 addView 之后 + choice_add = " root.addView(choicePanel, new FrameLayout.LayoutParams(\n" \ + " ViewGroup.LayoutParams.MATCH_PARENT,\n" \ + " ViewGroup.LayoutParams.MATCH_PARENT));\n" + if text.count(choice_add) != 1: + print("✗ choicePanel addView 锚点不唯一") + return 1 + text = text.replace( + choice_add, + choice_add + " root.addView(playerPanel, new FrameLayout.LayoutParams(\n" + " ViewGroup.LayoutParams.MATCH_PARENT,\n" + " ViewGroup.LayoutParams.MATCH_PARENT));\n" + " playerPanel.setVisibility(View.GONE);\n", + 1) + print(" ✓ 播放器面板挂到 root") + + # ---- 7. handleIncomingFile 支持播放器 ---- + text = must_replace( + text, + " appendLog(\"已保存 \" + name + \" (\" + humanSize(blob.length) + \")\");\n" + " toast(\"已保存到 \" + saved.getName());", + " appendLog(\"已保存 \" + name + \" (\" + humanSize(blob.length) + \")\");\n" + " if (playerPendingPlay) {\n" + " // 播放器在等这个文件 —— 下完直接播\n" + " playerPendingPlay = false;\n" + " File media = mediaCacheFile(name);\n" + " if (media != null) {\n" + " try {\n" + " java.io.FileOutputStream out =\n" + " new java.io.FileOutputStream(media);\n" + " out.write(blob);\n" + " out.close();\n" + " } catch (IOException ignored) {\n" + " }\n" + " startPlayback(media, name);\n" + " }\n" + " } else {\n" + " toast(\"已保存到 \" + saved.getName());\n" + " }", + "handleIncomingFile 播放器接管") + + JAVA.write_text(text, encoding="utf-8", newline="\n") + print() + print(" 改后 %d 字符(+%d)" % (len(text), len(text) - original_len)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/delete_methods.py b/_build/delete_methods.py new file mode 100644 index 0000000..33365f1 --- /dev/null +++ b/_build/delete_methods.py @@ -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()) diff --git a/_build/diag_tool.py b/_build/diag_tool.py new file mode 100644 index 0000000..a160a5d --- /dev/null +++ b/_build/diag_tool.py @@ -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 diff --git a/_build/find_orphans.py b/_build/find_orphans.py new file mode 100644 index 0000000..40ed898 --- /dev/null +++ b/_build/find_orphans.py @@ -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()) diff --git a/_build/halo_cert.py b/_build/halo_cert.py new file mode 100644 index 0000000..c6948bb --- /dev/null +++ b/_build/halo_cert.py @@ -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/ +能直接返回文件。 + +但 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()) diff --git a/_build/halo_console_path.py b/_build/halo_console_path.py new file mode 100644 index 0000000..08175c2 --- /dev/null +++ b/_build/halo_console_path.py @@ -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()) diff --git a/_build/halo_login3.py b/_build/halo_login3.py new file mode 100644 index 0000000..c144a29 --- /dev/null +++ b/_build/halo_login3.py @@ -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 里(`` 或 ``)。 +Halo 的登录页是 SPA 还是服务端渲染会影响它在不在 —— 所以脚本会 +**两种都找**,找不到就明确报出来,不瞎猜。 + +== 如果还是不行 == + +那就得让用户在 Halo 后台建一个**个人令牌**(设置 -> 个人令牌), +拿到之后 `Authorization: Bearer ` 就能用。 +那是最可靠的方式 —— 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 '(]*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 '[^<]*' /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()) diff --git a/_build/halo_login_rsa.py b/_build/halo_login_rsa.py new file mode 100644 index 0000000..ff0648c --- /dev/null +++ b/_build/halo_login_rsa.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +r'''Halo 发文章(最终版):RSA 加密登录 + 建文章。 + +== 前面六次都失败,原因是这个 == + +Halo 的登录页用 **jsencrypt** 把密码 **RSA 加密**后才提交: + + +
+ + <- JS 把密文填这里 + + <- 用户输入的明文 +
+ +**所以在终端直接发明文密码永远登不上** —— 服务端拿到的是密文, +它会 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()) diff --git a/_build/halo_nginx.py b/_build/halo_nginx.py new file mode 100644 index 0000000..ec17eb3 --- /dev/null +++ b/_build/halo_nginx.py @@ -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()) diff --git a/_build/halo_nginx_fix.py b/_build/halo_nginx_fix.py new file mode 100644 index 0000000..b14cd7a --- /dev/null +++ b/_build/halo_nginx_fix.py @@ -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()) diff --git a/_build/halo_precheck.py b/_build/halo_precheck.py new file mode 100644 index 0000000..9f48248 --- /dev/null +++ b/_build/halo_precheck.py @@ -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()) diff --git a/_build/halo_publish.py b/_build/halo_publish.py new file mode 100644 index 0000000..9253e71 --- /dev/null +++ b/_build/halo_publish.py @@ -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,**对不上就放弃这一轮,保持现状**。现状至少是能用的。 + +## 怎么装 + +**官网下载:** + +- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事 +- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码 + +装完之后: + +1. 手表连上电脑 +2. 会先到一个选择页:**远控** / **静默更新被控端** +3. 点「远控」进控制页 + +## 说在最后 + +这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。 + +后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。 + +**有问题、想要什么功能,欢迎留言。** +""" + + +def md_to_html(text: str) -> str: + """把这份固定的 Markdown 转成 HTML。 + + ⚠ 这里**不是在写通用 Markdown 解析器** —— 那样要引第三方库, + 而我们的正文是我自己写的、结构已知。用简单的正则按块处理就够了, + 而且不会因为库的行为差异出意外。 + + 支持:## / ### 标题、**粗体**、`代码`、- 列表、1. 列表、 + 表格、> 引用、 自动链接、空行分段。 + """ + 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"\1", s) + s = _re.sub(r"`([^`]+)`", r"\1", s) + s = _re.sub(r"<(https?://[^&]+?)>", r'\1', 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("" + + "".join("" % inline(c) for c in head) + + "" + + "".join("" + "".join("" % inline(c) + for c in r) + "" + for r in rows) + + "
%s
%s
") + continue + # 标题 + m = _re.match(r"^(#{2,4})\s+(.*)$", ln) + if m: + lvl = len(m.group(1)) + out.append("%s" % (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("
    " + "".join("
  • %s
  • " % x for x in items) + + "
") + 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("
    " + "".join("
  1. %s
  2. " % x for x in items) + + "
") + continue + # 引用 + if ln.startswith(">"): + out.append("
%s
" + % 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("

%s

" % 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()) diff --git a/_build/halo_publish2.py b/_build/halo_publish2.py new file mode 100644 index 0000000..23dc7da --- /dev/null +++ b/_build/halo_publish2.py @@ -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()) diff --git a/_build/halo_publish3.py b/_build/halo_publish3.py new file mode 100644 index 0000000..a262783 --- /dev/null +++ b/_build/halo_publish3.py @@ -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,**对不上就放弃这一轮,保持现状**。现状至少是能用的。 + +## 怎么装 + +**官网下载:** + +- **电脑端** —— 下载安装包,双击,填一个 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"\1", s) + s = _re.sub(r"`([^`]+)`", r"\1", s) + s = _re.sub(r"<(https?://[^&]+?)>", r'\1', 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("" + + "".join("" % inline(c) for c in head) + + "" + + "".join("" + "".join("" % inline(c) + for c in r) + "" + for r in rows) + "
%s
%s
") + continue + m = _re.match(r"^(#{2,4})\s+(.*)$", ln) + if m: + lv = len(m.group(1)) + out.append("%s" % (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("
    " + "".join("
  • %s
  • " % x for x in items) + + "
") + 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("
    " + "".join("
  1. %s
  2. " % x for x in items) + + "
") + continue + if ln.startswith(">"): + out.append("
%s
" + % 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("

%s

" % 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()) diff --git a/_build/halo_publish_final.py b/_build/halo_publish_final.py new file mode 100644 index 0000000..ff71d5f --- /dev/null +++ b/_build/halo_publish_final.py @@ -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,**对不上就放弃这一轮,保持现状**。现状至少是能用的。 + +## 怎么装 + +**官网下载:** + +- **电脑端** —— 下载安装包,双击,填一个 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"\1", s) + s = _re.sub(r"`([^`]+)`", r"\1", s) + s = _re.sub(r"<(https?://[^&]+?)>", r'\1', 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("" + + "".join("" % inline(c) for c in head) + + "" + + "".join("" + "".join("" % inline(c) + for c in r) + "" + for r in rows) + "
%s
%s
") + continue + m = _re.match(r"^(#{2,4})\s+(.*)$", ln) + if m: + lv = len(m.group(1)) + out.append("%s" % (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("
    " + "".join("
  • %s
  • " % x for x in items) + + "
") + 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("
    " + "".join("
  1. %s
  2. " % x for x in items) + + "
") + continue + if ln.startswith(">"): + out.append("
%s
" + % 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("

%s

" % 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 '[^<]*' | 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()) diff --git a/_build/halo_reset_email.py b/_build/halo_reset_email.py new file mode 100644 index 0000000..1d94880 --- /dev/null +++ b/_build/halo_reset_email.py @@ -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()) diff --git a/_build/install_halo.py b/_build/install_halo.py new file mode 100644 index 0000000..12cc521 --- /dev/null +++ b/_build/install_halo.py @@ -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()) diff --git a/_build/locate_instability.py b/_build/locate_instability.py new file mode 100644 index 0000000..93a3b5c --- /dev/null +++ b/_build/locate_instability.py @@ -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()) diff --git a/_build/package_remote.py b/_build/package_remote.py index 22d1afd..4acb8ce 100644 --- a/_build/package_remote.py +++ b/_build/package_remote.py @@ -17,7 +17,7 @@ import zipfile from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") -VERSION = "2.2.0" +VERSION = "2.3.0" NAME = f"ykxmz-{VERSION}" AGENT_SRC = ROOT / "dist_exe" / "ykxmz" diff --git a/_build/windows_v230.py b/_build/windows_v230.py new file mode 100644 index 0000000..5b3584d --- /dev/null +++ b/_build/windows_v230.py @@ -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()) diff --git a/_tests/test_apk_contents.py b/_tests/test_apk_contents.py index dfa8624..252bbfd 100644 --- a/_tests/test_apk_contents.py +++ b/_tests/test_apk_contents.py @@ -23,7 +23,7 @@ def _latest_apk() -> Path: (ROOT / "dist").glob("*watch*.apk"), key=lambda p: p.stat().st_mtime, reverse=True) return candidates[0] if candidates else \ - ROOT / "dist" / "ykxmz-watch-2.2.0.apk" + ROOT / "dist" / "ykxmz-watch-2.3.0.apk" APK = _latest_apk() diff --git a/_tests/test_no_orphan_methods.py b/_tests/test_no_orphan_methods.py index 30f62d3..54b364e 100644 --- a/_tests/test_no_orphan_methods.py +++ b/_tests/test_no_orphan_methods.py @@ -156,7 +156,14 @@ def main() -> int: print() print("二、分页定义完整性") tab_names = re.findall(r'new TabDef\("([^"]+)"', text) - check("定义了 10 个分页", len(tab_names) == 10, f"实际 {len(tab_names)} 个") + # 2.3.0:用户要求「杂七杂八功能都删掉」,分页从 10 个精简到 7 个。 + # 断言改成 7,并且把期望的名字也列出来 —— 这样以后误删/误加 + # 都能立刻看出来,比只数个数更有用。 + EXPECT_TABS = ["屏幕", "鼠标", "媒体", "文件", "电脑", "语音", "电源"] + check(f"定义了 {len(EXPECT_TABS)} 个分页", len(tab_names) == len(EXPECT_TABS), + f"实际 {len(tab_names)} 个:{' '.join(tab_names)}") + check("分页名字和 2.3.0 设计一致", tab_names == EXPECT_TABS, + f"实际 {' '.join(tab_names)},期望 {' '.join(EXPECT_TABS)}") print(f" 分页: {' '.join(tab_names)}") check("selectTab 有定义", "private void selectTab(" in text) check("selectTab 被调用", len(re.findall(r"\bselectTab\s*\(", text)) > 1) diff --git a/_tests/test_package_remote.py b/_tests/test_package_remote.py index 78d9150..62cb361 100644 --- a/_tests/test_package_remote.py +++ b/_tests/test_package_remote.py @@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent # 被控端 -> 一个 zip # 服务器端 -> 一个 zip # 控制端 -> 直接给 APK(不打 zip) -AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.2.0.zip" -SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.2.0.zip" -WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.2.0.apk" +AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.3.0.zip" +SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.3.0.zip" +WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.3.0.apk" TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain") JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe" JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe" @@ -89,7 +89,7 @@ def main() -> int: for label, path in (("被控端", agent_dir / "README-agent.txt"), ("服务器端", relay_dir / "README-server.txt"), ("控制端(旁边那份)", - ROOT / "dist" / "ykxmz-watch-2.2.0-说明.txt")): + ROOT / "dist" / "ykxmz-watch-2.3.0-说明.txt")): raw = path.read_bytes() if path.is_file() else b"" check(f"{label}说明有 UTF-8 BOM", raw[:3] == b"\xef\xbb\xbf", raw[:3]) diff --git a/_tests/test_quickbar.py b/_tests/test_quickbar.py index fd1965c..32ac3de 100644 --- a/_tests/test_quickbar.py +++ b/_tests/test_quickbar.py @@ -170,21 +170,51 @@ def main() -> int: # ---------------- 五、鼠标触摸板 ---------------- print() - print("五、鼠标触摸板") + print("五、鼠标触摸板(2.3.0 版:滑动区 + 左键/滚轮/右键)") check("有 buildMousePanel", "private void buildMousePanel()" in main_java) - check("用 GestureDetector(单击/双击/拖动)", - "GestureDetector" in main_java - and "onSingleTapConfirmed" in main_java - and "onDoubleTap" in main_java - and "onScroll" in main_java) - check("单击 -> 左键", 'sendMouse("click", "left", 0)' in main_java) - check("双击 -> 右键", 'sendMouse("click", "right", 0)' in main_java) - check("滚轮上/下", 'sendMouse("scroll", "", 3)' in main_java - and 'sendMouse("scroll", "", -3)' in main_java) + # 2.3.0 用户明确要求:「中间是一个鼠标,可以上下左右滑动控制, + # 下面有三个按钮,左边是左键,中间是那个滑轮,右边是那个右键」。 + # 所以**不再用单击/双击手势** —— 左键和右键各有专门按钮, + # 手势里只留"滑动 = 移动光标"。 + check("滑动区用 GestureDetector 的 onScroll(移动光标)", + "GestureDetector" in main_java and "onScroll" in main_java) + check("左键按钮 -> sendMouse click left", + 'sendMouse("click", "left", 0)' in main_java) + check("右键按钮 -> sendMouse click right", + 'sendMouse("click", "right", 0)' in main_java) + check("滚轮按钮:点一下 = 中键", 'sendMouse("click", "middle", 0)' in main_java) + check("滚轮按钮:上下滑 = 滚动", + 'sendMouse("scroll", "", dir)' in main_java) + check("滚轮按钮有独立的触摸处理(不是普通 OnClickListener)", + "wheel.setOnTouchListener" in main_java) check("拖动 -> moveby", '"moveby"' in main_java) check("isConnected() 存在(第一版漏了,编译才发现)", "private boolean isConnected()" in main_java) + # ---------------- 五之一、播放器(2.3.0 新增)---------------- + print() + print("五之一、播放器") + check("有 buildPlayerPanel", "private void buildPlayerPanel()" in main_java) + check("有 seekTo / seekBy(进度左右划)", + "private void seekTo(int ms)" in main_java + and "private void seekBy(int deltaMs)" in main_java) + check("有上一/下一(stepPlayer)", + "private void stepPlayer(int delta)" in main_java) + check("视频用 VideoView", "android.widget.VideoView" in main_java) + check("音频用 MediaPlayer", "android.media.MediaPlayer" in main_java) + check("画面区左右滑 = 快进/快退", + "seekBy(dx > 0 ? 10000 : -10000)" in main_java) + check("画面区点击 = 播放/暂停", + "public boolean onSingleTapConfirmed" in main_java) + check("★ 播放器面板登记进 showOnly 数组", "playerPanel" in main_java + and re.search(r"View\[\] all = \{[^}]*playerPanel", main_java) is not None, + "漏了的话它永远不隐藏") + check("★ 播放器面板挂到了 root 上", + "root.addView(playerPanel" in main_java, + "只建不挂 = 一片空白") + check("★ 播完自动下一首", + "onCompletion" in main_java and "stepPlayer(1)" in main_java) + # ---------------- 五之二、连接后的选择页 ---------------- print() print("五之二、连接后的选择页(远控 / 静默更新被控端)") diff --git a/_tests/test_recode_survives_ttl.py b/_tests/test_recode_survives_ttl.py new file mode 100644 index 0000000..4ce1487 --- /dev/null +++ b/_tests/test_recode_survives_ttl.py @@ -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()) diff --git a/_tests/test_v230_new_commands.py b/_tests/test_v230_new_commands.py new file mode 100644 index 0000000..d520bd5 --- /dev/null +++ b/_tests/test_v230_new_commands.py @@ -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()) diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 325b89c..9f3385a 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:versionName="2.3.0">