Files
ykxmz/windows/lanremote/server.py
T
xmz7891 4d97e40280 2.1.6:静默自动更新 + 远程鼠标控制 + 一键显示桌面
用户这次提了一整套升级。这个提交先落地三块(其余几块在做)。

== 一、静默自动更新 ==

用户原话:"手表端点击一个更新按钮,电脑端就可以自动进行静默更新,
其他什么设置都不变 …… 不弹任何窗口出来"。

链路:

    手表点按钮 -> 被控端问中转"最新是哪版"
               -> 下载到 %TEMP%\ykxmz-update\
               -> 校验 sha256
               -> 起一个**分离的 .cmd 帮手**
               -> 自己退出
               -> 帮手等它退出 -> 静默跑安装器 -> 重新拉起

关键设计说明:

  * **为什么要有那个 .cmd 帮手**:Windows 上正在运行的 exe 是锁着的,
    安装器覆盖不了,所以必须"先退出、再装、再起来",而这三步
    不能由被控端自己做(它一退出就没人执行后面的了)。
    用 DETACHED_PROCESS 起的助手不受父进程退出影响。

  * **配置为什么不会丢**:配置在 %ProgramData%\ykxmz\data\,
    **不在程序目录里**。安装器升级只替换 {app} 下的文件,
    不碰 ProgramData —— 中转地址、配对码、各种开关原样保留。
    这是这个功能能做的前提。

  * **更新源是用户自己的中转站**,不是作者的服务器,也不是任何
    写死的公网地址。被控端问的是它自己配置的那个地址。
    中转没有这个接口(老版本服务端)时会明确提示"先手动升级一次服务端"。

  * **sha256 必须校验**:自更新最大的风险是下到一半断了或被换了,
    装上去是个坏程序、而且它还会自己再更新 —— 螺旋下去很难救。
    对不上就**放弃这一轮**,保持现状(现状至少能用)。

中转新增:GET /v1/update/latest?platform=windows
返回 version / name / url / size / sha256。版本比较按**数字段**
(不能按字符串,否则 "1.9.0" > "2.1.4")。sha256 按
(文件名,大小,mtime) 缓存,避免每次重算 9MB。

手表端:"电脑端版本:点这里查看" 按钮,查完显示
"电脑端 2.1.4 → 2.1.5(点这里更新)",确认后才真的更新。

== 二、远程鼠标控制 ==

新增 windows/lanremote/input.py,用 SendInput 模拟鼠标键盘。

  * **为什么用 SendInput 而不是 mouse_event**:后者是 Win95 时代的
    API,会被 UIPI 拦,而且没有 dwExtraInfo(多个注入源会互相干扰)。
    SendInput 还能一次打包多个事件,按下+抬起之间不会插进别的输入。

  * **坐标是 0..65535 归一化**,而且**必须除以 (屏幕宽-1)**。
    少了那个 -1,最右边和最下边几像素点不到 —— 表现为
    "鼠标到不了屏幕边缘",任务栏在底下的机器上尤其明显。

  * **INPUT 结构在 32/64 位下大小不同**(28 / 40),
    ctypes 里按进程位数算,不能写死。

  * 权限说明:目标窗口如果是管理员权限的(UAC 提升过的),
    普通权限的注入会被 UIPI 拦掉。这不是 bug,是 Windows 的设计。
    被控端以管理员运行就都能点。

== 三、一键显示桌面 ==

用户要求快捷栏里那个"不能改"的按钮就是这个功能,等于 Win+D。
和鼠标控制共用 input.py。

== 顺手修的一个自己造的坑 ==

往 server.py 插方法时,**两次**把 dispatch 的兜底
`return {"ok": False, "error": f"未知命令: {cmd}"}` 一起卷进了新方法里:

  * pyflakes 报 "undefined name 'cmd'"
  * 而且 dispatch 丢了兜底分支(未知命令会返回 None)

第一次靠 pyflakes 抓到,第二次也是。为了以后不再犯,
`_tests/test_update_flow.py` 里加了**结构断言**:
兜底必须在带 CMD_UPDATE 的那个方法体里,而各新方法里不能出现它。

(那条断言第一版写得太死 —— 要求"紧跟 CMD_UPDATE 分支",
 后来往 dispatch 里插了新分支就误报了。改成验结构真相。)

== 验证 ==

    全量回归(含新增的 3a10 / 3a11 两关)全部 PASS
    静默自动更新:协议 + updater 15 项 + 服务端结构 8 项
    远程鼠标:坐标归一化 / 滚轮夹取 / Win+D 事件顺序 / 非法参数 22 项
    610 个 Python 文件语法通过
    实际注入鼠标成功(move / click / scroll / show_desktop)

== 还没做的(下一批) ==

    手表顶部快捷栏(可自定义最多 4 个,1 个固定"显示桌面")
    屏幕镜像去掉提示条、去掉所有弹窗
    托盘图标改成设置项
    「允许新设备连接」开关(中转后台 + 安卓端)
    弹文字窗口位置/大小可自定义
    中转后台设备列表显示版本 + 远程点更新

== 顺手清理 ==

_tests/out_app/ 下面那堆 .class 是 Java 编译检查的产物,
以前误提交进仓库了(152 个文件)。这次从索引里移除并加进 .gitignore。
2026-10-09 22:12:09 +08:00

