Files
ykxmz/windows/lanremote/screen.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

244 lines
8.8 KiB
Python

# -*- coding: utf-8 -*-
"""屏幕捕获:为「实时屏幕镜像」和「单次截屏」提供 JPEG 帧。
只使用 Pillow(已随程序打包),不引入额外依赖。
"""
from __future__ import annotations
import io
import sys
import threading
import time
IS_WINDOWS = sys.platform.startswith("win")
class ScreenError(RuntimeError):
pass
def _grab_error_text(error: Exception) -> str:
"""把抓屏失败翻译成人话。
锁屏是最常见的失败原因(离开电脑就会锁),
而且 Windows 锁屏时**不给抓屏**,报的是 OSError: screen grab failed。
直接透传这句用户看不懂,所以这里给出可操作的提示。
"""
text = str(error).lower()
if "screen grab failed" in text or "grab" in text:
return ("抓屏失败:电脑现在锁屏了(或没有桌面会话)。"
"请先解锁屏幕再试。")
return f"抓屏失败: {error}"
class ScreenCapturer:
"""抓屏并编码为 JPEG。线程安全(内部串行化)。"""
def __init__(self, max_width: int = 960, quality: int = 72, monitor: int = 1,
dedup: bool = True):
self.max_width = int(max_width)
self.quality = int(quality)
self.monitor = int(monitor)
# 画面没变就不重复编码(见 grab_jpeg)
self.dedup = bool(dedup)
self._lock = threading.RLock()
self._last_size = (0, 0)
self._last_digest = None
# ------------------------------------------------------------------
def available(self) -> bool:
try:
import importlib
importlib.import_module("PIL.ImageGrab")
return True
except Exception:
return False
def grab_jpeg(self, force: bool = False) -> tuple[bytes, int, int]:
"""抓一帧,返回 (jpeg 字节, 宽, 高)。"""
if not IS_WINDOWS:
# macOS 也可用 ImageGrab;其他平台给出明确错误
pass
try:
from PIL import Image, ImageGrab
except ImportError as exc: # pragma: no cover
raise ScreenError("缺少 Pillow,无法抓屏") from exc
with self._lock:
try:
image = ImageGrab.grab()
except Exception as exc: # noqa: BLE001
raise ScreenError(_grab_error_text(exc)) from exc
if image.mode not in ("RGB", "L"):
image = image.convert("RGB")
width, height = image.size
if self.max_width and width > self.max_width:
ratio = self.max_width / float(width)
new_size = (self.max_width, max(1, int(height * ratio)))
image = image.resize(new_size, Image.BILINEAR)
width, height = image.size
# 画面没变就不编码。
# 用 32x18 的小缩略图做指纹:算一次只要 0.1ms 左右,
# 但能省掉 17ms 的 JPEG 编码和之后的传输。
# 手表看静态桌面是最常见的情况,这一条省得最多。
digest = None
if self.dedup and not force:
try:
thumb = image.resize((32, 18), Image.BILINEAR)
digest = hash(thumb.tobytes())
except Exception: # noqa: BLE001
digest = None
if digest is not None and digest == self._last_digest:
self._last_size = (width, height)
return b"", width, height
self._last_digest = digest
buffer = io.BytesIO()
try:
image.save(buffer, format="JPEG", quality=self.quality,
optimize=False, subsampling=2)
except Exception as exc: # noqa: BLE001
raise ScreenError(f"JPEG 编码失败: {exc}") from exc
self._last_size = (width, height)
return buffer.getvalue(), width, height
@property
def last_size(self) -> tuple[int, int]:
return self._last_size
def screen_size(self) -> tuple[int, int]:
"""原始屏幕分辨率(用于界面显示)。"""
try:
from PIL import ImageGrab
image = ImageGrab.grab()
return image.size
except Exception:
return (0, 0)
class FrameStreamer:
"""按固定帧率在后台线程推帧,供服务端广播。
画面静止时不会傻发同样的画面(用 32x18 缩略图指纹判断),
但每隔 HEARTBEAT 秒会强制发一帧,让客户端知道流还活着。
"""
# 画面没变时,最多隔这么久也要发一帧(心跳)
HEARTBEAT = 1.5
def __init__(self, capturer: ScreenCapturer, on_frame, on_stop=None):
self.capturer = capturer
self.on_frame = on_frame # on_frame(jpeg: bytes, w: int, h: int)
self.on_stop = on_stop
self.fps = 3.0
self._thread: threading.Thread | None = None
self._stop = threading.Event()
self.frames_sent = 0
self.last_error = ""
self._started_at = 0.0
# 上次真正发出帧的时间(心跳判断用)
self._last_sent = 0.0
@property
def running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def start(self, fps: float = 3.0) -> None:
if self.running:
self.fps = max(0.5, min(15.0, float(fps)))
return
self.fps = max(0.5, min(30.0, float(fps)))
self._stop.clear()
self.frames_sent = 0
self.last_error = ""
self._started_at = time.time()
self._thread = threading.Thread(target=self._loop, name="screen-stream",
daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=3.0)
self._thread = None
if self.on_stop:
try:
self.on_stop()
except Exception:
pass
def _loop(self) -> None:
interval = 1.0 / self.fps
# 画面静止时把采样间隔逐级拉长(最多 4 倍),省 CPU。
# 抓屏本身就要 30ms 左右,静止时没必要一直满速抓。
idle = 1.0
while not self._stop.is_set():
started = time.time()
try:
jpeg, width, height = self.capturer.grab_jpeg()
except ScreenError as exc:
self.last_error = str(exc)
time.sleep(0.5)
continue
if not jpeg:
# 画面没变(capturer 做了去重)。
# 但不能一直不发 —— 客户端会以为流断了。
# 所以每隔 HEARTBEAT 秒强制发一帧当心跳。
now = time.time()
if now - self._last_sent >= self.HEARTBEAT:
try:
jpeg, width, height = self.capturer.grab_jpeg(
force=True)
except ScreenError as exc:
self.last_error = str(exc)
time.sleep(0.5)
continue
if jpeg:
self.frames_sent += 1
self._last_sent = now
idle = 1.0
try:
if self.on_frame(jpeg, width, height) is False:
break
except Exception as exc: # noqa: BLE001
self.last_error = str(exc)
break
elapsed = time.time() - started
wait = interval - elapsed
if wait > 0:
self._stop.wait(wait)
continue
idle = min(4.0, idle * 1.6)
self._stop.wait(interval * idle)
continue
self._last_sent = time.time()
idle = 1.0
self.frames_sent += 1
try:
if self.on_frame(jpeg, width, height) is False:
break # 没有观看者了,自动结束
except Exception as exc: # noqa: BLE001
self.last_error = str(exc)
break
elapsed = time.time() - started
wait = interval - elapsed
if wait > 0:
self._stop.wait(wait)
@property
def uptime(self) -> float:
return time.time() - self._started_at if self._started_at else 0.0
@property
def actual_fps(self) -> float:
uptime = self.uptime
return (self.frames_sent / uptime) if uptime > 0.2 else 0.0