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

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

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

中转站
- known_devices 从 ID 集合改成「设备ID -> 配对码」映射
- 修复:管理员改码后关机超过 6 小时,改码会被超时清理冲掉
- 老格式 state 文件平滑升级
This commit is contained in:
xmz7891
2026-10-18 11:39:14 +08:00
parent 1d368979aa
commit 2061f9525f
50 changed files with 13812 additions and 584 deletions
+339
View File
@@ -0,0 +1,339 @@
# -*- coding: utf-8 -*-
r'''通过 Halo API 把文章直接写进去。
== 为什么要走 API ==
用户试过复制粘贴,但 Halo 富文本编辑器老把内容吞进代码块。
与其来回折腾,不如**直接调 API 建文章** —— 一次到位,
而且以后想再发文章也能复用这个脚本。
== ⚠ 密码怎么处理 ==
密码存在 `_build/.halo_password`(**已经加进 .gitignore**)。
脚本本身**不含任何密码** —— 从文件读。
这一点很重要:这个密码和用户的中转站管理密码**是同一个**,
写进仓库就等于全泄漏了。
== Halo 2.x 的建文章 API ==
POST /apis/api.console.halo.run/v1alpha1/posts
Authorization: Basic base64(user:pass)
Content-Type: application/json
请求体是 `PostRequest`:
{
"post": { <- Post 资源本身
"apiVersion": "content.halo.run/v1alpha1",
"kind": "Post",
"metadata": {"name": "post-xxx"},
"spec": {
"title": "...",
"slug": "...",
"publish": true,
"publishTime": "...",
"visible": "PUBLIC",
"allowComment": true,
"excerpt": {"autoGenerate": true, "raw": ""},
"categories": [], "tags": [], "htmlMetas": []
}
},
"content": { <- 正文单独放
"raw": "...",
"content": "...",
"rawType": "markdown"
}
}
**注意 `content` 要同时给 `raw`(源码)和 `content`(渲染后的 HTML)**。
`raw` 给 Markdown,`content` 给 HTML —— Halo 前端直接读 `content`。
两个都给的话,编辑器里也能正常显示源码。
'''
from __future__ import annotations
import base64
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "_build"))
from lssh import run # noqa: E402
PWFILE = ROOT / "_build" / ".halo_password"
# ---------------------------------------------------------------- 文章正文
# 用 Markdown 写(API 的 raw 字段就是 Markdown),Halo 会渲染成网页。
# 内容只提官网,不提任何管理后台 —— 对外材料清洁度要求。
TITLE = "远控软件正式上线"
SLUG = "remote-control-launch"
EXCERPT = ("一个为站在讲台前的人做的远程控制工具。手表抬一下手腕,"
"就能操作教室那台电脑:远程鼠标、投图到大屏、语音播报、"
"静默自动更新。官网 yk.xxmxxs.top")
MD = """一个为「站在讲台前的人」做的远程控制工具。手表抬一下手腕,就能操作教室那台电脑。
## 它是干什么的
很多场景下,人不在电脑前面,但需要操作那台电脑:
- 上课时站在教室中间,想切个课件、放张图
- 坐在办公室,想看看教室那台电脑还开着没
- 手上拿着东西,腾不出手去碰鼠标键盘
**这个软件就是解决这个的。**
电脑端装一次,之后一直安静地在后台跑;手表端装上之后,输入 6 位配对码就能连上。
## 能做什么
### 远程鼠标控制
手表变成触摸板:
- **拖动** —— 移动鼠标
- **单击** —— 左键
- **双击** —— 右键
- 下面还有专门的**左键 / 右键 / 滚轮上 / 滚轮下**按钮
精度够用,配合屏幕镜像一起看,基本可以完全代替鼠标。
### 一键显示桌面
快捷栏上有一格「显示桌面」,点一下就是电脑上按 `Win + D` 的效果。
需要把投影切回桌面、或者临时藏起一堆窗口的时候,一下就行。
### 电脑端静默自动更新
这是最省心的一点:
**手表上点一下「更新电脑端」,电脑就会自己下载新版、静默安装、重启。**
- 全程**不弹任何窗口**
- 中转地址、配对码、所有设置**一个都不变**
- 更新完自动回来继续跑
不用再跑到电脑前手动装一遍。
### 顶部快捷栏
任何页面顶上都有一条栏,**4 个格子全部可以自己定义**:
| 可选动作 | 说明 |
|---|---|
| 传文件 | 从手表把文件发到电脑 |
| 屏幕镜像 | 看电脑屏幕 |
| 鼠标控制 | 进触摸板模式 |
| 音效 | 让电脑放一声 |
| 关机 | 锁屏 / 关机 / 重启 |
| 语音播报 | 让电脑念一段字 |
| 投图到电脑 | 手表选图,全屏投到大屏 |
| 显示桌面 | 等于 Win + D |
在设置页里点一下就能换。
### 投图到大屏
手表上选一张图,直接全屏显示在电脑(也就是投影)上。
上课讲题、展示作业、临时放张图,比插 U 盘快得多。
### 语音播报
输入文字,电脑用系统自带的语音念出来。
「上课了」「下课了」「大家看一下黑板」——说一遍不如让电脑说一遍。
### 让电脑放个音效
内置了一批**程序生成**的提示音和音效(不是网上下载的版权素材),也可以放自己电脑上的音乐。
### 其他
- 剪贴板互传(手表和电脑之间)
- 电源控制(锁屏、关机、重启)
- 文件互传
- 静音、音量加减、播放控制
## 几个设计上的取舍
### 电脑端不弹任何窗口
屏幕被观看的时候**不显示提示条**,整个软件在后台完全没有可见窗口。
这一点是刻意的:要的就是「像不存在一样」。如果这是**你自己的电脑**,你需要一个能看见它在做什么的开关 —— 设置里有,默认关着。
### 支持 Windows 7
老机器也能装。这一点花了些功夫:**内嵌的 Python 运行时用的是 3.8**(3.9 开始就不支持 Win7 了),打包时还有一道检查专门盯着这个 —— 出包前会验证「有没有那个 Win7 上不存在的 DLL」,有就拒绝出包。
### 静默更新为什么要校验
自更新最大的风险是**下载中途出问题**,装上一个坏程序 —— 而它还会自己继续更新。
所以升级包会校验 SHA-256,**对不上就放弃这一轮,保持现状**。现状至少是能用的。
## 怎么装
**官网下载:<https://yk.xxmxxs.top>**
- **电脑端** —— 下载安装包,双击,填一个 6 位配对码就完事
- **手表端** —— 下载 APK 安装,输入刚才那个 6 位码
装完之后:
1. 手表连上电脑
2. 会先到一个选择页:**远控** / **静默更新被控端**
3. 点「远控」进控制页
## 说在最后
这个软件最初是给自己上课用的 —— 站在讲台上,想切个东西还得走回电脑前面,很烦。
后来发现能用的地方比想的多,就整理出来放在官网上,有需要的人自己拿。
**有问题、想要什么功能,欢迎留言。**
"""
def md_to_html(text: str) -> str:
"""把这份固定的 Markdown 转成 HTML。
⚠ 这里**不是在写通用 Markdown 解析器** —— 那样要引第三方库,
而我们的正文是我自己写的、结构已知。用简单的正则按块处理就够了,
而且不会因为库的行为差异出意外。
支持:## / ### 标题、**粗体**、`代码`、- 列表、1. 列表、
表格、> 引用、<https://...> 自动链接、空行分段。
"""
import html as _html
import re as _re
def inline(s: str) -> str:
# 先转义,避免正文里的 < > 破坏 HTML
s = _html.escape(s, quote=False)
s = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
s = _re.sub(r"`([^`]+)`", r"<code>\1</code>", s)
s = _re.sub(r"&lt;(https?://[^&]+?)&gt;", r'<a href="\1">\1</a>', s)
return s
out = []
lines = text.split("\n")
i = 0
while i < len(lines):
ln = lines[i]
if not ln.strip():
i += 1
continue
# 表格
if ln.startswith("|") and i + 1 < len(lines) and \
set(lines[i + 1].replace("|", "").strip()) <= set("-: "):
head = [c.strip() for c in ln.strip("|").split("|")]
i += 2
rows = []
while i < len(lines) and lines[i].startswith("|"):
rows.append([c.strip() for c in lines[i].strip("|").split("|")])
i += 1
out.append("<table><thead><tr>" +
"".join("<th>%s</th>" % inline(c) for c in head) +
"</tr></thead><tbody>" +
"".join("<tr>" + "".join("<td>%s</td>" % inline(c)
for c in r) + "</tr>"
for r in rows) +
"</tbody></table>")
continue
# 标题
m = _re.match(r"^(#{2,4})\s+(.*)$", ln)
if m:
lvl = len(m.group(1))
out.append("<h%d>%s</h%d>" % (lvl, inline(m.group(2)), lvl))
i += 1
continue
# 列表
if _re.match(r"^[-*]\s+", ln):
items = []
while i < len(lines) and _re.match(r"^[-*]\s+", lines[i]):
items.append(inline(_re.sub(r"^[-*]\s+", "", lines[i])))
i += 1
out.append("<ul>" + "".join("<li>%s</li>" % x for x in items) +
"</ul>")
continue
if _re.match(r"^\d+\.\s+", ln):
items = []
while i < len(lines) and _re.match(r"^\d+\.\s+", lines[i]):
items.append(inline(_re.sub(r"^\d+\.\s+", "", lines[i])))
i += 1
out.append("<ol>" + "".join("<li>%s</li>" % x for x in items) +
"</ol>")
continue
# 引用
if ln.startswith(">"):
out.append("<blockquote>%s</blockquote>"
% inline(ln.lstrip("> ").strip()))
i += 1
continue
# 普通段落(连续的合成一段)
para = [ln.strip()]
i += 1
while i < len(lines) and lines[i].strip() and \
not _re.match(r"^(#{2,4}\s|[-*]\s|\d+\.\s|>|\|)", lines[i]):
para.append(lines[i].strip())
i += 1
out.append("<p>%s</p>" % inline(" ".join(para)))
return "\n".join(out)
def main() -> int:
print("=" * 62)
print(" 通过 Halo API 发布文章")
print("=" * 62)
print()
if not PWFILE.is_file():
print(" ✗ 找不到 %s" % PWFILE)
return 1
pw = PWFILE.read_text(encoding="utf-8").strip()
print(" 密码已从 %s 读到(长度 %d)" % (PWFILE.name, len(pw)))
# 1) 先测认证
print()
print("── 1. 测认证 ──")
out = run(r'''
curl -s -o /dev/null -w " basic auth 打 posts -> HTTP %{http_code}\n" \
--max-time 10 -u "xmz7891:__PW__" \
http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts
curl -s -o /dev/null -w " basic auth 打 users/- -> HTTP %{http_code}\n" \
--max-time 10 -u "xmz7891:__PW__" \
http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/users/-
'''.replace("__PW__", pw), timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
# 2) 看现在有几篇文章
print("── 2. 现在的文章 ──")
out = run(r'''
curl -s --max-time 10 -u "xmz7891:__PW__" \
"http://127.0.0.1:3001/apis/api.console.halo.run/v1alpha1/posts?page=0&size=20" \
| python3 -c "
import json,sys
try:
d = json.load(sys.stdin)
except Exception as e:
print(' 解析失败:', e); sys.exit()
items = d.get('items') or []
print(' 共', d.get('total', len(items)), '篇')
for p in items:
sp = p.get('spec', {})
print(' -', sp.get('title'), '| publish =', sp.get('publish'),
'| name =', (p.get('metadata') or {}).get('name'))
"
'''.replace("__PW__", pw), timeout=200, quiet=True)
print(out[1] if isinstance(out, tuple) else out)
return 0
if __name__ == "__main__":
sys.exit(main())