Files
ykxmz/windows/lanremote/actions.py
T
xmz7891 fd8be87209 2.1.8:修 Win7 语音播报 + 快捷栏按钮做大且4格全能换
用户这一轮报了两个问题,都修了。

== 一、Win7 上语音播报不能用 ==

用户原话:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"

**原来只有一条路**:

    powershell -NoProfile -WindowStyle Hidden -Command
        "Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"

这在 Win7 上有三个坑,任何一个都能让它彻底不出声:

  1. **`powershell` 只按名字找**
     Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
     精简版系统、或从服务/计划任务拉起时 PATH 里可能没有 ——
     CreateProcess 按名字找不到时是**静默失败**的。

  2. **Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`**
     PS 3.0 起才按 UTF-16 处理。中文经 `-Command` 传进去容易乱码 ——
     表现是念出来是乱码,或者干脆不念。

  3. **`System.Speech` 需要 .NET Framework 3.0+**
     而系统自带的 **SAPI5** 从 Windows XP 就有,**根本不需要 .NET**。

**改成多方案降级**,按"依赖越少越优先"排:

    1. cscript + VBScript + SAPI5      <- Win98 起就有,不用装任何东西
    2. wscript + VBScript + SAPI5      <- 上一条被安全软件拦时的备选
    3. powershell + SAPI5 (COM)        <- 绕开 .NET
    4. powershell + System.Speech      <- 原来的做法,改用绝对路径
    5. powershell(PATH) + System.Speech <- 最原始的,兜底

一条通了**记住它**,以后直接用(不用每次从头试)。

**顺带修掉一个"骗人"的行为**:原来 `say()` 用 `Popen` 不等进程结束,
失败了也返回"已播报" —— 明明没出声却报成功。
现在等退出码,**全失败会明确报错并列出试过哪几条**。
代价是同步等待(念多久等多久),但"报成功其实没出声"比"慢一点"糟糕得多。

还加了 `_build/diag_tts.py`:在一台机器上把 5 条方案全试一遍并报告
哪条能用 —— 下次再有"某台机器上不行",跑一下就知道。

VBScript 的细节:**必须用 ANSI(mbcs) 写**,写成 UTF-8 的话
cscript 读出来中文是乱码(VBScript 的老规矩,很隐蔽)。
长文本还要分句 —— VBScript 单行有长度上限。

== 二、快捷栏按钮太小 + 4 格都要能换 ==

用户原话:"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点,
           然后呢,这个每每个四格算换一下吧,第一个第二个第三个
           第四个都能换东西"

  * **按钮做大**:30dp -> 46dp(接近 Android 建议的最小可点区域 48),
    字号 11 -> 14。30dp 在表盘上手指按上去一半在栏外。
    **栏高做成常量 QUICK_BAR_DP,栏高 / 面板 topMargin / addView
    三处用同一个值** —— 分三处写的话改一个忘一个,
    表现是"内容被盖住"或"上面空一条"。

  * **4 格全部可自定义**。用户最早说"其中有一个按钮不能改,
    功能就是一键返回桌面",第一版就按"第 4 格写死 desktop"做的。
    现在他改主意了 —— 所以 desktop 变成**普通可选动作**,
    默认仍放第 4 格(习惯不用改),但想换也能换。
    可选动作从 7 个变成 8 个。

  **教训(写进测试注释了)**:别把"用户当时的一句话"当成永久约束
  焊进结构里;真是约束的话,注释里要写清是什么时候的要求,
  下次他改主意时才知道该动哪儿。

== 三、修一个"测试自己没擦干净"造成的假 bug ==

全量回归报:

    [FAIL] 配对码正确写入  实际 '(旧的残留值)'

而 `(旧的残留值)` 是**上一次测试遗留**的配对码 —— 数据目录
`C:\ProgramData\ykxmz\data` 在卸载时**故意不删**(那里存着用户的
中转地址、配对码等,卸载不该删),所以机器上会留着上一轮的
settings.json。测试装的时候虽然传了 `/CODE=517284`,但环境没擦干净时
就可能读到旧值 —— 表现成"安装包有 bug"。

**修法**:`test_installer_cycle.py` 加一步"装前先清残留"
(正规卸载 -> 杀进程 -> 删安装目录 -> **删数据目录**)。
数据目录那一步是关键 —— 卸载器不删它。

== 四、其他 ==

  * `docs/项目规矩.md` 第一节改成**按预案执行**:
    预案一 = 做完测试没问题就上传;预案二 = 做完测试但不上传,直接给用户。
    用户没说预案几时默认按预案一,并在交付时说明。
  * 顺手把那份文档里的老域名 `relay.xxmxxs.top` 改成现在的
    `yk.xxmxxs.top`(老服务器早就退役了)。
  * 修掉自己造的重复定义:往 actions.py 插降级链时,
    `list_voices` / `say` 各插了两份(pyflakes 报 redefinition)。
  * 一次危险的误操作:`Path.write_text(..., newline='\n')` 会**先截断文件
    再校验参数**,参数非法时文件已经空了 —— 我用它把
    `_tests/test_quickbar.py` 清空了。已重写。
    **以后不给 write_text 传 newline。**

== 验证 ==

    全量回归(21 关)全部 PASS
    新增关卡:3a13 手表快捷栏 / 3a14 语音播报降级
    安装包周期 23 项全过(装 -> 验 -> 卸 -> 再验)
    Java 编译通过(android-34, --release 8)
    本机实测语音播报:cscript 那条通了(2.4 秒)
    全部产物重建,Win7 兼容校验通过
2026-10-09 23:30:05 +08:00

1232 lines
50 KiB
Python
Raw Permalink 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 音色。
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话
System.Speech 那条路列不出来,而 SAPI5 一定有。
"""
if not IS_WINDOWS:
return []
ps = self._powershell_path()
if not ps:
return []
out = []
for script in (
# 优先 SAPI5 COM
"$v = New-Object -ComObject SAPI.SpVoice;"
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }",
# 退到 System.Speech
"Add-Type -AssemblyName System.Speech;"
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }",
):
try:
proc = subprocess.run(
[ps, "-NoProfile", "-Command", script],
capture_output=True, timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
encoding="mbcs", errors="replace")
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
if ln.strip()]
except Exception: # noqa: BLE001
out = []
if out:
return out
return []
# ------------------------------------------------------------------
# 语音播报(系统自带 TTS)
# ------------------------------------------------------------------
#
# ⚠ 这里有一套**降级链**,不是随便写的。原因见文件末尾的说明,
# 简单说:Win7 上的 PowerShell 2.0 + 中文 + PATH 三样都可能出问题,
# 而 cscript + SAPI5 是最老最稳的组合(从 XP 就有)。
#
# 顺序:cscript -> wscript -> ps(绝对路径)+SAPI COM
# -> ps(绝对路径)+System.Speech -> ps(PATH)+System.Speech
def _powershell_path(self) -> str:
"""找 powershell.exe。**先按绝对路径**,PATH 只作兜底。
为什么:Win7 上 PATH 里不一定有,而 CreateProcess 按名字找不到时
是**静默失败**的(进程根本没起来,但 Popen 不报错)。
"""
import shutil as _sh
cand = Path(os.environ.get("SystemRoot", r"C:\Windows")) / \
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
if os.name == "nt" and cand.is_file():
return str(cand)
return _sh.which("powershell") or ""
def _write_vbs(self, text: str, voice: str, rate: int) -> Path:
"""生成一个播报用的 .vbs。
⚠ 必须用 **mbcs(系统 ANSI 代码页)** 写 ——
cscript 读 .vbs 是按 ANSI 读的,写成 UTF-8 中文就是乱码。
"""
import tempfile
lines = ['Dim v', 'Set v = CreateObject("SAPI.SpVoice")']
if voice:
# 按描述匹配音色。找不到就 silently 用默认 ——
# 音色选不上不该让整条播报失败。
lines.append('Dim i')
lines.append('For i = 0 To v.GetVoices().Count - 1')
lines.append(' If InStr(v.GetVoices().Item(i).GetDescription(), "%s") > 0 Then'
% voice.replace('"', '""'))
lines.append(' Set v.Voice = v.GetVoices().Item(i)')
lines.append(' End If')
lines.append('Next')
if rate:
# SAPI 的 Rate 范围是 -10..10,跟我们的设置一致
lines.append("v.Rate = %d" % max(-10, min(10, rate)))
# 长文本 VBScript 单行有长度限制,所以**分句拼接**
chunk = text.replace('"', '""').replace("\r", " ").replace("\n", " ")
while chunk:
piece, chunk = chunk[:400], chunk[400:]
lines.append('v.Speak "%s"' % piece)
path = Path(tempfile.gettempdir()) / "ykxmz_say.vbs"
path.write_bytes(("\r\n".join(lines) + "\r\n").encode("mbcs",
errors="replace"))
return path
def _tts_commands(self, text: str, voice: str, rate: int) -> list:
"""按"依赖越少越优先"排出所有可行方案,返回 [(名字, 命令行)]。"""
cmds = []
vbs = None
try:
vbs = self._write_vbs(text, voice, rate)
except Exception: # noqa: BLE001
vbs = None
if vbs is not None:
# 1 / 2:完全不依赖 PowerShell 和 .NET
cmds.append(("cscript", ["cscript", "//nologo", str(vbs)]))
cmds.append(("wscript", ["wscript", "//nologo", str(vbs)]))
ps = self._powershell_path()
esc = text.replace("'", "''")
if ps:
# 3:绕开 .NET,只用系统原生 SAPI5
sapi = ["$v = New-Object -ComObject SAPI.SpVoice;"]
if voice:
sapi.append("foreach ($o in $v.GetVoices()) { "
"if ($o.GetDescription() -like '*%s*') { "
"$v.Voice = $o } };" % voice.replace("'", "''"))
if rate:
sapi.append("$v.Rate = %d;" % max(-10, min(10, rate)))
sapi.append("$v.Speak('%s')" % esc)
cmds.append(("ps-sapi", [ps, "-NoProfile", "-WindowStyle", "Hidden",
"-Command", "".join(sapi)]))
# 4:原来的做法,但用绝对路径
sp = ["Add-Type -AssemblyName System.Speech;",
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
if voice:
sp.append("try { $s.SelectVoice('%s') } catch { };"
% voice.replace("'", "''"))
if rate:
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
sp.append("$s.Speak('%s')" % esc)
cmds.append(("ps-speech", [ps, "-NoProfile", "-WindowStyle",
"Hidden", "-Command", "".join(sp)]))
# 5:最原始的(PATH + System.Speech),兜底
import shutil as _sh
if _sh.which("powershell"):
sp = ["Add-Type -AssemblyName System.Speech;",
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
if voice:
sp.append("try { $s.SelectVoice('%s') } catch { };"
% voice.replace("'", "''"))
if rate:
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
sp.append("$s.Speak('%s')" % esc)
cmds.append(("ps-path", ["powershell", "-NoProfile",
"-WindowStyle", "Hidden",
"-Command", "".join(sp)]))
return cmds
def say(self, text: str, voice: str = "", rate: int = 0) -> str:
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。
⚠ 会**等播报结束**并看退出码,失败就抛错。
原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。
代价是同步等待(念多久等多久),但"报成功其实没出声"
比"慢一点"糟糕得多。
"""
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))
cmds = self._tts_commands(text, chosen, speed)
if not cmds:
raise ActionError("这台电脑上找不到可用的语音播报方式"
"(没有 cscript 也没有 powershell?)")
# 上次成功的那条优先 —— 不然每播一次都要把几条全试一遍
cached = globals().get("_TTS_LAST_OK")
if cached:
cmds.sort(key=lambda c: 0 if c[0] == cached else 1)
tried = []
for name, cmd in cmds:
try:
proc = subprocess.run(
cmd, capture_output=True, timeout=180,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
encoding="mbcs", errors="replace")
except FileNotFoundError:
tried.append("%s(没有这个程序)" % name)
continue
except subprocess.TimeoutExpired:
tried.append("%s(超时)" % name)
continue
except Exception as exc: # noqa: BLE001
tried.append("%s(%s)" % (name, type(exc).__name__))
continue
if proc.returncode == 0:
globals()["_TTS_LAST_OK"] = name
return "已播报(%s,用 %s)" % (chosen or "默认音色", name)
err = ((proc.stderr or "") + (proc.stdout or "")).strip()
tried.append("%s(退出码%d %s)" % (name, proc.returncode, err[:40]))
raise ActionError("语音播报失败,试过:" + ";".join(tried))
# ------------------------------------------------------------------
# 播放本机音频文件(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])