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
+117
View File
@@ -683,7 +683,11 @@ class RemoteServer:
if cmd == P.CMD_FILE_LIST:
return self._file_list()
if cmd == "media_list":
return self._media_list()
if cmd == "media_pull":
return self._media_pull(session, str(msg.get("path") or ""))
if cmd == P.CMD_FILE_PULL:
return self._file_pull(session, str(msg.get("path") or ""))
@@ -1053,6 +1057,119 @@ class RemoteServer:
raise ActionError("发送文件失败:连接已断开")
return {"ok": True, "name": target.name, "size": len(blob)}
# ---------------- 播放器数据源(2.3.0)----------------
#
# 手表上的播放器要"能看进度、能左右划 seek" —— 那就必须手表自己解码播放。
# 所以电脑端负责两件事:**告诉它有哪些媒体** + **把文件发过去**。
# 允许扫描的媒体目录(相对用户主目录)。
MEDIA_SUBDIRS = ("Music", "Videos", "Downloads", "Desktop",
"音乐", "视频", "下载", "桌面")
AUDIO_EXT = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg",
".wma", ".amr", ".opus", ".ape"}
VIDEO_EXT = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv",
".webm", ".3gp", ".m4v", ".ts", ".rmvb"}
MEDIA_LIST_LIMIT = 300
def _media_roots(self) -> list:
"""允许访问的根目录(都 resolve 成绝对路径)。"""
roots = []
home = Path.home()
for sub in self.MEDIA_SUBDIRS:
p = home / sub
try:
if p.is_dir():
roots.append(p.resolve())
except OSError:
continue
try:
roots.append(Path(self.store.received_dir()).resolve())
except Exception: # noqa: BLE001
pass
return roots
def _media_list(self) -> dict:
roots = self._media_roots()
files = []
for root in roots:
try:
entries = list(root.iterdir())
except OSError:
continue
for p in entries:
try:
if not p.is_file():
continue
ext = p.suffix.lower()
if ext in self.AUDIO_EXT:
kind = "audio"
elif ext in self.VIDEO_EXT:
kind = "video"
else:
continue
files.append({
"name": p.name,
"path": str(p),
"size": p.stat().st_size,
"kind": kind,
"mtime": p.stat().st_mtime,
})
except OSError:
continue
# 新的排前面
files.sort(key=lambda f: f.get("mtime", 0), reverse=True)
total = len(files)
shown = files[:self.MEDIA_LIST_LIMIT]
return {
"ok": True,
"files": shown,
"total": total,
"shown": len(shown),
"truncated": total > len(shown),
"roots": [str(r) for r in roots],
}
def _media_pull(self, session: "Session", path: str) -> dict:
"""把一个媒体文件发给手表。
⚠ **安全边界**:只允许媒体根目录里的文件。
不能用 `str(p).startswith(root)` 这种判断 —— 那样
「音乐-evil」这种**同前缀的兄弟目录**会被误判成子目录。
必须用 `relative_to`,它在路径不真的位于该目录下时会抛 ValueError。
"""
wanted = Path(str(path or "")).expanduser()
try:
wanted = wanted.resolve()
except (OSError, ValueError) as exc:
raise ActionError(f"路径不合法: {exc}") from exc
allowed = False
for root in self._media_roots():
try:
wanted.relative_to(root)
allowed = True
break
except ValueError:
continue
if not allowed:
raise ActionError("这个文件不在允许的媒体目录里")
if not wanted.is_file():
raise ActionError(f"文件不存在: {wanted.name}")
size = wanted.stat().st_size
if size <= 0:
raise ActionError("文件是空的")
if size > 512 * 1024 * 1024:
raise ActionError("文件太大(超过 512MB),播放器放不下")
blob = wanted.read_bytes()
if not session.send_chunks(P.EVT_FILE, wanted.name, blob):
raise ActionError("发送失败:连接已断开")
return {"ok": True, "name": wanted.name, "size": len(blob)}
def _is_sensitive(self, path: Path) -> bool:
"""程序自己的数据目录:配对码、设备令牌都在这儿。