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