Files
ykxmz/windows/lanremote/server.py
T
xmz7891 b62ac865ea ykxmz 1.5.0:修复手表端 TLS 证书校验 + MTU 修复网络稳定性
被控端
- 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%)
- 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败)
- 每分钟回头找局域网,自动切回快 400 倍的路径
- 画面心跳帧:静止时省流量,客户端仍知道流是活的
- 锁屏时给出可操作的提示

手表端
- 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException
- 连接错误翻译成人话(证书/时间/解析/超时分别提示)
- 经中转连上后用服务器报的地址刷新已配对记录
- 新增局域网连接页面、服务器延迟检测

服务器端
- 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502)
- 网络调优脚本 net-tune.sh(开机自动应用)
- 管理页:批量删除、网络体检、下载区
- 隧道改 HTTP/2 + 双连接

文档
- 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
2026-10-06 19:01:05 +08:00

1240 lines
48 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
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 和电脑经常在同一个局域网 -> 走第一个(局域网直连,
# 几十毫秒,而且地址永远不变)
# 电脑在外面 -> 第一个连不上,自动切第二个(Cloudflare 隧道,
# 600 毫秒左右,地址固定 relay.xxmxxs.top)
DEFAULT_RELAY_URL = "192.168.10.105:8080;https://relay.xxmxxs.top"
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
# 配了中转地址就同时启用跨网络通道(和局域网监听并存,不冲突)
try:
self.enable_relay()
except Exception as exc: # noqa: BLE001
self._log(f"启用中转失败:{exc}")
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()
# 启动局域网自动发现,让手表不用输 IP
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._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
self._log(f"没配过中转地址,用内置默认值:{target}")
if self._relay is not None:
return True
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:
session.send(reply)
except OSError:
pass
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_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:
if not self.pairing_enabled:
return {"ok": False, "error": "被控端已关闭新设备配对"}
code = str(msg.get("code") or "").strip()
expected = self.store.pairing_code()
if not 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"