1483 lines
59 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 -*-
"""被控端 TCP 服务:配对、鉴权、命令分发、文件收发。"""
from __future__ import annotations
import os
import re
import secrets
import socket
import threading
import time
from pathlib import Path
import tempfile
import sys
from . import protocol as P
from . import updater as U
from . import input as IN
from .actions import ActionError
from .config import Store
from .discovery import DISCOVERY_PORT, DiscoveryResponder
from .features import Features
from .screen import FrameStreamer, ScreenCapturer, ScreenError
def _truthy(value) -> bool:
"""字符串 "false"/"0"/"off" 不能被当成真(bool("false") 是 True)。"""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "on", "yes", "是", "开")
return bool(value)
class Session:
"""一条客户端连接。"""
def __init__(self, conn: socket.socket, addr, server: "RemoteServer"):
self.conn = conn
self.addr = addr
self.server = server
self.device_id = ""
self.device_name = ""
self.authed = False
self.wants_screen = False # 是否正在观看屏幕镜像
self.connected_at = time.time()
self.last_seen = time.time()
self.buffers = {
P.CMD_IMAGE: P.ChunkAssembler(),
P.CMD_AUDIO: P.ChunkAssembler(),
P.CMD_FILE_PUSH: P.ChunkAssembler(),
}
self._send_lock = threading.Lock()
def send(self, obj: dict) -> bool:
try:
with self._send_lock:
self.conn.sendall(P.dumps(obj))
return True
except OSError:
return False
def send_chunks(self, event: str, name: str, blob: bytes) -> bool:
"""把二进制分块发给客户端。"""
for seq, last, piece in P.chunk_bytes(blob):
ok = self.send({
"event": event, "name": name,
"seq": seq, "last": last, "data": P.b64encode(piece),
})
if not ok:
return False
return True
def close(self) -> None:
try:
self.conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self.conn.close()
except OSError:
pass
# 内置的中转地址:**默认留空**,由用户自己在设置里填。
#
# 为什么不再内置一个:
# 原来写死了一台随身 WiFi 的地址 + 一个公网隧道域名。问题是
# 那台设备的 IP 会变(DHCP),写死的地址很快就过期,用户开箱
# 就一直在连一个不存在的主机,表现成"一直转圈/连不上"。
# 与其猜一个迟早会错的地址,不如留空 —— 局域网本身完全不受影响。
#
# 空字符串的含义:**只走局域网**。
# * UDP 广播发现、TCP 直连、配对、命令下发,全都照常工作
# * 不会去连任何中转,不报错、不卡住(RelayLink.start("") 会直接跳过)
# * 用户想要跨网络时,在「中转设置」里填自己的地址即可
# * 多个地址可用分号隔开,按顺序尝试,例如:
# http://192.168.1.10:8080;https://你自己的域名
DEFAULT_RELAY_URL = ""
class RemoteServer:
"""局域网被控端服务。"""
def __init__(self, store: Store, actions, ui=None):
self.store = store
self.actions = actions
self.features = Features(actions) if actions else None
self.ui = ui
self.sessions: list[Session] = []
# 配对失败记录:{来源 IP: [时间戳]},用于限速防枚举
self._pair_failures: dict = {}
self._lock = threading.RLock()
# 跨网络中转链路(配了 relay_url 才会启用)
self._relay = None
self._sock: socket.socket | None = None
self._accept_thread: threading.Thread | None = None
# 累计收到过多少次 TCP 连接 —— 自检用它判断"手表的包有没有到"
self._total_connections = 0
self._clip_thread: threading.Thread | None = None
self._running = False
self._last_clip = ""
self.pairing_enabled = True
# 屏幕镜像
self._capturer: ScreenCapturer | None = None
# 建采集器时用的 (分辨率, 画质)。变了就重建,不然改设置不生效。
self._capturer_signature = None
self._streamer: FrameStreamer | None = None
self._screen_indicator_on = False
# 局域网自动发现(手表不用输 IP)
self._discovery: DiscoveryResponder | None = None
# ---------------- 生命周期 ----------------
@property
def port(self) -> int:
return int(self.store.get("port", P.DEFAULT_PORT))
def start(self) -> None:
if self._running:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Windows 上 SO_REUSEADDR 允许第二个进程抢占同一端口,
# 会让"端口被占用"检测失效。改用独占绑定。
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
except OSError:
pass
else:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", self.port))
sock.listen(16)
sock.settimeout(1.0)
self._sock = sock
self._running = True
# ── 先起局域网发现 ──
#
# 顺序很重要:enable_relay() 会挨个试中转地址,
# 每个候选都要等到超时才算"不通",地址越烂等得越久(实测 10 秒+)。
# 原来这一步排在发现服务前面,导致:
# 电脑刚开机的十几秒里,手表广播搜不到它。
# 局域网发现是纯本机能力,不该被"连不连得上外网"拖住,
# 所以挪到前面,开机即可被搜到。
self._discovery = DiscoveryResponder(self.store, self.port)
# 手表点「连不上?」会广播请求,这里就把网络自检结论算出来
self._discovery.on_selfcheck = self._on_selfcheck_request
if self._discovery.start():
self._log(f"自动发现已开启(UDP {DISCOVERY_PORT})")
else:
self._log(f"自动发现未开启:{self._discovery.last_error}")
# ── 再起收发线程 ──
self._accept_thread = threading.Thread(target=self._accept_loop,
name="accept", daemon=True)
self._accept_thread.start()
self._clip_thread = threading.Thread(target=self._clip_loop,
name="clip", daemon=True)
self._clip_thread.start()
# ── 最后才连中转(会阻塞,所以放最后)──
# 配了中转地址就同时启用跨网络通道(和局域网监听并存,不冲突)
try:
self.enable_relay()
except Exception as exc: # noqa: BLE001
self._log(f"启用中转失败:{exc}")
self._log(f"服务已启动,监听 0.0.0.0:{self.port}")
def _selfcheck_report(self, from_ip: str) -> str:
"""生成一段给手表看的诊断结论(不弹任何窗口)。
用户要的是「电脑上什么都不出现」,所以结论直接回传给手表显示。
"""
try:
from .diagnostics import collect, verdict
data = collect(self, self.store)
_level, text = verdict(data)
ips = data.get("ips") or []
port = self.port
blocked = (data.get("firewall") or {}).get("blocked_count", 0)
lines = [
f"电脑:{data.get('name') or '被控端'}",
f"本机地址:{', '.join(ips[:2]) if ips else '读不到'}",
f"监听端口:{port}",
f"收到过的连接:{data.get('tcp_connections', 0)} 次",
f"阻止规则:{blocked} 条",
"",
f"结论:{text}",
]
return "\n".join(lines)
except Exception as exc: # noqa: BLE001
return f"电脑端诊断失败:{exc}"
def _on_selfcheck_request(self, from_ip: str) -> str:
"""手表说「我连不上」→ 生成诊断结论,随 ACK 回给手表。
走 UDP 是有意的:连不上的典型原因就是 TCP 被防火墙拦了,
而 UDP 广播往往还通(那正是"能搜到但连不上"的原因)。
注意:**不往电脑上弹窗口**。结论直接回传给手表,
用户看的就是手表,电脑上保持干净。
"""
note = self._selfcheck_report(from_ip)
first = note.splitlines()[0] if note else ""
self._log(f"[自检] 收到来自 {from_ip} 的自检请求,已生成诊断结论({first})")
return note
def _open_selfcheck_window(self, from_ip: str) -> None:
"""有窗口的模式下才用得上(静默模式不会走到这里)。"""
self._log(f"[自检] 收到来自 {from_ip} 的请求,已生成诊断结论")
def launch() -> None:
target = getattr(self, "ui", None)
if target is not None and hasattr(target, "open_diagnostics"):
try:
target.ui_call(lambda: target.open_diagnostics(from_ip), timeout=3.0)
return
except Exception as exc: # noqa: BLE001
self._log(f"[自检] 打开界面失败:{exc}")
return
self._log("[自检] 没有可用的界面,跳过")
# 弹窗口这件事要放到主线程做,避免和界面的消息循环打架
target = getattr(self, "ui", None)
if target is not None and hasattr(target, "ui_call"):
try:
target.ui_call(launch, timeout=3.0)
return
except Exception:
pass
try:
import threading
threading.Thread(target=launch, name="selfcheck", daemon=True).start()
except Exception:
pass
def stop(self) -> None:
self._running = False
self._stop_relay()
self._stop_streamer()
if self._discovery is not None:
self._discovery.on_selfcheck = None
self._discovery.stop()
self._discovery = None
with self._lock:
sessions = list(self.sessions)
self.sessions.clear()
for session in sessions:
session.close()
if self._sock:
try:
self._sock.close()
except OSError:
pass
self._sock = None
self._log("服务已停止")
def restart(self, new_port: int | None = None) -> None:
self.stop()
if new_port:
self.store.set("port", int(new_port))
time.sleep(0.3)
self.start()
@property
def running(self) -> bool:
return self._running
# ---------------- 中转(跨网络)----------------
def adopt_relay_socket(self, relay_socket) -> None:
"""把一条中转连接当成普通 TCP 连接接纳进来。
中转那边说"有手表接入了",这里就造一个 Session 跑同样的
_client_loop —— 上面的协议、配对、鉴权一行都不用改。
"""
address = ("中转服务器", 0)
session = Session(relay_socket, address, self)
with self._lock:
self.sessions.append(session)
self._log("设备接入(经中转,跨网络)")
self._notify_ui()
threading.Thread(target=self._client_loop, args=(session,),
name="client-relay", daemon=True).start()
def enable_relay(self, url: str = "") -> bool:
"""启用中转。地址优先用参数,其次读设置。"""
from .relay import RelayLink
target = (url or "").strip() or str(self.store.get("relay_url") or "")
if not target.strip():
# 没配过就用内置默认值 —— 现在是空字符串,表示"只走局域网"。
# 这时候**什么都不做**是正确的:局域网监听已经起来了,
# 不报错、不重试、不占用任何后台线程。
target = DEFAULT_RELAY_URL
if not target.strip():
self._log("没配中转地址,只走局域网"
"(需要跨网络时在设置里填中转地址)")
# ⚠ 这里以前是 `if self._relay is not None: return True` ——
# 只要对象建过就报成功。而"没配中转地址"那条路也会把对象建起来
# (start("") 返回 False),于是第二次调用会谎报中转在跑。
# 改成看**真实运行状态**。
if self._relay is not None:
return bool(getattr(self._relay, "running", False))
self._relay = RelayLink(self.store, log=self._log,
on_session=self.adopt_relay_socket,
on_alert=self._alert)
started = self._relay.start(target)
if started:
self._log(f"已启用中转:{self._relay.url} 配对码 {self.store.pairing_code()}")
return started
def _alert(self, title: str, text: str) -> None:
"""给用户看的通知(配对码被占这类必须让人知道的事)。
走界面的通知,用户在电脑上能直接看到。
界面还没起来时退回写日志,至少不丢信息。
"""
self._log(f"[需要处理] {title}:{text}")
try:
ui = getattr(self.actions, "ui", None)
if ui is not None and hasattr(ui, "notify"):
ui.notify(title, text)
except Exception: # noqa: BLE001
pass
def relay_status(self) -> dict:
if self._relay is None:
return {"enabled": False}
return self._relay.status()
def _stop_relay(self) -> None:
if self._relay is None:
return
try:
self._relay.stop()
except Exception: # noqa: BLE001
pass
self._relay = None
# ---------------- 接受连接 ----------------
def _accept_loop(self) -> None:
while self._running:
try:
conn, addr = self._sock.accept()
self._total_connections += 1
except socket.timeout:
continue
except OSError:
break
conn.settimeout(None)
try:
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except OSError:
pass
session = Session(conn, addr, self)
with self._lock:
self.sessions.append(session)
self._log(f"设备接入 {addr[0]}:{addr[1]}")
self._notify_ui()
threading.Thread(target=self._client_loop, args=(session,),
name=f"client-{addr[0]}", daemon=True).start()
def _client_loop(self, session: Session) -> None:
try:
for line in self._read_lines(session.conn):
session.last_seen = time.time()
try:
msg = P.loads(line)
except ValueError:
session.send({"ok": False, "error": "消息格式错误"})
continue
if not isinstance(msg, dict):
continue
try:
reply = self._dispatch(session, msg)
except ActionError as exc:
reply = {"ok": False, "error": str(exc)}
except Exception as exc: # noqa: BLE001
reply = {"ok": False, "error": f"内部错误: {exc}"}
self._log(f"[错误] {exc}")
if reply is not None:
# ⚠ 这里必须兜住**所有**异常。
# 以前只在外层捕获 OSError,而 session.send() 在
# 中转链路上会抛 RelayError(实测:"请求失败: Idle")——
# 那个异常一路冒出去,把 client-relay 线程整个打死。
# 线程一死,电脑就再也不往手表回任何东西,
# 而 relay-link 还在长轮询、中转看着"设备在线",
# 用户看到的就是"连上了,按一下毫无反应"。
# 发失败最多踢掉这个会话让手表重连,绝不能丢线程。
try:
session.send(reply)
except Exception as exc: # noqa: BLE001
self._log(f"[错误] 回包失败,断开该会话:{exc}")
break
except OSError:
pass
except Exception as exc: # noqa: BLE001
self._log(f"[错误] 会话循环异常退出:{exc}")
finally:
with self._lock:
if session in self.sessions:
self.sessions.remove(session)
session.close()
session.wants_screen = False
# 没有观看者了就立刻停掉抓屏,不留后台耗电/耗资源
if not any(s.wants_screen for s in self._authed_sessions()):
self._stop_streamer()
self._log(f"设备断开 {session.addr[0]}"
f"{' / ' + session.device_name if session.device_name else ''}")
self._notify_ui()
@staticmethod
def _read_lines(conn: socket.socket):
buf = b""
while True:
try:
data = conn.recv(65536)
except OSError:
return
if not data:
return
buf += data
if len(buf) > P.MAX_LINE_BYTES and b"\n" not in buf:
return
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
if line.strip():
yield line
# ---------------- 命令分发 ----------------
def _dispatch(self, session: Session, msg: dict) -> dict | None:
cmd = str(msg.get("cmd") or "")
# --- 握手 ---
if cmd == P.CMD_PAIR:
return self._do_pair(session, msg)
if cmd == P.CMD_AUTH:
return self._do_auth(session, msg)
if not session.authed:
return {"ok": False, "error": "未鉴权:请先配对或使用已保存的设备令牌"}
if cmd == P.CMD_PING:
return {"ok": True, "pong": True, "time": time.time()}
if cmd == P.CMD_IMAGE:
return self._recv_media(session, msg, P.CMD_IMAGE, "图片")
if cmd == P.CMD_AUDIO:
return self._recv_media(session, msg, P.CMD_AUDIO, "音频")
if cmd == P.CMD_FILE_PUSH:
return self._recv_media(session, msg, P.CMD_FILE_PUSH, "文件")
if cmd == P.CMD_MEDIA:
note = self.actions.media_control(str(msg.get("action") or ""))
self._log(f"[媒体] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_CLIP_SET:
note = self.actions.clip_set(str(msg.get("text") or ""))
self._last_clip = str(msg.get("text") or "")
self._log(f"[剪贴板] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_CLIP_GET:
text = self.actions.clip_get()
return {"ok": True, "text": text}
if cmd == P.CMD_POWER:
note = self.actions.power(str(msg.get("action") or ""))
self._log(f"[电源] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_RUN:
result = self.actions.run_command(str(msg.get("command") or ""))
return {"ok": True, **result}
if cmd == P.CMD_SAY:
try:
rate = int(msg.get("rate") or 0)
except (TypeError, ValueError):
rate = 0
note = self.actions.say(str(msg.get("text") or ""),
str(msg.get("voice") or ""), rate)
self._log(f"[播报] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_VOICE_LIST:
voices = self.actions.list_voices()
return {"ok": True, "voices": voices,
"current": str(self.store.get("tts_voice") or "")}
if cmd == P.CMD_SFX_LIST:
items = self.actions.list_sfx()
# 同时给一个扁平的 names —— 手表端的 stringList(reply, key)
# 只认字符串数组,和 audio_list 的约定保持一致。
return {"ok": True, "sfx": items, "count": len(items),
"names": [it.get("name", "") for it in items],
"dir": self.actions.sfx_dir_hint()}
if cmd == P.CMD_SFX_PLAY:
name = str(msg.get("name") or "").strip()
if not name:
return {"ok": False, "error": "没说要放哪个音效"}
try:
msg = self.actions.play_sfx(name)
except Exception as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "message": msg, "name": name}
if cmd == P.CMD_AUDIO_LIST:
files = self.actions.list_local_audio()
# 顺手给出扁平的 names/paths,手表端不用再解析嵌套结构
return {"ok": True,
"files": files,
"names": [f.get("name", "") for f in files],
"paths": [f.get("path", "") for f in files],
"roots": [str(p) for p in self.actions.audio_roots()]}
if cmd == P.CMD_SHUTDOWN_TIMER:
cancel = _truthy(msg.get("cancel"))
try:
minutes = int(msg.get("minutes") or 0)
except (TypeError, ValueError):
minutes = 0
note = self.actions.shutdown_timer(minutes, cancel)
self._log(f"[电源] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_MONITOR:
note = self.actions.monitor_power(_truthy(msg.get("on")))
self._log(f"[显示] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_OPEN_URL:
note = self.actions.open_url(str(msg.get("url") or ""))
self._log(f"[网址] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_QUICK:
note = self.actions.quick_launch(str(msg.get("app") or ""))
self._log(f"[快捷] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_OPEN_FOLDER:
note = self.actions.open_folder(str(msg.get("folder") or ""))
self._log(f"[文件夹] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_PROC_LIST:
procs = self.actions.list_processes()
return {"ok": True,
"names": [f"{p['name']} ({p['mem_kb'] // 1024} MB)" for p in procs],
"pids": [p["pid"] for p in procs]}
if cmd == P.CMD_PROC_KILL:
try:
pid = int(msg.get("pid") or 0)
except (TypeError, ValueError):
pid = 0
note = self.actions.kill_process(pid, str(msg.get("name") or ""))
self._log(f"[进程] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_BATTERY:
info = self.actions.battery()
if not info.get("present"):
return {"ok": True, "note": info.get("note") or "没有电池(台式机)"}
detail = f"{info['percent']}%"
if info.get("charging"):
detail += "(充电中)"
return {"ok": True, "note": detail, **info}
if cmd == P.CMD_DISK:
drives = self.actions.disk()
lines = [f"{d['drive']} {d['free_gb']}/{d['total_gb']} GB 可用"
f"(已用 {d['used_percent']}%)" for d in drives]
return {"ok": True, "note": " | ".join(lines) or "读不到磁盘",
"drives": drives}
if cmd == P.CMD_PERF:
info = self.actions.perf()
note = (f"CPU {info.get('cpu_percent')}% "
f"内存 {info.get('mem_percent')}%"
f"({info.get('mem_used_gb')}/{info.get('mem_total_gb')} GB)")
return {"ok": True, "note": note, **info}
if cmd == P.CMD_CLIP_HISTORY:
items = self.actions.clip_history()
return {"ok": True, "note": f"共 {len(items)} 条",
"texts": [i.get("text", "") for i in items],
"times": [i.get("at", "") for i in items]}
if cmd == P.CMD_SET_WALLPAPER:
note = self.actions.set_wallpaper(str(msg.get("path") or ""))
self._log(f"[壁纸] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_SAY_CLIP:
note = self.actions.say_clipboard()
self._log(f"[朗读] 剪贴板内容")
return {"ok": True, "note": note}
if cmd == P.CMD_AUDIO_PAUSE:
note = self.actions.audio_pause()
self._log(f"[音频] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_AUDIO_RESUME:
note = self.actions.audio_resume()
self._log(f"[音频] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_AUDIO_STATUS:
return {"ok": True, "note": self.actions.audio_status()}
if cmd == P.CMD_FEATURE_LIST:
if self.features is None:
raise ActionError("功能不可用")
return {"ok": True, "features": self.features.defs(),
"extra_apps": self.features.extra_apps(),
"extra_commands": self.features.extra_commands(),
"roots": [str(p) for p in self.features.allowed_roots()]}
if cmd == P.CMD_FEATURE_RUN:
if self.features is None:
raise ActionError("功能不可用")
name = str(msg.get("name") or "")
params = msg.get("params") or {}
if not isinstance(params, dict):
params = {}
try:
note = self.features.run(name, params)
except ValueError as exc:
raise ActionError(str(exc)) from exc
self._log(f"[功能] {name} -> {note}")
return {"ok": True, "note": str(note)[:4000]}
if cmd == P.CMD_SET_VOICE:
voice = str(msg.get("voice") or "").strip()
self.store.set("tts_voice", voice)
self._log(f"[音色] 已设为 {voice or '系统默认'}")
return {"ok": True, "note": voice or "系统默认"}
if cmd == P.CMD_PLAY_LOCAL:
note = self.actions.play_local(str(msg.get("path") or ""))
self._log(f"[本机播放] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_NOTIFY:
note = self.actions.notify(str(msg.get("title") or ""),
str(msg.get("text") or ""))
return {"ok": True, "note": note}
if cmd == P.CMD_FILE_LIST:
return self._file_list()
if cmd == P.CMD_FILE_PULL:
return self._file_pull(session, str(msg.get("path") or ""))
if cmd == P.CMD_SCREEN_START:
return self._screen_start(session, msg)
if cmd == P.CMD_SCREEN_STOP:
return self._screen_stop(session)
if cmd == P.CMD_SCREENSHOT:
return self._screenshot(session, msg)
if cmd == P.CMD_INFO:
return {"ok": True, "info": self.actions.info()}
if cmd == P.CMD_UPDATE:
return self._do_update(msg)
if cmd == P.CMD_MOUSE:
return self._do_mouse(msg)
if cmd == P.CMD_SHOW_DESKTOP:
try:
IN.show_desktop()
except IN.InputError as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "done": "show_desktop"}
return {"ok": False, "error": f"未知命令: {cmd}"}
# ---------------- 远程鼠标 / 键盘注入 ----------------
def _do_mouse(self, msg: dict) -> dict:
"""处理 {"cmd":"mouse","action":...}。
坐标系是**屏幕像素**(相对虚拟屏幕左上角),由手表按比例映射好
再发过来 —— 电脑端不做缩放猜测,免得两边算法不一致导致漂移。
"""
action = str(msg.get("action") or "").lower()
try:
if action == "move":
# 分辨率可能被改过(投屏切换等),每次移动前刷一下缓存。
# 不刷的话,插上投影仪之后鼠标会跑到屏幕外。
IN.refresh_screen()
IN.move_to(int(msg.get("x", 0)), int(msg.get("y", 0)))
return {"ok": True}
if action == "moveby":
IN.move_by(int(msg.get("dx", 0)), int(msg.get("dy", 0)))
return {"ok": True}
if action == "click":
IN.click(str(msg.get("button") or "left"),
double=bool(msg.get("double")))
return {"ok": True}
if action == "down":
IN.down(str(msg.get("button") or "left"))
return {"ok": True}
if action == "up":
IN.up(str(msg.get("button") or "left"))
return {"ok": True}
if action == "scroll":
IN.scroll(int(msg.get("amount", 0)),
horizontal=bool(msg.get("horizontal")))
return {"ok": True}
if action == "desktop":
IN.show_desktop()
return {"ok": True}
return {"ok": False, "error": "不认识的鼠标动作:%s" % action}
except IN.InputError as exc:
return {"ok": False, "error": str(exc)}
except (TypeError, ValueError) as exc:
return {"ok": False, "error": "参数不对:%s" % exc}
# ---------------- 静默自动更新 ----------------
def _do_update(self, msg: dict) -> dict:
"""处理 {"cmd":"update","action":...}。
取包的地方是**用户自己配的中转站**(store 里的 relay_url),
不是任何写死的公网地址 —— 这一点是刻意的,见 updater.py 的说明。
"""
from . import __version__ as my_version
action = str(msg.get("action") or "check").lower()
relay = (self.store.relay_url() or "").strip()
if action == "check":
try:
info = U.check(relay, my_version)
except U.UpdateError as exc:
return {"ok": False, "error": str(exc)}
if not info.get("supported"):
return {"ok": False,
"error": info.get("error") or "不支持自动更新"}
if "error" in info:
return {"ok": False, "error": info["error"]}
return {
"ok": True,
"current": my_version,
"latest": info["latest"],
"needUpdate": bool(info["needUpdate"]),
"size": info.get("size", 0),
"name": info.get("name", ""),
}
if action != "start":
return {"ok": False, "error": "action 只能是 check 或 start"}
# ---- 真的更新 ----
# 先查一遍(在调用方线程里查,失败能立刻报回去)
try:
info = U.check(relay, my_version)
except U.UpdateError as exc:
return {"ok": False, "error": str(exc)}
if not info.get("supported") or "error" in info:
return {"ok": False,
"error": info.get("error") or "不支持自动更新"}
if not info["needUpdate"]:
return {"ok": True, "alreadyLatest": True, "current": my_version,
"message": "电脑端已经是最新版本 %s" % my_version}
# 下载和应用放到后台线程,先回一句让手表知道开始了
threading.Thread(
target=self._run_update,
args=(info, my_version),
name="ykxmz-update", daemon=True).start()
return {
"ok": True,
"started": True,
"from": my_version,
"to": info["latest"],
"size": info.get("size", 0),
"message": "正在下载 %s(%.1f MB),下完会自动装好并重启,"
"设置和配对码都不变"
% (info["latest"], (info.get("size") or 0) / 1048576.0),
}
def _run_update(self, info: dict, my_version: str) -> None:
"""后台线程:下载 -> 校验 -> 起帮手 -> 自己退出。"""
def log(text: str) -> None:
try:
self._log(text)
except Exception: # noqa: BLE001
pass
try:
log("[更新] 开始下载 %s(%.1f MB)"
% (info["latest"], (info.get("size") or 0) / 1048576.0))
work = Path(tempfile.gettempdir()) / "ykxmz-update"
last = [0]
def progress(got: int, total: int) -> None:
# 每 25% 报一次,别把日志刷爆
if total:
pct = int(got * 100 / total)
if pct - last[0] >= 25:
last[0] = pct
log("[更新] 已下载 %d%%(%.1f/%.1f MB)"
% (pct, got / 1048576.0, total / 1048576.0))
pkg = U.download(info["url"], work,
info.get("sha256", ""), info.get("size", 0),
progress=progress)
log("[更新] 下载完成并校验通过:%s" % pkg.name)
# 应用:起分离的 .cmd 帮手,然后**本进程必须退出**
app_exe = Path(sys.executable if getattr(sys, "frozen", False)
else sys.argv[0]).resolve()
logfile = U.apply_update(pkg, app_exe)
log("[更新] 已交给更新帮手(%s),被控端即将退出并重启" % logfile)
U.cleanup_old_downloads()
# 给日志和网络一点时间落完,再退出
time.sleep(2)
os._exit(0)
except U.UpdateError as exc:
log("[更新] 失败:%s(保持当前版本 %s)" % (exc, my_version))
except Exception as exc: # noqa: BLE001
log("[更新] 意外失败:%s: %s" % (type(exc).__name__, exc))
# ---------------- 握手实现 ----------------
def _do_pair(self, session: Session, msg: dict) -> dict:
code = str(msg.get("code") or "").strip()
expected = self.store.pairing_code()
if not expected:
return {"ok": False, "error": "被控端尚未生成配对码"}
# 配对窗口关着、但对方报的码是对的 —— 照样放行。
#
# 为什么:配对码本身就是密钥。老逻辑是"首台设备配对成功后
# 自动关闭配对窗口"(close_pair_after_first),结果手表重装、
# 换新表、或清掉 App 数据之后令牌没了,重新配对会被拒,
# 用户看到"被控端已关闭新设备配对"就彻底连不上了 ——
# 而这时候他能做的只有跑去管理页手动开窗口,完全不合理。
#
# 码不对时才用"已关闭配对"这个说法挡回去,
# 防暴力枚举的按 IP 限速(下面那段)仍然生效。
if not self.pairing_enabled and code != expected:
return {"ok": False,
"error": "被控端已关闭新设备配对" +
"(配对码不对;要在管理页临时开启的话,"
"打开中转站管理页找「允许新设备配对」)"}
# 防暴力枚举:6 位数字只有 100 万种,不限速的话局域网里慢慢试就行。
# 按**来源 IP**分别计数 —— 全局计数的话,别人发 10 个错码
# 就能把你这个合法用户也锁在门外,那本身就是个拒绝服务的口子。
now = time.time()
ip = session.addr[0]
buckets = getattr(self, "_pair_failures", None)
if not isinstance(buckets, dict):
buckets = {}
mine = [t for t in buckets.get(ip, []) if now - t < 300]
buckets = {k: [t for t in v if now - t < 300]
for k, v in buckets.items() if any(now - t < 300 for t in v)}
buckets[ip] = mine
self._pair_failures = buckets
if len(mine) >= 10:
wait = max(1, int(300 - (now - mine[0])))
self._log(f"[配对] {ip} 失败次数过多,锁定中({wait} 秒)")
return {"ok": False,
"error": f"配对码错误次数过多,请等 {wait} 秒后再试"}
# 用 compare_digest,避免按字符比较带来的时间侧信道
if not secrets.compare_digest(code, expected):
mine.append(now)
self._log(f"[配对失败] 来自 {ip},配对码不匹配({len(mine)}/10)")
return {"ok": False, "error": "配对码错误"}
self._pair_failures[ip] = []
device_id = str(msg.get("deviceId") or "").strip() or f"dev-{session.addr[0]}"
device_name = str(msg.get("deviceName") or "手表").strip()
token = self.store.remember_device(device_id, device_name)
session.device_id = device_id
session.device_name = device_name
session.authed = True
# 首次配对成功后自动把配对窗口关上 —— 用户设完码只需配一次,
# 之后不用再操心"要不要关配对"。
if self.store.get("close_pair_after_first"):
self.pairing_enabled = False
self.store.set("allow_new_pairing", False)
self.store.set("close_pair_after_first", False)
self._log("[配对] 首台设备已配对,配对窗口已自动关闭")
self._log(f"[配对成功] {device_name} ({session.addr[0]})")
self._notify_ui()
return {
"ok": True,
"token": token,
"serverName": self.store.get("server_name", ""),
"protocol": P.PROTOCOL_VERSION,
}
def _do_auth(self, session: Session, msg: dict) -> dict:
device_id = str(msg.get("deviceId") or "").strip()
token = str(msg.get("token") or "").strip()
if not device_id or not token:
return {"ok": False, "error": "缺少设备标识或令牌"}
if not self.store.verify_token(device_id, token):
self._log(f"[鉴权失败] {device_id} 令牌无效")
return {"ok": False, "error": "令牌无效,请重新配对", "needPair": True}
session.device_id = device_id
session.device_name = str(msg.get("deviceName") or "").strip() or \
self.store.devices.get(device_id, {}).get("name", "设备")
session.authed = True
self._log(f"[已连接] {session.device_name} ({session.addr[0]})")
self._notify_ui()
return {
"ok": True,
"serverName": self.store.get("server_name", ""),
"protocol": P.PROTOCOL_VERSION,
}
# ---------------- 媒体/文件接收 ----------------
def _recv_media(self, session: Session, msg: dict, kind: str, label: str) -> dict:
seq = int(msg.get("seq") or 0)
last = _truthy(msg.get("last"))
data = P.b64decode(str(msg.get("data") or ""))
name = os.path.basename(str(msg.get("name") or "")) or f"{kind}_{int(time.time())}"
assembler = session.buffers[kind]
try:
blob = assembler.feed(seq, last, data)
except P.ChunkTooLarge as exc:
# 顺序不对或超限:丢掉这个 kind 的缓冲,回一条明确的错误
session.buffers.pop(kind, None)
raise ActionError(str(exc)) from exc
if blob is None:
# 中间分块不回包:避免回复洪泛与后续命令的消息错位
return None
target = self.store.received_dir() / name
target = _unique_path(target)
try:
target.write_bytes(blob)
except OSError as exc:
raise ActionError(f"保存失败: {exc}") from exc
size = len(blob)
import hashlib
digest = hashlib.sha256(blob).hexdigest()[:16]
self._log(f"[{label}] 收到 {name} ({_human(size)}) sha256={digest}")
if kind == P.CMD_IMAGE:
note = self.actions.show_image(str(target),
str(msg.get("title") or ""))
elif kind == P.CMD_AUDIO:
note = self.actions.play_audio(str(target))
else:
note = f"已保存到 {target}"
return {"ok": True, "note": note, "path": str(target), "size": size}
# 一次最多返回多少个文件。
# 为什么不返回全部:手表屏幕就那么大,几千条也翻不动,
# 而且一条 JSON 太长会把单条消息撑爆。
FILE_LIST_LIMIT = 100
def _file_list(self) -> dict:
directory = self.store.received_dir()
try:
entries = [p for p in directory.iterdir() if p.is_file()]
except OSError as exc:
raise ActionError(f"读取目录失败: {exc}") from exc
total = len(entries)
entries.sort(key=lambda p: p.stat().st_mtime, reverse=True)
shown = entries[:self.FILE_LIST_LIMIT]
files = []
for entry in shown:
try:
files.append({
"name": entry.name,
"size": entry.stat().st_size,
"mtime": entry.stat().st_mtime,
})
except OSError:
# 正好被别的进程删掉了,跳过就好,别让整条列表失败
continue
return {
"ok": True,
"dir": str(directory),
"files": files,
# 说清楚有没有被截断 —— 不能默默藏起来,
# 用户会以为文件丢了(这个坑在测试里踩过一次)。
"total": total,
"shown": len(files),
"truncated": total > len(files),
}
def _file_pull(self, session: Session, path: str) -> dict:
target = self._resolve_received(path)
if target is None:
raise ActionError(f"文件不存在: {path}")
blob = target.read_bytes()
if not session.send_chunks(P.EVT_FILE, target.name, blob):
raise ActionError("发送文件失败:连接已断开")
return {"ok": True, "name": target.name, "size": len(blob)}
def _is_sensitive(self, path: Path) -> bool:
"""程序自己的数据目录:配对码、设备令牌都在这儿。
注意:接收目录必须豁免 —— 它在程序目录里面,
不豁免的话拉文件会全被挡住("文件不存在")。
"""
try:
received = Path(self.store.received_dir()).resolve()
path.resolve().relative_to(received)
return False
except (ValueError, OSError):
pass
if self.features is not None:
try:
return self.features.is_sensitive(path)
except Exception: # noqa: BLE001
pass
try:
folder = Path(self.store.dir).resolve()
resolved = path.resolve()
return resolved == folder or folder in resolved.parents
except OSError:
return False
def _resolve_received(self, path: str) -> Path | None:
"""按名字在接收目录里定位文件。
同名文件会被 _unique_path 存成 name_1.bin / name_2.bin,
所以这里必须取**最新的一份**,否则「同名文件发两次」时
客户端拉回的会是上一轮的旧文件。
"""
raw = Path(path)
if raw.is_absolute() and raw.is_file():
# 只允许拉取「接收目录」或「用户自己的目录」里的文件。
# 否则这条命令等于"能下载电脑上任意文件",是个大洞。
try:
resolved = raw.resolve()
except OSError:
return None
allowed = [self.store.received_dir()]
if self.features is not None:
allowed.extend(self.features.allowed_roots())
if self._is_sensitive(resolved):
self._log(f"[文件] 拒绝拉取数据目录: {raw}")
return None
for root in allowed:
try:
resolved.relative_to(root.resolve())
return resolved
except (ValueError, OSError):
continue
self._log(f"[文件] 拒绝拉取允许范围外的路径: {raw}")
return None
# 相对路径也要过一遍检查。Path("data/settings.json").is_absolute() 是 False,
# 上面那一段挡不住它 —— 而 EXE 的 CWD 就是程序目录,
# 于是已配对设备能把 data/settings.json(配对码 + 全部设备令牌)拉走。
#
# 注意:这里**不能**因为"不在允许目录内"就 return None ——
# 后面的逻辑还要按文件名在接收目录里找。只有命中敏感目录时才直接拒绝。
try:
relative = (Path.cwd() / raw).resolve()
except OSError:
relative = None
if relative is not None and relative.is_file():
# 只有真的命中一个**存在的文件**时才做敏感判断。
# 否则 (cwd/文件名) 会拼出一个程序目录内的假路径,
# 被误判成敏感目录 -> 直接返回 None -> 走不到下面的按名字查找,
# 表现就是"接收文件永远拿不到东西"。
if self._is_sensitive(relative):
self._log(f"[文件] 拒绝拉取数据目录: {raw}")
return None
allowed_roots = [self.store.received_dir()]
if self.features is not None:
allowed_roots.extend(self.features.allowed_roots())
for root in allowed_roots:
try:
relative.relative_to(root.resolve())
return relative
except (ValueError, OSError):
continue
self._log(f"[文件] 拒绝拉取允许范围外的路径: {relative}")
return None
# 落到下面:按文件名在「接收目录」里找最新的那一份
directory = self.store.received_dir()
basename = os.path.basename(path)
if not basename:
return None
stem, suffix = os.path.splitext(basename)
pattern = re.compile(
rf"^{re.escape(stem)}(?:_\d+)?{re.escape(suffix)}$", re.IGNORECASE)
matches: list[Path] = []
try:
for entry in directory.iterdir():
if entry.is_file() and pattern.match(entry.name):
matches.append(entry)
except OSError:
return None
if matches:
return max(matches, key=lambda p: p.stat().st_mtime)
return None
# ---------------- 屏幕镜像 / 截屏 ----------------
def _ensure_capturer(self) -> ScreenCapturer | None:
"""拿到屏幕采集器。**设置变了要重建。**
以前只判断 self._capturer is None,于是改了分辨率/画质之后
正在推流的会话永远用旧设置 —— 用户会以为"改了没用"。
现在记住建它时用的参数,参数一变就重建。
"""
width = int(self.store.get("screen_max_width", 480) or 480)
quality = int(self.store.get("screen_quality", 55) or 55)
signature = (width, quality)
if self._capturer is not None and \
getattr(self, "_capturer_signature", None) == signature:
return self._capturer
self._capturer = ScreenCapturer(max_width=width, quality=quality)
self._capturer_signature = signature
return self._capturer
def _require_screen(self) -> ScreenCapturer:
if not self.store.get("allow_screen", True):
raise ActionError("被控端已关闭屏幕共享(可在被控端窗口重新开启)")
capturer = self._ensure_capturer()
if capturer is None or not capturer.available():
raise ActionError("当前系统不支持屏幕捕获")
return capturer
def _screen_start(self, session: Session, msg: dict) -> dict:
capturer = self._require_screen()
fps = _clamp(int(msg.get("fps") or self.store.get("screen_fps", 3) or 3), 1, 15)
quality = _clamp(int(msg.get("quality") or self.store.get("screen_quality", 55) or 55),
20, 90)
max_width = _clamp(int(msg.get("maxWidth") or self.store.get("screen_max_width", 480) or 480),
160, 1920)
capturer.quality = quality
capturer.max_width = max_width
session.wants_screen = True
self._ensure_streamer().start(fps)
self._set_indicator(True)
who = session.device_name or session.addr[0]
self._log(f"[屏幕] {who} 开始观看屏幕镜像({fps} fps, {max_width}px, 质量 {quality})")
self._notify_ui()
return {"ok": True, "fps": fps, "maxWidth": max_width, "quality": quality,
"note": f"屏幕共享已开始({fps} fps)"}
def _screen_stop(self, session: Session) -> dict:
session.wants_screen = False
if not any(s.wants_screen for s in self._authed_sessions()):
self._stop_streamer()
self._log(f"[屏幕] {session.device_name or session.addr[0]} 停止观看")
self._notify_ui()
return {"ok": True, "note": "屏幕共享已停止"}
def _screenshot(self, session: Session, msg: dict) -> dict:
capturer = self._require_screen()
quality = _clamp(int(msg.get("quality") or self.store.get("screen_quality", 55) or 55),
30, 95)
max_width = _clamp(int(msg.get("maxWidth") or 1280), 160, 3840)
capturer.quality = quality
capturer.max_width = max_width
# 截屏也是一次屏幕读取,同样给出可见提示(短暂闪烁)
self._flash_indicator()
try:
jpeg, width, height = capturer.grab_jpeg()
except ScreenError as exc:
raise ActionError(str(exc)) from exc
name = f"screenshot_{time.strftime('%Y%m%d_%H%M%S')}.jpg"
who = session.device_name or session.addr[0]
self._log(f"[屏幕] {who} 截取屏幕 {width}x{height}({_human(len(jpeg))})")
return {"ok": True, "name": name, "w": width, "h": height,
"size": len(jpeg), "data": P.b64encode(jpeg), "note": "截屏已获取"}
# ---- 帧流 ----
def _ensure_streamer(self) -> FrameStreamer:
if self._streamer is None:
self._streamer = FrameStreamer(
self._ensure_capturer(),
self._on_frame,
on_stop=self._on_stream_stopped)
return self._streamer
def _on_frame(self, jpeg: bytes, width: int, height: int) -> bool:
"""把一帧推给所有正在观看的设备。返回 False 表示应停止推流。"""
viewers = [s for s in self._authed_sessions() if s.wants_screen]
if not viewers:
return False
payload = {
"event": P.EVT_FRAME,
"w": width, "h": height,
"size": len(jpeg),
"ts": time.time(),
"data": P.b64encode(jpeg),
}
delivered = 0
for viewer in viewers:
if viewer.send(payload):
delivered += 1
else:
viewer.wants_screen = False
return delivered > 0
def _on_stream_stopped(self) -> None:
self._set_indicator(False)
self._notify_ui()
def _stop_streamer(self) -> None:
if self._streamer is not None and self._streamer.running:
self._log("[屏幕] 已停止屏幕镜像")
if self._streamer is not None:
self._streamer.stop()
self._set_indicator(False)
def _set_indicator(self, active: bool) -> None:
"""开启/关闭被控端上的「屏幕正在被共享」可见提示。
这个提示是**强制**的:只要在推流就一定显示,界面里没有关闭开关。
远控工具必须让使用者知道屏幕正在被共享。
"""
if self._screen_indicator_on == active:
return
self._screen_indicator_on = active
if self.ui and hasattr(self.ui, "set_screen_sharing"):
try:
self.ui.set_screen_sharing(active)
except Exception:
pass
def _flash_indicator(self) -> None:
"""截屏时短暂显示提示。"""
if self.ui and hasattr(self.ui, "flash_screen_indicator"):
try:
self.ui.flash_screen_indicator(2.5)
except Exception:
pass
# ---------------- 剪贴板同步 ----------------
def _clip_loop(self) -> None:
time.sleep(2.0)
while self._running:
time.sleep(1.5)
if not self._authed_sessions():
continue
try:
text = self.actions.clip_get()
except Exception:
continue
if text and text != self._last_clip:
self._last_clip = text
self.broadcast({"event": P.EVT_CLIP, "text": text})
def _authed_sessions(self) -> list[Session]:
with self._lock:
return [s for s in self.sessions if s.authed]
# ---------------- 广播 / UI 通知 ----------------
def broadcast(self, obj: dict) -> None:
for session in self._authed_sessions():
session.send(obj)
def stop_screen_sharing(self) -> int:
"""本机使用者主动停止屏幕共享(点击红色提示条上的按钮)。
这是被控端的最终否决权:任何时候都能一键掐断。
"""
with self._lock:
sessions = list(self.sessions)
affected = 0
for session in sessions:
if session.wants_screen:
session.wants_screen = False
affected += 1
self._stop_streamer()
self._set_indicator(False)
self.broadcast({"event": "screen_stopped", "reason": "被控端本机已停止共享"})
self._log(f"[屏幕] 本机使用者停止了屏幕共享(影响 {affected} 台设备)")
self._notify_ui()
return affected
def disconnect_all(self) -> int:
with self._lock:
sessions = list(self.sessions)
for session in sessions:
session.close()
self._log(f"已断开 {len(sessions)} 个连接")
self._notify_ui()
return len(sessions)
def status(self) -> dict:
"""给界面用的实时状态。"""
with self._lock:
sessions = list(self.sessions)
devices = []
for s in sessions:
devices.append({
"addr": s.addr[0],
"name": s.device_name or "未鉴权",
"authed": s.authed,
"since": s.connected_at,
"last_seen": s.last_seen,
})
return {
"running": self._running,
"port": self.port,
"pairing_code": self.store.pairing_code(),
"pairing_enabled": self.pairing_enabled,
"connected": devices,
"paired_saved": self.store.device_list(),
"screen_sharing": bool(self._streamer and self._streamer.running),
"screen_viewers": sum(1 for s in sessions if s.wants_screen),
"screen_fps": round(self._streamer.actual_fps, 1)
if self._streamer and self._streamer.running else 0.0,
"discovery": self._discovery.status() if self._discovery else {"running": False},
"total_connections": self._total_connections,
"local_ip": local_ip(),
}
def _notify_ui(self) -> None:
if self.ui and hasattr(self.ui, "on_state_changed"):
try:
self.ui.on_state_changed()
except Exception:
pass
def _log(self, text: str) -> None:
if self.ui and hasattr(self.ui, "log"):
try:
self.ui.log(text)
except Exception:
pass
def _clamp(value: int, low: int, high: int) -> int:
return max(low, min(high, int(value)))
def _unique_path(path: Path) -> Path:
if not path.exists():
return path
stem, suffix = path.stem, path.suffix
for index in range(1, 1000):
candidate = path.with_name(f"{stem}_{index}{suffix}")
if not candidate.exists():
return candidate
return path.with_name(f"{stem}_{int(time.time())}{suffix}")
def _human(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"
def lan_ips() -> list[str]:
"""列出所有可用的局域网 IPv4 地址,优先真实网卡。
这台机器上装了 Tailscale,`gethostbyname(hostname)` 会返回 Tailscale 的
169.254 链路地址——那是没用的。所以这里枚举网卡并做优先级排序。
"""
candidates: list[tuple[int, str]] = []
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
addr = info[4][0]
if addr and not addr.startswith("127."):
candidates.append((_ip_rank(addr), addr))
except OSError:
pass
# 主路由地址(UDP 连 8.8.8.8 不会真的发包,只用来问内核选哪张网卡)
try:
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
probe.connect(("8.8.8.8", 80))
addr = probe.getsockname()[0]
probe.close()
if addr and not addr.startswith("127."):
candidates.append((0, addr)) # 最优
except OSError:
pass
ranked: dict[str, int] = {}
for rank, addr in candidates:
if addr not in ranked or rank < ranked[addr]:
ranked[addr] = rank
return [addr for addr, _ in sorted(ranked.items(), key=lambda kv: kv[1])]
def _ip_rank(addr: str) -> int:
"""数字越小越可能是真实局域网地址。"""
if addr.startswith("169.254."):
return 90 # APIPA / Tailscale 链路地址
if addr.startswith("192.168."):
return 10
if addr.startswith("10."):
return 20
if addr.startswith("172."):
try:
second = int(addr.split(".")[1])
if 16 <= second <= 31:
return 25
except (ValueError, IndexError):
pass
return 60
def local_ip() -> str:
"""本机在局域网中最可能的地址。"""
ips = lan_ips()
return ips[0] if ips else "127.0.0.1"