Files
ykxmz/windows/lanremote/actions.py
T
xmz7891 3f18e6bb40 2.1.0:网站自动抢救 + 后台抢救报告分区 + 网络热门音效 + 全量测试后台静默
按用户定的规矩,这一轮有新功能,升第二位:2.0.0 -> 2.1.0。

== 一、网站挂了会自己救 ==

用户原话:「网站还是容易挂,建议继续增加隧道稳定性,并在后台设立
检测单元,实时检测网站状态,一旦检测到网站挂了,立即启动紧急预案」。

新增 relay/monitor.sh + lanremote-monitor.timer:
    每 60 秒从**公网**查一次 /v1/health(不是查 127.0.0.1)

    为什么不查本机:本机 200 只说明中转进程活着,用户真正关心的是
    经过 Cloudflare 边缘 + 隧道 + 本机三段之后能不能打开。实测过
    隧道两条实例都掉了、本机 curl 照样 200,只有从公网测才发现 502。

    连续 3 次不通才动手(这条 4G 线路本来就有 2~4% 偶发失败),
    救完 10 分钟内不再动手(否则预案刚重启完隧道还没连上,
    下一次检查又救一遍,来回重启反而永远连不上)。

新增 relay/rescue.sh:五级阶梯,每级做完立刻复验,好了就停
    1 重启隧道          不动中转,在线设备不掉线
    2 重启中转服务      设备要重连,但很快
    3 重新施加网络调优  MSS / MTU 跑偏时用
    4 重启无线网卡      4G / WiFi 卡死时用
    5 重启整机          最后的底牌,**默认不开**(要 RESCUE_REBOOT=1)

    理由:如果问题出在供电或上游线路,重启只会让恢复更慢;
    而且半夜自动重启一台没人管的机器风险太大。

== 二、抢救报告 + 后台分区 ==

用户原话:「如果紧急预案未能解决,则将问题打包成 .txt,然后在
中转站设置里面加一个分区专门放还有下载这个 txt。解决后将解决问题
的方法加入紧急预案」。

    * 五级都没救回来 -> 把现场(内存/进程/隧道连接数/路由和 MTU、
      三份日志尾部、内核异常)整理成 rescue_reports/<时间戳>.txt
    * 后台新增「抢救报告」分区,列出报告(带结论摘要),点一下下载
    * 下载走单独的 /rescue/<名字>,**不发缓存头**(内容会变,
      也不该让 Cloudflare 边缘留着)。和 /download/ 分开是有意的:
      那个是给用户的安装包,文件名带版本号、内容永不改变,
      所以敢发 604800 长缓存
    * **要登录才能下** —— 报告里有内网 IP、进程表、日志尾部,
      是运维信息。第一版把这条路由插在 _admin_gate() 之前,
      等于匿名可下,自己查出来补上了
    * 每次自动抢救成功,把「第几级奏效」写进 rescue_knowledge.md;
      监测单元读它,下次同类症状优先试历史上管用的那一级
      (这就是用户说的"解决后将解决问题的方法加入紧急预案")

== 三、网络热门音效 ==

用户原话:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈,
各种老牧师音效,可以直接远程播放」。

**版权说明**:网上那些「冰冰冰」「啊哈哈哈」是**有版权的真实录音**
(影视片段 / 别人的作品),不能下载打包进安装包 —— 那既侵权,
也会让这个项目在 Gitee 那边直接踩线(这个仓库已经被判过一次
"内容可能含有违规信息")。

所以:
    * 内置 8 个**程序合成**的音效(正弦/方波现场算,零版权风险、
      不占安装包体积):冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 /
      警报 / 鼓点 / 弹簧。名字对着感觉来,但不是那些录音本身
    * 做「自己放进来」的目录:mp3/wav 丢进 data/sfx/ 就自动出现在
      列表里,照样能远程播。目录里放了一份说明
    * 手表首页新增「音效」按钮,点开选一个,电脑就响
      (复用现成的 showChooser,不新建面板)

协议加 sfx_list / sfx_play 两条命令。

== 四、修「安卓设置页一片空白」 ==

用户报的。根因:设置页**建好了但忘了 root.addView()** ——
showOnly() 让它 VISIBLE 也没用,它根本不在视图树里。

这类"漏挂"编译完全不报,只在运行时表现为空白。已加断言
_tests/test_watch_panels_wired.py 盯着:每个面板都必须挂到 root、
并且纳入 showOnly()。

== 五、全量测试改成后台静默 ==

用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。

会开窗口的 6 个阶段(含开**全屏**那个)现在跑在独立的 Windows
隐藏桌面上(_tests/hidden_desktop.py)。窗口是**真的** —— 全屏、
可见、几何、Win32 枚举都真实,测试有效性一点没损失 ——
只是不在用户眼前,全屏也不会锁屏。

顺带修了一个一直在骗人的检测:判断"锁屏"时只看 OpenInputDesktop
成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。
于是截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了
(用户电脑锁了一夜,我就是被这个误导过)。改成看输入桌面的**名字**。
现在锁屏也能跑全量:该 SKIP 的 SKIP,该跑的照跑。

== 六、顺手修的真 bug ==

