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

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

中转站
- known_devices 从 ID 集合改成「设备ID -> 配对码」映射
- 修复:管理员改码后关机超过 6 小时,改码会被超时清理冲掉
- 老格式 state 文件平滑升级
2026-10-18 11:39:14 +08:00

392 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
r'''Halo 发文章(最终版):完整登录流程 + 建文章 + 验证。
== 走通的路 ==
1. GET /login -> 会话 cookie + HTML 里的 _csrf token
2. POST /login -> cookie + _csrf + 用户名密码
3. GET posts -> 确认会话有效 + 看现有文章
4. POST posts -> 建文章(PostRequest 结构)
5. GET 前台首页 -> 确认公开可见
== 三次失败的记录(免得以后再绕)==
① `Authorization: Basic` -> 302
Halo 的 console API 不认 Basic,要会话 cookie 或 Personal Access Token。
② 直接 POST /login -> 403
Spring Security 的 CSRF 保护,必须带上 GET 时拿到的 `_csrf`。
③ 只看 cookie jar 的格式 -> 以为没 cookie
curl 的 jar 是 tab 分隔的,我按空格拆了。实际有。
== 建文章的请求体 ==
POST /apis/api.console.halo.run/v1alpha1/posts
{
"post": {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {"name": "post-<随机>", "generateName": "post-"},
"spec": {
"title": "...", "slug": "...",
"publish": true, "publishTime": "...",
"visible": "PUBLIC", "allowComment": true,
"excerpt": {"autoGenerate": false, "raw": "摘要..."},
"categories": [], "tags": [], "htmlMetas": []
}
},
"content": {"raw": "Markdown 源码", "content": "渲染后的 HTML",
"rawType": "markdown"}
}
'''
from __future__ import annotations
import base64
import html as _html
import json
import re as _re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
PWFILE = ROOT / "_build" / ".halo_password"
USER = "xmz7891"
TITLE = "远控软件正式上线"
SLUG = "remote-control-launch"
EXCERPT = ("一个为站在讲台前的人做的远程控制工具。手表抬一下手腕,就能操作"
"教室那台电脑:远程鼠标、投图到大屏、语音播报、静默自动更新。")
MD = """一个为「站在讲台前的人」做的远程控制工具。手表抬一下手腕,就能操作教室那台电脑。
## 它是干什么的
很多场景下,人不在电脑前面,但需要操作那台电脑:
- 上课时站在教室中间,想切个课件、放张图
- 坐在办公室,想看看教室那台电脑还开着没
- 手上拿着东西,腾不出手去碰鼠标键盘
**这个软件就是解决这个的。**
电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。
## 能做什么
### 远程鼠标控制
手表变成触摸板:
- **拖动** —— 移动鼠标
- **单击** —— 左键
- **双击** —— 右键
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
### 一键显示桌面
快捷栏上有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
### 电脑端静默自动更新
这是最省心的一点:
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
- 全程**不弹任何窗口**
- 中转地址、配对码、所有设置**一个都不变**
- 更新完自动回来继续跑
不用再跑到电脑前手动装一遍。
### 顶部快捷栏
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
| 可选动作 | 说明 |
|---|---|
| 传文件 | 从手表把文件发到电脑 |
| 屏幕镜像 | 看电脑屏幕 |
| 鼠标控制 | 进触摸板模式 |
| 音效 | 让电脑放一声 |
| 关机 | 锁屏 / 关机 / 重启 |
| 语音播报 | 让电脑念一段字 |
| 投图到电脑 | 手表选图,全屏投到大屏 |
| 显示桌面 | 等于 Win + D |
在设置页里点一下就能换。
### 投图到大屏
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
上课讲题、展示作业、临时放张图,比插 U 盘快得多。
### 语音播报
输入文字,电脑用系统自带的语音念出来。
「上课了」「下课了」「大家看一下黑板」——说一遍不如让电脑说一遍。
### 让电脑放个音效
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
### 其他
- 剪贴板互传(手表和电脑之间)
- 电源控制(锁屏、关机、重启)
- 文件互传
- 静音、音量加减、播放控制
## 几个设计上的取舍
### 电脑端不弹任何窗口
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
这一点是刻意的:要的就是「像不存在一样」。如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 —— 设置里有,默认关着。
### 支持 Windows 7
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
### 静默更新为什么要校验
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
## 怎么装
**官网下载:<https://yk.xxmxxs.top>**
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
装完之后:
1. 手表连上电脑
2. 会先到一个选择页:**远控** / **静默更新被控端**
3. 点「远控」进控制页
## 说在最后
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
**有问题、想要什么功能,欢迎留言。**
"""
def md_to_html(text: str) -> str:
"""把这份固定的 Markdown 转成 HTML。
不是通用解析器 —— 只处理我正文里用到的那几种语法。
好处:不引第三方库,行为完全可控,不会有意外。
"""
def inline(s: str) -> str:
s = _html.escape(s, quote=False)
s = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
s = _re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = _re.sub(r"&lt;(https?://[^&]+?)&gt;", r'<a href="\1">\1</a>', s)
return s
out, lines, i = [], text.split("\n"), 0
while i < len(lines):
ln = lines[i]
if not ln.strip():
i += 1
continue
if ln.startswith("|") and i + 1 < len(lines) and \
set(lines[i + 1].replace("|", "").strip()) <= set("-: "):
head = [c.strip() for c in ln.strip("|").split("|")]
i += 2
rows = []
while i < len(lines) and lines[i].startswith("|"):
rows.append([c.strip() for c in lines[i].strip("|").split("|")])
i += 1
out.append("<table><thead><tr>" +
"".join("<th>%s</th>" % inline(c) for c in head) +
"</tr></thead><tbody>" +
"".join("<tr>" + "".join("<td>%s</td>" % inline(c)
for c in r) + "</tr>"
for r in rows) + "</tbody></table>")
continue
m = _re.match(r"^(#{2,4})\s+(.*)$", ln)
if m:
lv = len(m.group(1))
out.append("<h%d>%s</h%d>" % (lv, inline(m.group(2)), lv))
i += 1
continue
if _re.match(r"^[-*]\s+", ln):
items = []
while i < len(lines) and _re.match(r"^[-*]\s+", lines[i]):
items.append(inline(_re.sub(r"^[-*]\s+", "", lines[i])))
i += 1
out.append("<ul>" + "".join("<li>%s</li>" % x for x in items)
+ "</ul>")
continue
if _re.match(r"^\d+\.\s+", ln):
items = []
while i < len(lines) and _re.match(r"^\d+\.\s+", lines[i]):
items.append(inline(_re.sub(r"^\d+\.\s+", "", lines[i])))
i += 1
out.append("<ol>" + "".join("<li>%s</li>" % x for x in items)
+ "</ol>")
continue
if ln.startswith(">"):
out.append("<blockquote>%s</blockquote>"
% inline(ln.lstrip("> ").strip()))
i += 1
continue
para = [ln.strip()]
i += 1
while i < len(lines) and lines[i].strip() and \
not _re.match(r"^(#{2,4}\s|[-*]\s|\d+\.\s|>|\|)", lines[i]):
para.append(lines[i].strip())
i += 1
out.append("<p>%s</p>" % inline(" ".join(para)))
return "\n".join(out)
def main() -> int:
print("=" * 62)
print(" Halo:登录 -> 发文章")
print("=" * 62)
print()
pw = PWFILE.read_text(encoding="utf-8").strip()
body_html = md_to_html(MD)
print(" 正文 Markdown %d 字 -> HTML %d 字" % (len(MD), len(body_html)))
# 请求体
import datetime
now = datetime.datetime.now(datetime.timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S.000Z")
import uuid
req_body = {
"post": {
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {"name": "post-" + uuid.uuid4().hex[:12],
"generateName": "post-"},
"spec": {
"title": TITLE,
"slug": SLUG,
"template": "",
"cover": "",
"deleted": False,
"publish": True,
"publishTime": now,
"pinned": False,
"allowComment": True,
"visible": "PUBLIC",
"priority": 0,
"excerpt": {"autoGenerate": False, "raw": EXCERPT},
"categories": [],
"tags": [],
"htmlMetas": [],
},
},
"content": {"raw": MD, "content": body_html, "rawType": "markdown"},
}
payload = json.dumps(req_body, ensure_ascii=False)
# 把请求体传到服务器(用 base64 避免 shell 转义地狱)
b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii")
print()
print("── 1. 登录(GET /login 拿 csrf+cookie,再 POST)──")
out = run(r'''
set -e
U=__U__
PW=__PW__
BASE=http://127.0.0.1:3001
J=/tmp/hc.txt
H=/tmp/login.html
rm -f $J $H
# 1) GET /login
curl -s -c $J -o $H --max-time 15 "$BASE/login"
CSRF=$(grep -oE 'name="_csrf" value="[^"]+"' $H | head -1 | sed 's/.*value="//; s/"//')
echo " csrf: ${CSRF:0:24}...(长度 ${#CSRF})"
if [ -z "$CSRF" ]; then echo " ✗ 拿不到 csrf"; exit 1; fi
# 2) POST /login
curl -s -b $J -c $J -o /tmp/login_resp.html \
-w " POST /login -> HTTP %{http_code} 跳转-> %{redirect_url}\n" \
--max-time 20 -X POST "$BASE/login" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "_csrf=$CSRF" \
--data-urlencode "username=$U" \
--data-urlencode "password=$PW"
echo " 会话 cookie:"
grep -v '^#' $J 2>/dev/null | awk -F'\t' '{if($6!="") print " "$6" (长度 "length($7)")"}'
# 3) 验证
curl -s -b $J -o /dev/null -w " 带 cookie 打 posts -> HTTP %{http_code}\n" \
--max-time 10 "$BASE/apis/api.console.halo.run/v1alpha1/posts?page=0&size=3"
'''.replace("__PW__", pw).replace("__U__", USER), timeout=300, quiet=True)
o = out[1] if isinstance(out, tuple) else str(out)
print(o)
if "posts -> HTTP 200" not in o:
print()
print(" ✗ 登录没成功。要么密码不对,要么流程还差一步。")
print(" 替代方案:在 Halo 后台建个「个人令牌」,我用 Bearer 发。")
return 1
print()
print("── 2. 看现有文章 ──")
out = run(r'''
curl -s -b /tmp/hc.txt --max-time 10 \
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
| python3 -c "
import json,sys
d=json.load(sys.stdin)
print(' 共', d.get('total'), '篇')
for p in (d.get('items') or []):
s=p.get('spec',{})
print(' -', repr(s.get('title')), '| publish=', s.get('publish'), '| name=', (p.get('metadata') or {}).get('name'))
"
''', timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
print()
print("── 3. 建文章 ──")
# 把 base64 的请求体写进服务器临时文件,再解码用
out = run("printf '%s' '%s' > /tmp/halo_body.b64 && "
"base64 -d /tmp/halo_body.b64 > /tmp/halo_body.json && "
"echo \" 请求体 $(wc -c < /tmp/halo_body.json) 字节\"" % (b64,),
timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
out = run(r'''
curl -s -b /tmp/hc.txt --max-time 30 \
-w "\n HTTP %{http_code}\n" \
-X POST "http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts" \
-H "Content-Type: application/json" \
--data-binary @/tmp/halo_body.json > /tmp/halo_post_resp.json 2>&1
tail -3 /tmp/halo_post_resp.json
echo " --- 响应前 400 字 ---"
head -c 400 /tmp/halo_post_resp.json
''', timeout=300, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())