mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 18:20:43 +00:00
按用户定的规矩,这一轮有新功能,升第二位: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 个阶段全部通过。
1307 lines
52 KiB
Python
1307 lines
52 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""被控端 TCP 服务:配对、鉴权、命令分发、文件收发。"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import secrets
|
||
import socket
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
|
||
from . import protocol as P
|
||
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()}
|
||
|
||
return {"ok": False, "error": f"未知命令: {cmd}"}
|
||
|
||
# ---------------- 握手实现 ----------------
|
||
|
||
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"
|