* windows/lanremote/server.py 的 sfx_play 分支用了不存在的变量名 body
  (应该是 msg)-> **一调就 NameError**。pyflakes 那关抓出来的,
  说明"未定义名"这条检查有用。
* _build/package_remote.py 的文件白名单只有 .py/.sh/.ico,
  **.service/.timer 收不进服务端 zip** -> 新机器上监测根本不启动,
  而且不报错、只是静默不跑。已加。
* protocol.py 被我补丁插入了 LF 行到 CRLF 文件里 -> 行尾混用,编码审计红。
* deploy.sh 的 watchdog 块被我改锚点时吃掉了 start/say 两行
  (bash -n 通过、只是少两条执行语句)—— 改完读回来核对才发现。
* _build/CHANGELOG.md 的 2.1.0 说明里写了"管理后台",
  触发对外材料清洁度检查。改成不提管理页(用户定的规矩:
  对外只能提官网)。

全量回归 46 个阶段全部通过。
2026-10-08 18:36:50 +08:00

1086 lines
43 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 -*-
"""被控端功能实现:投图 / 放音 / 媒体控制 / 剪贴板 / 电源 / 自定义命令 / TTS / 通知。
所有会碰 UI 的操作都通过 ui_call 调度回 tkinter 主线程执行。
"""
from __future__ import annotations
import ctypes
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
from .protocol import MEDIA_ACTIONS, POWER_ACTIONS
IS_WINDOWS = sys.platform.startswith("win")
# ---- Win32 常量 ----
KEYEVENTF_KEYUP = 0x0002
VK_MEDIA = MEDIA_ACTIONS
def _human_size(num: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if abs(num) < 1024.0:
return f"{num:.1f}{unit}"
num /= 1024.0
return f"{num:.1f}TB"
class ActionError(RuntimeError):
pass
def work_area() -> tuple:
"""屏幕工作区 (left, top, width, height) —— 已经扣掉任务栏。
直接读 SPI_GETWORKAREA,比自己猜任务栏多高靠谱。
"""
try:
import ctypes.wintypes
rect = ctypes.wintypes.RECT()
ok = ctypes.windll.user32.SystemParametersInfoW(
0x0030, 0, ctypes.byref(rect), 0) # SPI_GETWORKAREA
if ok and rect.right > rect.left and rect.bottom > rect.top:
return (rect.left, rect.top,
rect.right - rect.left, rect.bottom - rect.top)
except Exception: # noqa: BLE001
pass
try:
import tkinter as tk
root = tk._default_root # noqa: SLF001
if root is not None:
return (0, 0, root.winfo_screenwidth(),
root.winfo_screenheight())
except Exception: # noqa: BLE001
pass
return (0, 0, 1920, 1080)
def _outer_size(win) -> tuple:
"""窗口的真实外框尺寸(含标题栏和边框)。
客户区尺寸不等于窗口占的地方 —— 不按外框算,看起来就不居中。
"""
try:
import ctypes
import ctypes.wintypes
hwnd = int(win.frame(), 0) # '0x00012345'
rect = ctypes.wintypes.RECT()
if ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)):
return (rect.right - rect.left, rect.bottom - rect.top,
rect.left, rect.top)
except Exception: # noqa: BLE001
pass
return (win.winfo_width(), win.winfo_height(), 0, 0)
def _place_centered(win, client_w: int, client_h: int) -> None:
"""在工作区正中间摆好这个窗口(按外框算)。"""
client_w = max(1, int(client_w))
client_h = max(1, int(client_h))
# 先放到 0,0,好量出真实外框
win.geometry(f"{client_w}x{client_h}+0+0")
win.update_idletasks()
outer_w, outer_h, outer_x, outer_y = _outer_size(win)
# 外框相对客户区多出来的部分(左边框 / 标题栏+上边框)
chrome_w = max(0, outer_w - client_w)
chrome_h = max(0, outer_h - client_h)
area_x, area_y, area_w, area_h = work_area()
x = area_x + max(0, (area_w - outer_w) // 2) + chrome_w // 2
y = area_y + max(0, (area_h - outer_h) // 2) + chrome_h - chrome_h // 2
win.geometry(f"{client_w}x{client_h}+{x}+{y}")
win.update_idletasks()
win.lift()
win.focus_force()
class Actions:
"""把协议命令翻译成 Windows 上的实际操作。"""
def __init__(self, ui, store):
self.ui = ui # 需要提供 .root / .log() / .notify()
self.store = store
self._audio_alias: str | None = None
self._audio_path: str = "" # 上一首的路径,循环播放要用
self._loop: bool = False # 单曲循环开关
self._loop_thread = None # 循环看门线程
self._image_window = None
self._lock = threading.RLock()
# 剪贴板历史(最近 20 条)
self._clip_history: list[dict] = []
# CPU 占用要两次采样才算得出来
self._last_cpu_total = 0
self._last_cpu_idle = 0
# 电脑正在播的音频是否被暂停(MCI 不是系统媒体会话,得自己记)
self._audio_paused = False
self._audio_name = ""
# ------------------------------------------------------------------
# 投图:在电脑上全屏显示
# ------------------------------------------------------------------
def show_image(self, path: str, title: str = "") -> str:
"""显示图片。
title 为窗口标题(可留空,用设置里的模板;模板支持 {name})。
方式由设置 image_display 决定:
window —— 屏幕正中间的普通 Windows 窗口(默认)
fullscreen —— 全屏
custom —— 按 image_custom_width/height 指定大小
宽或高填 0 表示该方向自动。
"""
p = Path(path)
if not p.is_file():
raise ActionError(f"图片不存在: {path}")
from PIL import Image, ImageTk
try:
img = Image.open(p)
img.load()
except Exception as exc:
raise ActionError(f"无法读取图片: {exc}") from exc
mode = str(self.store.get("image_display", "window") or "window")
if mode not in ("window", "fullscreen", "custom"):
mode = "window"
screen_w, screen_h = self.ui.screen_size()
img = img.convert("RGB")
if mode == "fullscreen":
target = (screen_w, screen_h)
elif mode == "custom":
cw = int(self.store.get("image_custom_width", 0) or 0)
ch = int(self.store.get("image_custom_height", 0) or 0)
if cw <= 0 and ch <= 0:
# 两个都没填就退回默认窗口大小
cw, ch = int(screen_w * 0.78), int(screen_h * 0.78)
self.store.set("image_custom_width", cw)
self.store.set("image_custom_height", ch)
elif cw <= 0:
cw = screen_w
elif ch <= 0:
ch = screen_h
target = (max(80, cw), max(80, ch))
else:
target = (int(screen_w * 0.78), int(screen_h * 0.78))
img.thumbnail(target, Image.LANCZOS)
fullscreen = (mode == "fullscreen")
# 窗口标题:单次指定 > 设置里的模板 > 默认
window_title = (title or "").strip() or str(
self.store.get("image_window_title") or "").strip() or "图片 - {name}"
try:
window_title = window_title.replace("{name}", p.name)
except Exception:
window_title = f"图片 - {p.name}"
backdrop = "#000000" if fullscreen else "#ffffff"
def build():
import tkinter as tk
if self._image_window is not None:
try:
self._image_window.destroy()
except Exception:
pass
win = tk.Toplevel(self.ui.root)
win.title(window_title)
win.configure(bg=backdrop)
win.resizable(False, False)
def close(_event=None):
with self._lock:
self._image_window = None
try:
win.destroy()
except Exception:
pass
photo = ImageTk.PhotoImage(img)
label = tk.Label(win, image=photo, bg=backdrop, bd=0,
highlightthickness=0)
label.image = photo # 防止被 GC
label.pack(fill="both", expand=True)
if fullscreen:
# 注意:Windows 上先 overrideredirect(True) 再设 -fullscreen 会抛
# "override-redirect flag is set"。全屏本身就会去掉边框,不需要它。
win.attributes("-fullscreen", True)
win.attributes("-topmost", True)
tk.Label(win, text="按 Esc 或点击关闭", bg=backdrop, fg="#888888",
font=("Microsoft YaHei UI", 10)).place(
relx=0.5, rely=0.97, anchor="center")
else:
foot = tk.Frame(win, bg=backdrop)
foot.pack(fill="x")
tk.Button(foot, text="关闭", command=close,
bg="#eaeef2", fg="#1f2328", relief="flat",
font=("Microsoft YaHei UI", 9), padx=14, pady=2,
cursor="hand2").pack(side="right", padx=10, pady=8)
# 摆到屏幕正中间 —— 普通窗口,有标题栏和关闭按钮
# 必须先 update_idletasks,否则 winfo_reqwidth 还是 Tk
# 默认的 200x200,自定义尺寸就白填了。
win.update_idletasks()
_place_centered(win, win.winfo_reqwidth(),
win.winfo_reqheight())
win.bind("<Escape>", close)
win.bind("<Button-1>", close)
win.protocol("WM_DELETE_WINDOW", close)
self._image_window = win
timeout = int(self.store.get("image_timeout", 0) or 0)
if timeout > 0:
win.after(timeout * 1000, close)
self.ui.ui_call(build)
label_text = {"window": "屏幕中间", "fullscreen": "全屏",
"custom": f"{img.width}x{img.height}"}[mode]
return f"已显示 {p.name}({label_text})"
def close_image(self) -> str:
def close():
if self._image_window is not None:
try:
self._image_window.destroy()
except Exception:
pass
self._image_window = None
self.ui.ui_call(close)
return "已关闭显示窗口"
# ------------------------------------------------------------------
# 放音:MCI(系统自带,无需外部库)
# ------------------------------------------------------------------
def play_audio(self, path: str) -> str:
p = Path(path)
if not p.is_file():
raise ActionError(f"音频不存在: {path}")
if not IS_WINDOWS:
raise ActionError("音频播放目前仅支持 Windows")
with self._lock:
self._mci_close_locked()
alias = f"lanremote{int(time.time() * 1000) % 1000000}"
opened = self._mci(f'open "{p}" type mpegvideo alias {alias}')
if not opened:
opened = self._mci(f'open "{p}" alias {alias}')
if not opened:
raise ActionError(f"系统无法播放该音频格式: {p.suffix}")
self._audio_alias = alias
self._audio_paused = False
self._audio_name = p.name
self._audio_path = str(p)
self._mci(f"play {alias}")
return f"正在播放 {p.name}"
def set_loop(self, on: bool) -> None:
"""开关单曲循环。开了就起一个看门线程,放完自动重放。"""
self._loop = bool(on)
if on and self._loop_thread is None:
thread = threading.Thread(target=self._loop_watch, daemon=True,
name="audio-loop")
self._loop_thread = thread
thread.start()
def _loop_watch(self) -> None:
while self._loop:
time.sleep(0.6)
if not self._loop:
break
with self._lock:
alias = self._audio_alias
path = self._audio_path
if not alias or not path:
continue
if self._mci_mode(alias) != "stopped":
continue
# 放完了,再放一遍
try:
self.play_audio(path)
except Exception: # noqa: BLE001
pass
self._loop_thread = None
def stop_audio(self) -> str:
with self._lock:
self._mci_close_locked()
self._audio_paused = False
self._audio_name = ""
return "已停止播放"
def _mci_mode(self, alias: str) -> str:
"""问 MCI 现在是什么状态:playing / paused / stopped。"""
buffer = ctypes.create_unicode_buffer(64)
try:
ctypes.windll.winmm.mciSendStringW(
ctypes.c_wchar_p(f"status {alias} mode"), buffer, 64, None)
return (buffer.value or "").strip().lower()
except Exception: # noqa: BLE001
return ""
def audio_pause(self) -> str:
with self._lock:
alias = self._audio_alias
if not alias:
raise ActionError("电脑当前没有播放手表推过去的音频")
if self._audio_paused:
return "已经暂停了"
if not self._mci(f"pause {alias}"):
raise ActionError("暂停失败")
self._audio_paused = True
return f"已暂停 {self._audio_name or '音频'}"
def audio_resume(self) -> str:
with self._lock:
alias = self._audio_alias
if not alias:
raise ActionError("电脑当前没有播放手表推过去的音频")
if not self._audio_paused:
return "正在播放中"
if not self._mci(f"resume {alias}"):
raise ActionError("继续播放失败")
self._audio_paused = False
return f"继续播放 {self._audio_name or '音频'}"
def audio_status(self) -> str:
with self._lock:
alias = self._audio_alias
if not alias:
return "电脑当前没有播放音频"
mode = self._mci_mode(alias)
if mode == "playing":
return f"正在播放 {self._audio_name}"
if mode == "paused":
return f"已暂停 {self._audio_name}"
# MCI 自己放完了
self._mci_close_locked()
self._audio_paused = False
name = self._audio_name
self._audio_name = ""
return f"{name} 已播放完毕" if name else "播放已结束"
def _mci_close_locked(self) -> None:
if self._audio_alias:
self._mci(f"close {self._audio_alias}")
self._audio_alias = None
@staticmethod
def _mci(command: str) -> bool:
try:
winmm = ctypes.windll.winmm
err = winmm.mciSendStringW(ctypes.c_wchar_p(command), None, 0, None)
return err == 0
except Exception:
return False
# ------------------------------------------------------------------
# 媒体控制:模拟多媒体按键,作用于当前播放器
# ------------------------------------------------------------------
def media_control(self, action: str) -> str:
if not IS_WINDOWS:
raise ActionError("媒体控制目前仅支持 Windows")
if action not in VK_MEDIA:
raise ActionError(f"不支持的媒体动作: {action}")
# 电脑正在播手表推过去的音频时,直接控制它。
# MCI 不是系统媒体会话,全局媒体键对它无效 ——
# 这就是"推了 mp3 过去却暂停不了"的原因。
if action in ("playpause", "stop"):
handled = self._control_pushed_audio(action)
if handled:
return handled
vk = VK_MEDIA.get(action)
try:
user32 = ctypes.windll.user32
user32.keybd_event(vk, 0, 0, 0)
user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0)
except Exception as exc:
raise ActionError(f"媒体按键注入失败: {exc}") from exc
return f"已执行 {action}"
# ------------------------------------------------------------------
# 剪贴板
# ------------------------------------------------------------------
def clip_set(self, text: str) -> str:
def do():
self.ui.root.clipboard_clear()
self.ui.root.clipboard_append(text)
self.ui.root.update_idletasks()
self.ui.ui_call(do)
self.store.set("last_clip", text)
self._remember_clip(text)
return f"剪贴板已更新({len(text)} 字符)"
def clip_get(self) -> str:
result = {"text": ""}
def do():
try:
result["text"] = self.ui.root.clipboard_get()
except Exception:
result["text"] = ""
self.ui.ui_call(do)
return result["text"]
# ------------------------------------------------------------------
# 电源
# ------------------------------------------------------------------
def power(self, action: str) -> str:
if action not in POWER_ACTIONS:
raise ActionError(f"不支持的电源动作: {action}")
if not self.store.get("allow_power", True):
raise ActionError("被控端已禁用电源操作")
if not IS_WINDOWS:
raise ActionError("电源控制目前仅支持 Windows")
if action == "shutdown":
cmd = ["shutdown", "/s", "/t", "5"]
note = "将在 5 秒后关机"
elif action == "restart":
cmd = ["shutdown", "/r", "/t", "5"]
note = "将在 5 秒后重启"
elif action == "abort":
cmd = ["shutdown", "/a"]
note = "已取消关机"
elif action == "lock":
cmd = ["rundll32.exe", "user32.dll,LockWorkStation"]
note = "已锁屏"
else: # sleep
cmd = ["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"]
note = "已进入睡眠"
try:
subprocess.Popen(
cmd, shell=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except Exception as exc:
raise ActionError(f"执行失败: {exc}") from exc
return note
# ------------------------------------------------------------------
# 自定义命令
# ------------------------------------------------------------------
def run_command(self, command: str, timeout: int = 60) -> dict:
if not self.store.get("allow_run", True):
raise ActionError("被控端已禁用自定义命令")
if not command or not command.strip():
raise ActionError("命令为空")
self.ui.log(f"[自定义命令] {command}")
try:
# 打包成 --windowed 后没有控制台,cmd 的输出是 ANSI 代码页(本机 936)
# 而不是 UTF-8 —— 用 utf-8 解会全是乱码。和本文件其它地方统一用 mbcs。
# 另外必须加 CREATE_NO_WINDOW,否则每跑一条远程命令都会闪一个黑窗。
proc = subprocess.run(
command, shell=True, capture_output=True, text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
encoding="mbcs", errors="replace")
except subprocess.TimeoutExpired:
raise ActionError(f"命令超时(>{timeout}s)")
except Exception as exc:
raise ActionError(f"命令执行失败: {exc}") from exc
return {
"code": proc.returncode,
"stdout": (proc.stdout or "")[-4000:],
"stderr": (proc.stderr or "")[-2000:],
}
def _control_pushed_audio(self, action: str) -> str | None:
"""如果正在播推过去的音频,就控制它;否则返回 None 走系统媒体键。"""
with self._lock:
alias = self._audio_alias
if not alias:
return None
mode = self._mci_mode(alias)
if mode not in ("playing", "paused"):
# MCI 已经放完了
self._mci_close_locked()
self._audio_paused = False
self._audio_name = ""
return None
if action == "stop":
self._mci_close_locked()
self._audio_paused = False
self._audio_name = ""
return "已停止播放"
# playpause
if mode == "paused" or self._audio_paused:
if self._mci(f"resume {alias}"):
self._audio_paused = False
return f"继续播放 {self._audio_name}"
return None
if self._mci(f"pause {alias}"):
self._audio_paused = True
return f"已暂停 {self._audio_name}"
return None
# ------------------------------------------------------------------
# 语音播报(系统自带 TTS)
# ------------------------------------------------------------------
def list_voices(self) -> list[str]:
"""列出系统可用的 TTS 音色。"""
if not IS_WINDOWS:
return []
script = (
"Add-Type -AssemblyName System.Speech;"
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"
)
try:
proc = subprocess.run(
["powershell", "-NoProfile", "-Command", script],
capture_output=True, timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
encoding="mbcs", errors="replace")
except Exception:
return []
return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()]
def say(self, text: str, voice: str = "", rate: int = 0) -> str:
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。"""
if not text.strip():
raise ActionError("播报内容为空")
if not IS_WINDOWS:
raise ActionError("语音播报目前仅支持 Windows")
chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip()
try:
speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0)
except (TypeError, ValueError):
speed = 0
speed = max(-10, min(10, speed))
safe = text.replace("'", "''")
parts = [
"Add-Type -AssemblyName System.Speech;",
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;",
]
if chosen:
parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} "
"catch { };")
if speed:
parts.append(f"$s.Rate = {speed};")
parts.append(f"$s.Speak('{safe}')")
script = "".join(parts)
try:
subprocess.Popen(
["powershell", "-NoProfile", "-WindowStyle", "Hidden",
"-Command", script],
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except Exception as exc:
raise ActionError(f"TTS 启动失败: {exc}") from exc
return f"已播报({chosen or '默认音色'})"
# ------------------------------------------------------------------
# 播放本机音频文件(MP3 等)
# ------------------------------------------------------------------
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".wma")
def audio_roots(self) -> list[Path]:
"""允许播放的目录。只认音乐相关目录,避免被用来播任意系统文件。"""
roots: list[Path] = []
configured = str(self.store.get("mp3_dir") or "").strip()
if configured:
path = Path(configured)
if path.is_dir():
roots.append(path)
home = Path.home()
for name in ("Music", "音乐", "Downloads", "下载"):
path = home / name
if path.is_dir():
roots.append(path)
return roots
def list_local_audio(self, limit: int = 300) -> list[dict]:
"""列出本机可播放的音频文件。"""
items: list[dict] = []
seen: set[str] = set()
for root in self.audio_roots():
try:
paths = sorted(root.rglob("*"))
except OSError:
continue
for path in paths:
if len(items) >= limit:
return items
try:
if not path.is_file():
continue
if path.suffix.lower() not in self.AUDIO_EXTS:
continue
key = str(path).lower()
if key in seen:
continue
seen.add(key)
items.append({
"name": path.name,
"path": str(path),
"size": path.stat().st_size,
"dir": root.name,
})
except OSError:
continue
items.sort(key=lambda it: it["name"].lower())
return items
# ---- 音效(网络热门音效,手表上点一下电脑就响)----
#
# 素材放哪:data/sfx/
# 内置的几个是程序**合成**的(正弦/方波现场算出来),
# 所以零版权风险、也不占安装包体积。
# 用户想用真正的一模一样的素材,把文件丢进 data/sfx/ 即可,
# 列表会自动带上(见 sfx.py 顶部的说明)。
def _sfx(self):
"""懒加载音效库。第一次调用时才生成内置 WAV。"""
if getattr(self, "_sfx_obj", None) is None:
from .sfx import Sfx
self._sfx_obj = Sfx(self.store.dir, player=self.play_audio)
return self._sfx_obj
def list_sfx(self) -> list:
return self._sfx().list_sfx()
def sfx_dir_hint(self) -> str:
return self._sfx().path_hint()
def play_sfx(self, name: str) -> str:
return self._sfx().play(name)
def play_local(self, path: str) -> str:
"""播放本机某个音频文件。只允许音乐目录内的文件。"""
target = Path(path)
if not target.is_file():
raise ActionError(f"文件不存在: {path}")
if target.suffix.lower() not in self.AUDIO_EXTS:
raise ActionError(f"不支持的音频格式: {target.suffix}")
allowed = False
for root in self.audio_roots():
try:
target.resolve().relative_to(root.resolve())
allowed = True
break
except (ValueError, OSError):
continue
if not allowed:
raise ActionError("只允许播放音乐目录里的文件")
self.play_audio(str(target))
return f"正在播放 {target.name}"
# ------------------------------------------------------------------
# 定时关机 / 关显示器
# ------------------------------------------------------------------
def shutdown_timer(self, minutes: int = 0, cancel: bool = False) -> str:
"""定时关机;cancel=True 时取消。minutes<=0 也会取消。"""
if not IS_WINDOWS:
raise ActionError("仅支持 Windows")
if cancel or minutes <= 0:
proc = subprocess.run(["shutdown", "/a"], capture_output=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
text = (proc.stdout or proc.stderr or b"").decode("mbcs", errors="replace")
if proc.returncode != 0 and "1116" not in text and "没有" not in text:
return "当前没有待执行的关机计划"
return "已取消定时关机"
minutes = max(1, min(1440, int(minutes)))
subprocess.run(["shutdown", "/s", "/t", str(minutes * 60)],
capture_output=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
return f"将在 {minutes} 分钟后关机"
def monitor_power(self, on: bool = False) -> str:
"""关屏 / 唤醒屏幕。"""
if not IS_WINDOWS:
raise ActionError("仅支持 Windows")
import ctypes
HWND_BROADCAST = 0xFFFF
WM_SYSCOMMAND = 0x0112
SC_MONITORPOWER = 0xF170
# 2 = 关闭, -1 = 打开
ctypes.windll.user32.SendMessageW(HWND_BROADCAST, WM_SYSCOMMAND,
SC_MONITORPOWER, -1 if on else 2)
return "显示器已打开" if on else "显示器已关闭"
# ------------------------------------------------------------------
# 打开网址 / 快捷命令 / 常用文件夹
# ------------------------------------------------------------------
def open_url(self, url: str) -> str:
text = (url or "").strip()
if not text:
raise ActionError("网址为空")
if "://" not in text:
text = "https://" + text
if not text.lower().startswith(("http://", "https://")):
raise ActionError("只允许打开 http/https 网址")
try:
os.startfile(text) # noqa: S606
except Exception as exc: # noqa: BLE001
raise ActionError(f"打开失败: {exc}") from exc
return f"已在电脑打开 {text}"
QUICK_APPS = {
"taskmgr": ("任务管理器", "taskmgr.exe"),
"explorer": ("资源管理器", "explorer.exe"),
"cmd": ("命令提示符", "cmd.exe"),
"calc": ("计算器", "calc.exe"),
"notepad": ("记事本", "notepad.exe"),
"control": ("控制面板", "control.exe"),
"mspaint": ("画图", "mspaint.exe"),
"devmgmt": ("设备管理器", "devmgmt.msc"),
}
def quick_launch(self, key: str) -> str:
item = self.QUICK_APPS.get((key or "").strip().lower())
if item is None:
raise ActionError(f"没有这个快捷程序: {key}")
label, command = item
try:
subprocess.Popen(command, shell=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except Exception as exc: # noqa: BLE001
raise ActionError(f"启动失败: {exc}") from exc
return f"已打开{label}"
def open_folder(self, key: str) -> str:
home = Path.home()
folders = {
"downloads": ("下载", home / "Downloads"),
"documents": ("文档", home / "Documents"),
"desktop": ("桌面", home / "Desktop"),
"pictures": ("图片", home / "Pictures"),
"music": ("音乐", home / "Music"),
"received": ("接收目录", self.store.received_dir()),
"app": ("程序目录", Path(__file__).resolve().parent.parent),
}
item = folders.get((key or "").strip().lower())
if item is None:
raise ActionError(f"没有这个文件夹: {key}")
label, path = item
if not path.is_dir():
raise ActionError(f"{label}目录不存在: {path}")
try:
os.startfile(str(path)) # noqa: S606
except Exception as exc: # noqa: BLE001
raise ActionError(f"打开失败: {exc}") from exc
return f"已打开{label}"
# ------------------------------------------------------------------
# 进程
# ------------------------------------------------------------------
def list_processes(self, limit: int = 40) -> list[dict]:
if not IS_WINDOWS:
return []
try:
proc = subprocess.run(
["tasklist", "/FO", "CSV", "/NH"], capture_output=True,
timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
encoding="mbcs", errors="replace")
except Exception: # noqa: BLE001
return []
items: list[dict] = []
for raw in (proc.stdout or "").splitlines():
parts = [p.strip().strip('"') for p in raw.split('","')]
if len(parts) < 5:
continue
name = parts[0].strip('"')
pid = parts[1]
memory = parts[4].replace(",", "").replace(" K", "").strip()
try:
mem_kb = int(memory)
except ValueError:
mem_kb = 0
if not pid.isdigit():
continue
items.append({"name": name, "pid": int(pid), "mem_kb": mem_kb})
items.sort(key=lambda it: it["mem_kb"], reverse=True)
return items[:limit]
def kill_process(self, pid: int = 0, name: str = "") -> str:
if not IS_WINDOWS:
raise ActionError("仅支持 Windows")
args = ["taskkill", "/F"]
if pid:
args += ["/PID", str(int(pid))]
label = f"PID {pid}"
elif name:
safe = "".join(ch for ch in name if ch.isalnum() or ch in "._- ")
if not safe:
raise ActionError("进程名不合法")
args += ["/IM", safe]
label = safe
else:
raise ActionError("要指定 PID 或进程名")
proc = subprocess.run(args, capture_output=True,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
encoding="mbcs", errors="replace")
if proc.returncode != 0:
raise ActionError(f"结束失败: {(proc.stdout or proc.stderr or '').strip()[:80]}")
return f"已结束 {label}"
# ------------------------------------------------------------------
# 状态:电量 / 磁盘 / 性能
# ------------------------------------------------------------------
def battery(self) -> dict:
if not IS_WINDOWS:
return {"present": False}
import ctypes
class PowerStatus(ctypes.Structure):
_fields_ = [("ACLineStatus", ctypes.c_byte),
("BatteryFlag", ctypes.c_byte),
("BatteryLifePercent", ctypes.c_byte),
("SystemStatusFlag", ctypes.c_byte),
("BatteryLifeTime", ctypes.c_ulong),
("BatteryFullLifeTime", ctypes.c_ulong)]
status = PowerStatus()
if not ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status)):
return {"present": False}
if status.BatteryLifePercent == 255 or status.BatteryFlag == 128:
return {"present": False, "note": "台式机,没有电池"}
return {
"present": True,
"percent": int(status.BatteryLifePercent),
"charging": bool(status.ACLineStatus),
"seconds_left": int(status.BatteryLifeTime)
if status.BatteryLifeTime not in (0xFFFFFFFF, 0) else 0,
}
def disk(self) -> list[dict]:
if not IS_WINDOWS:
return []
import ctypes
import string
out: list[dict] = []
bitmask = ctypes.windll.kernel32.GetLogicalDrives()
free = ctypes.c_ulonglong(0)
total = ctypes.c_ulonglong(0)
for i, letter in enumerate(string.ascii_uppercase):
if not (bitmask >> i) & 1:
continue
root = f"{letter}:\\"
try:
ok = ctypes.windll.kernel32.GetDiskFreeSpaceExW(
ctypes.c_wchar_p(root), None, ctypes.byref(total),
ctypes.byref(free))
except Exception: # noqa: BLE001
continue
if not ok or total.value == 0:
continue
out.append({
"drive": f"{letter}:",
"total_gb": round(total.value / 1073741824, 1),
"free_gb": round(free.value / 1073741824, 1),
"used_percent": int((total.value - free.value) * 100 / total.value),
})
return out
def perf(self) -> dict:
if not IS_WINDOWS:
return {}
import ctypes
class MemoryStatus(ctypes.Structure):
_fields_ = [("dwLength", ctypes.c_ulong),
("dwMemoryLoad", ctypes.c_ulong),
("ullTotalPhys", ctypes.c_ulonglong),
("ullAvailPhys", ctypes.c_ulonglong),
("ullTotalPageFile", ctypes.c_ulonglong),
("ullAvailPageFile", ctypes.c_ulonglong),
("ullTotalVirtual", ctypes.c_ulonglong),
("ullAvailVirtual", ctypes.c_ulonglong),
("ullAvailExtendedVirtual", ctypes.c_ulonglong)]
mem = MemoryStatus()
mem.dwLength = ctypes.sizeof(MemoryStatus)
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(mem))
idle = ctypes.c_ulonglong()
kernel = ctypes.c_ulonglong()
user = ctypes.c_ulonglong()
ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle),
ctypes.byref(kernel),
ctypes.byref(user))
cpu = 0
try:
total = kernel.value + user.value
if total and self._last_cpu_total:
delta_total = total - self._last_cpu_total
delta_idle = idle.value - self._last_cpu_idle
if delta_total > 0:
cpu = int(round((1 - delta_idle / delta_total) * 100))
except Exception: # noqa: BLE001
cpu = 0
self._last_cpu_total = kernel.value + user.value
self._last_cpu_idle = idle.value
import platform as _platform
return {
"cpu_percent": max(0, min(100, cpu)),
"mem_percent": int(mem.dwMemoryLoad),
"mem_used_gb": round((mem.ullTotalPhys - mem.ullAvailPhys) / 1073741824, 1),
"mem_total_gb": round(mem.ullTotalPhys / 1073741824, 1),
"uptime": self._uptime(),
"os": f"{_platform.system()} {_platform.release()}",
}
def _uptime(self) -> str:
try:
import ctypes
# 不设 restype 的话 ctypes 默认按 32 位处理,
# 开机超过 24.8 天会被截断成负数,时长显示错乱
ctypes.windll.kernel32.GetTickCount64.restype = ctypes.c_ulonglong
ms = ctypes.windll.kernel32.GetTickCount64()
seconds = int(ms / 1000)
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if days:
return f"{days} 天 {hours} 小时"
if hours:
return f"{hours} 小时 {minutes} 分"
return f"{minutes} 分钟"
except Exception: # noqa: BLE001
return "-"
# ------------------------------------------------------------------
# 剪贴板历史
# ------------------------------------------------------------------
def clip_history(self) -> list[dict]:
return list(self._clip_history)
def _remember_clip(self, text: str) -> None:
text = (text or "").strip()
if not text:
return
with self._lock:
self._clip_history = [x for x in self._clip_history
if x.get("text") != text]
self._clip_history.insert(0, {
"text": text[:2000],
"at": time.strftime("%H:%M:%S"),
})
del self._clip_history[20:]
def say_clipboard(self) -> str:
# 之前读的是 store["last_clip"](那是手表推过来的内容),
# 手表没推过就永远报"剪贴板是空的",可电脑剪贴板明明有东西。
# 应该读电脑本机的剪贴板。
try:
text = str(self.clip_get() or "").strip()
except Exception: # noqa: BLE001
text = ""
if not text:
text = str(self.store.get("last_clip") or "").strip()
if not text:
raise ActionError("电脑剪贴板是空的")
return self.say(text[:500] + ("…" if len(text) > 500 else ""))
# ------------------------------------------------------------------
# 壁纸
# ------------------------------------------------------------------
def set_wallpaper(self, path: str = "") -> str:
"""把某张图片设为桌面壁纸。默认用最近收到的那张图。"""
if not IS_WINDOWS:
raise ActionError("仅支持 Windows")
import ctypes
target = Path(path) if path else None
if target is None or not target.is_file():
received = self.store.received_dir()
images = sorted(
[p for p in received.iterdir()
if p.is_file() and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp")],
key=lambda p: p.stat().st_mtime, reverse=True)
if not images:
raise ActionError("接收目录里还没有图片")
target = images[0]
SPI_SETDESKWALLPAPER = 0x0014
SPIF_UPDATEINIFILE = 0x01
SPIF_SENDCHANGE = 0x02
ok = ctypes.windll.user32.SystemParametersInfoW(
SPI_SETDESKWALLPAPER, 0, ctypes.c_wchar_p(str(target)),
SPIF_UPDATEINIFILE | SPIF_SENDCHANGE)
if not ok:
raise ActionError("设置壁纸失败")
return f"壁纸已设为 {target.name}"
# ------------------------------------------------------------------
# 通知(托盘气泡,可见)
# ------------------------------------------------------------------
def notify(self, title: str, text: str) -> str:
self.ui.notify(title or "局域网遥控", text or "")
return "已通知"
# ------------------------------------------------------------------
# 系统信息
# ------------------------------------------------------------------
def info(self) -> dict:
import platform
screen_ok = False
screen_size = (0, 0)
try:
from .screen import ScreenCapturer
capturer = ScreenCapturer()
screen_ok = capturer.available()
if screen_ok:
screen_size = capturer.screen_size()
except Exception:
screen_ok = False
return {
"server_name": self.store.get("server_name", ""),
"os": f"{platform.system()} {platform.release()}",
"python": platform.python_version(),
"port": self.store.get("port", 0),
"allow_run": bool(self.store.get("allow_run", True)),
"allow_power": bool(self.store.get("allow_power", True)),
"allow_screen": bool(self.store.get("allow_screen", True)),
"screen_supported": screen_ok,
"screen_width": screen_size[0],
"screen_height": screen_size[1],
"received_dir": str(self.store.received_dir()),
}
def open_path(path: str) -> None:
"""用系统默认程序打开文件/文件夹(仅本机 UI 按钮使用)。"""
if IS_WINDOWS:
os.startfile(path) # noqa: S606
elif sys.platform == "darwin":
subprocess.Popen(["open", path])
else:
subprocess.Popen(["xdg-open", path])