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
+1 -1
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""局域网遥控 · Windows 被控端。"""
__version__ = "2.2.0"
__version__ = "2.3.0"
APP_NAME = "ykxmz"
+90
View File
@@ -390,6 +390,7 @@ class Features:
add("site_list", "看屏蔽列表", "action", "教室")
add("site_clear", "清空屏蔽列表", "action", "教室")
add("kill_games", "结束游戏进程", "action", "教室")
add("kill_all", "结束所有进程", "action", "系统")
add("kill_by_name", "按名字结束进程", "input", "教室",
hint="进程名,如 game.exe", key="name")
add("bell", "下课铃", "action", "教室")
@@ -2978,6 +2979,95 @@ class Features:
raise ValueError("进程名为空")
return self.actions.kill_process(0, name)
# ---------------- 一键结束所有进程(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))
# ---------------- 课堂 ----------------
def _do_bell(self, params) -> str:
+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:
"""程序自己的数据目录:配对码、设备令牌都在这儿。
+7 -4
View File
@@ -10,8 +10,11 @@
VSVersionInfo(
ffi=FixedFileInfo(
filevers=(1, 3, 0, 0),
prodvers=(1, 3, 0, 0),
# ⚠ 这四个数字以前一直是 (1, 3, 0, 0) —— 老早就没跟着版本走了。
# 用户右键 exe 看「详细信息」时,看到的"文件版本"是这里的 FixedFileInfo,
# 不是下面 StringFileInfo 里那个字符串。两边不一致会让人困惑。
filevers=(2, 3, 0, 0),
prodvers=(2, 3, 0, 0),
mask=0x3f,
flags=0x0,
OS=0x40004,
@@ -27,12 +30,12 @@ VSVersionInfo(
[
StringStruct("CompanyName", "ykxmz"),
StringStruct("FileDescription", "ykxmz"),
StringStruct("FileVersion", "2.2.0.0"),
StringStruct("FileVersion", "2.3.0.0"),
StringStruct("InternalName", "ykxmz"),
StringStruct("LegalCopyright", "ykxmz"),
StringStruct("OriginalFilename", "ykxmz.exe"),
StringStruct("ProductName", "ykxmz"),
StringStruct("ProductVersion", "2.2.0.0"),
StringStruct("ProductVersion", "2.3.0.0"),
],
)
]