mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-19 14:10:43 +00:00
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 + 双连接 文档 - 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
This commit is contained in:
@@ -0,0 +1,512 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""静默运行模式:启动时没有任何窗口。
|
||||
|
||||
和 AgentUI 的区别:
|
||||
没有主控制窗口、没有托盘、没有终端。
|
||||
但 **屏幕共享提示保留** —— 有人在看你的屏幕时,屏幕右下角会出现一条提示。
|
||||
这在有配对认证的前提下依然保留,理由:让电脑前面的人知道"此刻有人在看"。
|
||||
|
||||
内容窗口(投图、全屏大字、白板)是**手表明确要求显示**的,所以照常工作 ——
|
||||
它们不是"程序自己弹出来的窗口"。Tk 根窗口按需惰性创建并保持隐藏。
|
||||
|
||||
没有 Tk 也能用的能力(剪贴板、屏幕分辨率)直接用 Win32 API 实现,
|
||||
不依赖界面。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
IS_WINDOWS = False
|
||||
try:
|
||||
import tkinter as tk
|
||||
IS_WINDOWS = True
|
||||
except Exception: # noqa: BLE001
|
||||
tk = None
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 不依赖 Tk 的系统能力
|
||||
# ======================================================================
|
||||
|
||||
class _Clipboard:
|
||||
"""直接读写 Windows 剪贴板,不需要 Tk。"""
|
||||
|
||||
CF_UNICODETEXT = 13
|
||||
GMEM_MOVEABLE = 0x0002
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> str:
|
||||
if not IS_WINDOWS:
|
||||
return ""
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
# 64 位下必须声明,否则句柄会被截断
|
||||
user32.GetClipboardData.restype = ctypes.c_void_p
|
||||
kernel32.GlobalLock.restype = ctypes.c_void_p
|
||||
kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
|
||||
kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
|
||||
|
||||
if not user32.OpenClipboard(None):
|
||||
return ""
|
||||
try:
|
||||
handle = user32.GetClipboardData(cls.CF_UNICODETEXT)
|
||||
if not handle:
|
||||
return ""
|
||||
pointer = kernel32.GlobalLock(handle)
|
||||
if not pointer:
|
||||
return ""
|
||||
try:
|
||||
return ctypes.c_wchar_p(pointer).value or ""
|
||||
finally:
|
||||
kernel32.GlobalUnlock(handle)
|
||||
finally:
|
||||
user32.CloseClipboard()
|
||||
|
||||
@classmethod
|
||||
def set(cls, text: str) -> bool:
|
||||
if not IS_WINDOWS:
|
||||
return False
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32.GlobalAlloc.restype = ctypes.c_void_p
|
||||
kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
|
||||
kernel32.GlobalLock.restype = ctypes.c_void_p
|
||||
kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
|
||||
kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
|
||||
user32.SetClipboardData.restype = ctypes.c_void_p
|
||||
user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
|
||||
|
||||
data = (text + "\0").encode("utf-16-le")
|
||||
handle = kernel32.GlobalAlloc(cls.GMEM_MOVEABLE, len(data))
|
||||
if not handle:
|
||||
return False
|
||||
pointer = kernel32.GlobalLock(handle)
|
||||
if not pointer:
|
||||
return False
|
||||
ctypes.memmove(pointer, data, len(data))
|
||||
kernel32.GlobalUnlock(handle)
|
||||
|
||||
if not user32.OpenClipboard(None):
|
||||
return False
|
||||
try:
|
||||
user32.EmptyClipboard()
|
||||
return bool(user32.SetClipboardData(cls.CF_UNICODETEXT, handle))
|
||||
finally:
|
||||
user32.CloseClipboard()
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 静默 UI
|
||||
# ======================================================================
|
||||
|
||||
class HeadlessUI:
|
||||
"""给静默模式用的界面替身。
|
||||
|
||||
接口和 AgentUI 对齐,这样 Actions / RemoteServer 一行都不用改。
|
||||
"""
|
||||
|
||||
def __init__(self, store, log_file: Path | None = None,
|
||||
allow_content_windows: bool = True):
|
||||
self.store = store
|
||||
self.log_file = Path(log_file) if log_file else None
|
||||
self.allow_content_windows = allow_content_windows
|
||||
|
||||
self._root = None # 惰性创建,见下面的 root 属性
|
||||
self._tk_ready = False
|
||||
self._tk_lock = threading.RLock()
|
||||
self._ui_lock = threading.RLock()
|
||||
self._queue: queue.Queue = queue.Queue()
|
||||
|
||||
self.server = None
|
||||
self.state = "stopped"
|
||||
self.port = 0
|
||||
|
||||
self._screen_on = False
|
||||
self._notices = []
|
||||
self._notice = None
|
||||
self._notice_inner = None
|
||||
self.mirroring = False
|
||||
|
||||
self._pump_thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
self._stopping = False
|
||||
|
||||
# ---------------- 日志 ----------------
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
line = f"[{time.strftime('%H:%M:%S')}] {message}"
|
||||
if self.log_file:
|
||||
try:
|
||||
with self.log_file.open("a", encoding="utf-8") as handle:
|
||||
handle.write(line + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 别名,有些地方用 set_status
|
||||
def set_status(self, text: str) -> None:
|
||||
self.log(text)
|
||||
|
||||
def notify(self, title: str, text: str) -> None:
|
||||
"""弹一个小通知窗(屏幕右下角,几秒后自动消失)。
|
||||
|
||||
之前这里只写日志,所以手表点「弹通知」电脑上毫无反应。
|
||||
静默模式的意思是"开机不弹控制窗口",而不是
|
||||
"手表明确要求显示的东西也不显示" —— 投图、全屏大字、通知
|
||||
都属于"明确要求",照常显示。
|
||||
"""
|
||||
self.log(f"[通知] {title}: {text}")
|
||||
root = self._ensure_root()
|
||||
if root is None:
|
||||
return
|
||||
|
||||
def build():
|
||||
try:
|
||||
win = tk.Toplevel(root)
|
||||
win.title(f"通知 - {title}")
|
||||
win.overrideredirect(True)
|
||||
win.attributes("-topmost", True)
|
||||
win.configure(bg="#ffffff")
|
||||
|
||||
box = tk.Frame(win, bg="#ffffff", highlightthickness=1,
|
||||
highlightbackground="#d0d7de")
|
||||
box.pack(fill="both", expand=True)
|
||||
tk.Frame(box, bg="#185ce8", height=4).pack(fill="x")
|
||||
tk.Label(box, text=title or "通知", bg="#ffffff",
|
||||
fg="#1f2328", font=("Microsoft YaHei UI", 11, "bold"),
|
||||
anchor="w").pack(fill="x", padx=14, pady=(10, 2))
|
||||
tk.Label(box, text=text or "", bg="#ffffff", fg="#656d76",
|
||||
font=("Microsoft YaHei UI", 10), anchor="w",
|
||||
justify="left", wraplength=260).pack(
|
||||
fill="x", padx=14, pady=(0, 12))
|
||||
win.update_idletasks()
|
||||
|
||||
screen_w, screen_h = self.screen_size()
|
||||
width = max(240, win.winfo_reqwidth())
|
||||
height = win.winfo_reqheight()
|
||||
win.geometry(f"+{screen_w - width - 24}+{screen_h - height - 72}")
|
||||
|
||||
def close(_event=None):
|
||||
try:
|
||||
win.destroy()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
win.bind("<Button-1>", close)
|
||||
win.after(6000, close)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log(f"通知显示失败: {exc}")
|
||||
|
||||
self._notices.append(None) # 先占位,build 里回填
|
||||
index = len(self._notices) - 1
|
||||
|
||||
def build():
|
||||
try:
|
||||
win = tk.Toplevel(root)
|
||||
win.title(f"通知 - {title}")
|
||||
win.overrideredirect(True)
|
||||
win.attributes("-topmost", True)
|
||||
win.configure(bg="#ffffff")
|
||||
self._notices[index] = win
|
||||
|
||||
box = tk.Frame(win, bg="#ffffff", highlightthickness=1,
|
||||
highlightbackground="#d0d7de")
|
||||
box.pack(fill="both", expand=True)
|
||||
tk.Frame(box, bg="#185ce8", height=4).pack(fill="x")
|
||||
tk.Label(box, text=title or "通知", bg="#ffffff",
|
||||
fg="#1f2328", font=("Microsoft YaHei UI", 11, "bold"),
|
||||
anchor="w").pack(fill="x", padx=14, pady=(10, 2))
|
||||
tk.Label(box, text=text or "", bg="#ffffff", fg="#656d76",
|
||||
font=("Microsoft YaHei UI", 10), anchor="w",
|
||||
justify="left", wraplength=260).pack(
|
||||
fill="x", padx=14, pady=(0, 12))
|
||||
win.update_idletasks()
|
||||
|
||||
screen_w, screen_h = self.screen_size()
|
||||
width = max(240, win.winfo_reqwidth())
|
||||
height = win.winfo_reqheight()
|
||||
win.geometry(f"+{screen_w - width - 24}+{screen_h - height - 72}")
|
||||
|
||||
def close(_event=None):
|
||||
try:
|
||||
win.destroy()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
self._notices[index] = None
|
||||
except (IndexError, AttributeError):
|
||||
pass
|
||||
|
||||
win.bind("<Button-1>", close)
|
||||
win.after(6000, close)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log(f"通知显示失败: {exc}")
|
||||
|
||||
try:
|
||||
self._queue.put(build)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def close_notifications(self) -> bool:
|
||||
"""关掉所有还在显示的通知窗。返回是否关掉了至少一个。"""
|
||||
handled = False
|
||||
for index, win in enumerate(list(getattr(self, "_notices", []))):
|
||||
if win is None:
|
||||
continue
|
||||
handled = True
|
||||
|
||||
def build(target=win, slot=index):
|
||||
try:
|
||||
target.destroy()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
try:
|
||||
self._notices[slot] = None
|
||||
except (IndexError, AttributeError):
|
||||
pass
|
||||
|
||||
try:
|
||||
self._queue.put(build)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return handled
|
||||
|
||||
# ---------------- Tk 根窗口(惰性、隐藏)----------------
|
||||
|
||||
@property
|
||||
def root(self):
|
||||
"""Tk 根窗口,按需创建且**始终隐藏**。
|
||||
|
||||
AgentUI 已经删掉了,但 Actions 里投图/全屏大字这些还要一个父窗口。
|
||||
这里惰性建一个 withdrew 的根窗口 —— 它本身永远不显示,
|
||||
所以启动时屏幕上依然什么都没有。
|
||||
"""
|
||||
return self._ensure_root()
|
||||
|
||||
@root.setter
|
||||
def root(self, value):
|
||||
self._root = value
|
||||
|
||||
def _ensure_root(self):
|
||||
"""按需启动界面线程,并等它把**隐藏的** Tk 根窗口建好。
|
||||
|
||||
关键:Tk 解释器必须在**同一个线程**里创建和使用。
|
||||
所以根窗口由泵线程自己建 —— 如果在主线程建、在泵线程 update,
|
||||
Tkinter 会因为线程亲和性直接抛异常,泵线程当场就死了。
|
||||
"""
|
||||
if not IS_WINDOWS or tk is None:
|
||||
return None
|
||||
if threading.current_thread() is self._pump_thread:
|
||||
return self._root # 已经在界面线程里
|
||||
|
||||
with self._tk_lock:
|
||||
if self._root is not None:
|
||||
return self._root
|
||||
if self._pump_thread is None:
|
||||
self._ready.clear()
|
||||
self._pump_thread = threading.Thread(
|
||||
target=self._pump, daemon=True, name="headless-ui")
|
||||
self._pump_thread.start()
|
||||
self._ready.wait(8.0)
|
||||
return self._root
|
||||
|
||||
def _pump(self) -> None:
|
||||
"""界面线程:Tk 在这里建,也只有这里能碰它。"""
|
||||
try:
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
root.title("")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log(f"创建界面根窗口失败: {exc}")
|
||||
self._ready.set()
|
||||
return
|
||||
|
||||
self._root = root
|
||||
self._ready.set()
|
||||
|
||||
while not self._stopping:
|
||||
try:
|
||||
task = self._queue.get(timeout=0.05)
|
||||
except queue.Empty:
|
||||
task = None
|
||||
if task is not None:
|
||||
try:
|
||||
task()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log(f"界面任务出错: {exc}")
|
||||
try:
|
||||
root.update()
|
||||
except Exception: # noqa: BLE001
|
||||
break
|
||||
|
||||
try:
|
||||
root.destroy()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._root = None
|
||||
|
||||
def ui_call(self, fn, timeout: float = 8.0):
|
||||
"""在界面线程里执行 fn。
|
||||
|
||||
Tk 只允许在拥有它的那个线程里操作,所以这里一律丢给泵线程;
|
||||
调用方本来就在泵线程里的话就地执行(否则会自己等自己)。
|
||||
"""
|
||||
if threading.current_thread() is self._pump_thread:
|
||||
return fn()
|
||||
|
||||
root = self._ensure_root()
|
||||
if root is None:
|
||||
return fn() # 没有 Tk 环境(非 Windows),就地执行
|
||||
|
||||
result: list = []
|
||||
error: list = []
|
||||
done = threading.Event()
|
||||
|
||||
def task():
|
||||
try:
|
||||
result.append(fn())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
error.append(exc)
|
||||
finally:
|
||||
done.set()
|
||||
|
||||
self._queue.put(task)
|
||||
if not done.wait(timeout):
|
||||
raise TimeoutError("界面响应超时")
|
||||
if error:
|
||||
raise error[0]
|
||||
return result[0] if result else None
|
||||
|
||||
def screen_size(self) -> tuple:
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
user32 = ctypes.windll.user32
|
||||
return (user32.GetSystemMetrics(0), user32.GetSystemMetrics(1))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return (1920, 1080)
|
||||
|
||||
def center_window(self, win, width: int = 0, height: int = 0) -> None:
|
||||
pass # 静默模式不做居中
|
||||
|
||||
# ---------------- 服务端会用到的回调 ----------------
|
||||
|
||||
def on_state_changed(self, state: str) -> None:
|
||||
self.state = state
|
||||
|
||||
def flash_screen_indicator(self, seconds: float = 2.5) -> None:
|
||||
self.log("(截屏)")
|
||||
|
||||
def set_screen_sharing(self, on: bool) -> None:
|
||||
"""屏幕共享提示。
|
||||
|
||||
这是**故意保留**的:有人在看你的屏幕时,屏幕右下角会出现一条提示。
|
||||
静默模式只是不显示主控制窗口,不是"让被看的人不知道"。
|
||||
"""
|
||||
if on == self._screen_on:
|
||||
return
|
||||
self._screen_on = on
|
||||
try:
|
||||
if on:
|
||||
self._show_notice("屏幕正在被共享")
|
||||
else:
|
||||
self._hide_notice()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log(f"共享提示失败: {exc}")
|
||||
|
||||
def refresh_sharing_notice(self, status: dict, sharing: bool) -> None:
|
||||
self.set_screen_sharing(sharing)
|
||||
|
||||
def refresh(self) -> None:
|
||||
pass
|
||||
|
||||
def open_diagnostics(self, from_watch: str = "") -> None:
|
||||
"""手表请求自检:静默模式下不弹窗口,只记日志。"""
|
||||
self.log(f"手表 {from_watch or '?'} 请求了网络自检(静默模式不弹窗口)")
|
||||
|
||||
def _show_notice(self, text: str) -> None:
|
||||
root = self._ensure_root()
|
||||
if root is None:
|
||||
return
|
||||
|
||||
def build():
|
||||
win = tk.Toplevel(root)
|
||||
# 标题要设 —— 无边框窗口在外观上看不到,
|
||||
# 但系统里能查到这个标题,是"确实显示了提示"的可验证证据。
|
||||
win.title(text)
|
||||
win.overrideredirect(True)
|
||||
win.attributes("-topmost", True)
|
||||
win.configure(bg="#cf222e")
|
||||
label = tk.Label(win, text=text, bg="#cf222e", fg="#ffffff",
|
||||
font=("Microsoft YaHei UI", 11, "bold"),
|
||||
padx=16, pady=8)
|
||||
label.pack()
|
||||
screen_w, screen_h = self.screen_size()
|
||||
win.update_idletasks()
|
||||
width = win.winfo_reqwidth()
|
||||
height = win.winfo_reqheight()
|
||||
win.geometry(f"+{screen_w - width - 24}+{screen_h - height - 72}")
|
||||
self._notice = win
|
||||
|
||||
try:
|
||||
self._queue.put(build)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _hide_notice(self) -> None:
|
||||
win = self._notice
|
||||
self._notice = None
|
||||
if win is None:
|
||||
return
|
||||
|
||||
def destroy():
|
||||
try:
|
||||
win.destroy()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
try:
|
||||
self._queue.put(destroy)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# ---------------- 设置窗口等:静默模式下没有 ----------------
|
||||
|
||||
def open_settings(self) -> None:
|
||||
self.log("静默模式没有设置窗口,请改用带界面的模式或直接编辑 data/settings.json")
|
||||
|
||||
def open_help(self) -> None:
|
||||
self.log("静默模式没有说明窗口")
|
||||
|
||||
def open_log(self) -> None:
|
||||
self.log("静默模式没有日志窗口;日志写在 data/silent.log")
|
||||
|
||||
def show_message(self, text: str) -> None:
|
||||
self.log(text)
|
||||
|
||||
def show_text_window(self, title: str, text: str) -> None:
|
||||
self.log(f"[{title}] {text}")
|
||||
|
||||
def quit_app(self) -> None:
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stopping = True
|
||||
# 不要在非界面线程里 destroy 根窗口 —— Tcl 会报
|
||||
# "async handler deleted by the wrong thread"。
|
||||
# 让泵线程自己收尾(它的循环退出时会 destroy)。
|
||||
self._hide_notice()
|
||||
thread = self._pump_thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=2.0)
|
||||
self._root = None
|
||||
|
||||
# ---------------- 静默模式自己的控制 ----------------
|
||||
|
||||
def run_forever(self) -> None:
|
||||
"""一直跑着,直到收到停止信号。"""
|
||||
while not self._stopping:
|
||||
time.sleep(0.5)
|
||||
Reference in New Issue
Block a user