mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-19 15:20:44 +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,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""局域网遥控 · Windows 被控端。"""
|
||||
|
||||
__version__ = "1.5.0"
|
||||
APP_NAME = "局域网遥控 被控端"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""开机自启(当前用户级,不需要管理员)。
|
||||
|
||||
写 HKCU\\...\\Run,不碰 HKLM —— 不需要 UAC,也不会影响别的用户。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RUN_KEY = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
# 任务管理器「启动」标签页里显示的就是这个名字。
|
||||
# 用 ykxmz 而不是冒充 "Windows xxx" —— 用的人应该看得出这是什么。
|
||||
VALUE_NAME = "ykxmz"
|
||||
# 早期版本用过的名字。开着自启的老用户升级后要把它清掉,别留两条。
|
||||
LEGACY_VALUE_NAMES = ("LanRemoteAgent",)
|
||||
|
||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
def executable_path() -> str:
|
||||
"""要写进注册表的可执行文件路径。"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return str(Path(sys.executable).resolve())
|
||||
# 源码模式:用 pythonw + run_agent.py,这样不会有黑窗
|
||||
script = Path(__file__).resolve().parent.parent / "run_agent.py"
|
||||
pythonw = Path(sys.executable).with_name("pythonw.exe")
|
||||
exe = pythonw if pythonw.is_file() else Path(sys.executable)
|
||||
return f'"{exe}" "{script}"'
|
||||
|
||||
|
||||
def _run(args: list[str]) -> tuple[int, str]:
|
||||
try:
|
||||
proc = subprocess.run(args, capture_output=True, timeout=20,
|
||||
creationflags=_NO_WINDOW,
|
||||
encoding="mbcs", errors="replace")
|
||||
return proc.returncode, ((proc.stdout or "") + (proc.stderr or "")).strip()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return -1, str(exc)
|
||||
|
||||
|
||||
def command_line() -> str:
|
||||
"""注册表里存的完整命令行。"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return f'"{Path(sys.executable).resolve()}" --silent'
|
||||
script = Path(__file__).resolve().parent.parent / "run_agent.py"
|
||||
pythonw = Path(sys.executable).with_name("pythonw.exe")
|
||||
exe = pythonw if pythonw.is_file() else Path(sys.executable)
|
||||
return f'"{exe}" "{script}" --silent'
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
code, out = _run(["reg", "query", RUN_KEY, "/v", VALUE_NAME])
|
||||
return code == 0 and VALUE_NAME in out
|
||||
|
||||
|
||||
def current_value() -> str:
|
||||
code, out = _run(["reg", "query", RUN_KEY, "/v", VALUE_NAME])
|
||||
if code != 0:
|
||||
return ""
|
||||
for line in out.splitlines():
|
||||
if VALUE_NAME in line and "REG_SZ" in line:
|
||||
return line.split("REG_SZ", 1)[-1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def cleanup_legacy() -> list:
|
||||
"""清掉早期版本留下的自启项(换名字了,别留两条)。"""
|
||||
removed = []
|
||||
for name in LEGACY_VALUE_NAMES:
|
||||
code, _out = _run(["reg", "delete", RUN_KEY, "/v", name, "/f"])
|
||||
if code == 0:
|
||||
removed.append(name)
|
||||
return removed
|
||||
|
||||
|
||||
def enable() -> str:
|
||||
value = command_line()
|
||||
code, out = _run(["reg", "add", RUN_KEY, "/v", VALUE_NAME,
|
||||
"/t", "REG_SZ", "/d", value, "/f"])
|
||||
if code != 0:
|
||||
raise ValueError(f"写入开机自启失败: {out[:120]}")
|
||||
gone = cleanup_legacy()
|
||||
extra = f"(顺手清掉旧自启项 {', '.join(gone)})" if gone else ""
|
||||
return f"已开启开机自启(静默模式){extra}\n{value}"
|
||||
|
||||
|
||||
def disable() -> str:
|
||||
code, out = _run(["reg", "delete", RUN_KEY, "/v", VALUE_NAME, "/f"])
|
||||
if code != 0 and "找不到" not in out and "unable" not in out.lower():
|
||||
# 本来就没开,也算成功
|
||||
pass
|
||||
return "已关闭开机自启"
|
||||
|
||||
|
||||
def toggle(on: bool) -> str:
|
||||
return enable() if on else disable()
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
enabled = is_enabled()
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"command": current_value() if enabled else "",
|
||||
"expected": command_line(),
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""配置与设备凭据持久化。
|
||||
|
||||
数据放在程序目录下的 data/,绿色便携,不写注册表。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
"port": 8765,
|
||||
"server_name": "",
|
||||
"allow_run": True, # 允许自定义命令(可在界面关闭)
|
||||
"allow_power": True, # 允许关机/重启(可在界面关闭)
|
||||
"allow_screen": True, # 允许屏幕镜像/截屏(可在界面关闭)
|
||||
"start_minimized": False, # 启动后直接最小化到托盘
|
||||
"allow_new_pairing": True, # 是否允许新设备配对
|
||||
"image_timeout": 0, # 图片显示秒数,0=手动关闭
|
||||
"image_display": "window", # 投图方式: window=屏幕中间普通窗口 / fullscreen=全屏 / custom=自定义大小
|
||||
"image_custom_width": 0, # 自定义宽度(像素),0=自动
|
||||
"image_custom_height": 0, # 自定义高度(像素),0=自动
|
||||
"image_window_title": "", # 投图窗口标题模板,可用 {name};空=默认
|
||||
"tts_voice": "", # 默认 TTS 音色,空=系统默认
|
||||
"tts_rate": 0, # 语速 -10~10
|
||||
"mp3_dir": "", # 本机音乐目录,空=自动找 音乐/Music
|
||||
# ---- 下面三项是"边界可配置":由电脑主人决定允许什么 ----
|
||||
"file_roots": "", # 额外允许的目录,分号分隔
|
||||
"extra_apps": "", # 自定义快捷程序:显示名=路径,分号分隔
|
||||
"extra_commands": "", # 自定义命令别名:显示名=命令,分号分隔
|
||||
"download_dir": "received",
|
||||
"screen_max_width": 480, # 镜像帧最大宽度
|
||||
# 镜像 JPEG 质量。
|
||||
# 实测质量对编码耗时几乎没影响(都是 ~17ms),主要影响体积:
|
||||
# 55 -> 30KB/帧 72 -> 45KB/帧 85 -> 70KB/帧
|
||||
# 取 72:画质明显更清楚,局域网完全带得动。
|
||||
"screen_quality": 72,
|
||||
# 默认帧率。
|
||||
# 实测抓屏 33ms + 编码 17ms = 50ms,也就是**硬件上限约 20 帧/秒**。
|
||||
# 原来写 3 是白白浪费了 6 倍的能力。
|
||||
# 取 12:够顺,又给抓屏留了余量,不至于把 CPU 占满。
|
||||
# (画面静止时会自动降频,见 screen.py 的 _loop)
|
||||
"screen_fps": 12,
|
||||
}
|
||||
|
||||
|
||||
class Store:
|
||||
"""线程安全的 JSON 存储。"""
|
||||
|
||||
def __init__(self, directory: Path):
|
||||
self.dir = Path(directory)
|
||||
try:
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
probe = self.dir / ".write_test"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink()
|
||||
except OSError:
|
||||
# 装到 Program Files 这类只读目录时,原来会直接崩在启动。
|
||||
# 退到用户自己的 AppData —— 那里一定可写。
|
||||
fallback = Path(os.environ.get("LOCALAPPDATA",
|
||||
str(Path.home()))) / "LanRemote"
|
||||
try:
|
||||
fallback.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
self.dir = fallback
|
||||
self._lock = threading.RLock()
|
||||
self.settings_file = self.dir / "settings.json"
|
||||
self.devices_file = self.dir / "devices.json"
|
||||
self.settings = self._load(self.settings_file, dict(DEFAULT_SETTINGS))
|
||||
self.devices = self._load(self.devices_file, {})
|
||||
if not self.settings.get("server_name"):
|
||||
self.settings["server_name"] = socket.gethostname()
|
||||
|
||||
# ---------- 基础读写 ----------
|
||||
|
||||
@staticmethod
|
||||
def _load(path: Path, fallback):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
if isinstance(data, dict):
|
||||
if isinstance(fallback, dict) and fallback:
|
||||
merged = dict(fallback)
|
||||
merged.update(data)
|
||||
return merged
|
||||
return data
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return fallback
|
||||
|
||||
def save_settings(self) -> None:
|
||||
with self._lock:
|
||||
self._write(self.settings_file, self.settings)
|
||||
|
||||
def save_devices(self) -> None:
|
||||
with self._lock:
|
||||
self._write(self.devices_file, self.devices)
|
||||
|
||||
@staticmethod
|
||||
def _write(path: Path, data) -> None:
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
with self._lock:
|
||||
return self.settings.get(key, default)
|
||||
|
||||
def set(self, key: str, value) -> None:
|
||||
with self._lock:
|
||||
self.settings[key] = value
|
||||
self.save_settings()
|
||||
|
||||
# ---------- 配对码 ----------
|
||||
|
||||
def new_pairing_code(self, digits: int = 6) -> str:
|
||||
with self._lock:
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(digits))
|
||||
self.settings["pairing_code"] = code
|
||||
self.settings["pairing_created"] = time.time()
|
||||
self.save_settings()
|
||||
return code
|
||||
|
||||
MIN_CODE_LEN = 6
|
||||
MAX_CODE_LEN = 16
|
||||
|
||||
def set_pairing_code(self, code: str) -> str:
|
||||
"""设成用户自己的配对码。返回规范化后的值。
|
||||
|
||||
只存在本机 data/settings.json 里,不写进任何要分发的东西。
|
||||
"""
|
||||
cleaned = "".join(ch for ch in str(code or "").strip()
|
||||
if ch.isdigit() or ch.isalpha())
|
||||
if len(cleaned) < self.MIN_CODE_LEN:
|
||||
raise ValueError(f"配对码至少 {self.MIN_CODE_LEN} 位")
|
||||
if len(cleaned) > self.MAX_CODE_LEN:
|
||||
raise ValueError(f"配对码最多 {self.MAX_CODE_LEN} 位")
|
||||
with self._lock:
|
||||
self.settings["pairing_code"] = cleaned
|
||||
self.settings["pairing_created"] = time.time()
|
||||
self.save_settings()
|
||||
return cleaned
|
||||
|
||||
def pairing_code(self) -> str:
|
||||
return self.get("pairing_code", "")
|
||||
|
||||
# ---------- 设备 ----------
|
||||
|
||||
def remember_device(self, device_id: str, name: str) -> str:
|
||||
token = secrets.token_hex(16)
|
||||
with self._lock:
|
||||
self.devices[device_id] = {
|
||||
"name": name or "未知设备",
|
||||
"token": token,
|
||||
"paired_at": time.time(),
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
self.save_devices()
|
||||
return token
|
||||
|
||||
def verify_token(self, device_id: str, token: str) -> bool:
|
||||
with self._lock:
|
||||
rec = self.devices.get(device_id)
|
||||
if not rec:
|
||||
return False
|
||||
ok = secrets.compare_digest(str(rec.get("token", "")), str(token or ""))
|
||||
if ok:
|
||||
with self._lock:
|
||||
rec["last_seen"] = time.time()
|
||||
self.save_devices()
|
||||
return ok
|
||||
|
||||
def touch_device(self, device_id: str) -> None:
|
||||
with self._lock:
|
||||
rec = self.devices.get(device_id)
|
||||
if rec:
|
||||
rec["last_seen"] = time.time()
|
||||
self.save_devices()
|
||||
|
||||
def forget_device(self, device_id: str) -> bool:
|
||||
with self._lock:
|
||||
existed = self.devices.pop(device_id, None) is not None
|
||||
if existed:
|
||||
self.save_devices()
|
||||
return existed
|
||||
|
||||
def device_list(self) -> list[dict]:
|
||||
with self._lock:
|
||||
items = []
|
||||
for dev_id, rec in self.devices.items():
|
||||
items.append({
|
||||
"id": dev_id,
|
||||
"name": rec.get("name", ""),
|
||||
"paired_at": rec.get("paired_at", 0),
|
||||
"last_seen": rec.get("last_seen", 0),
|
||||
})
|
||||
return sorted(items, key=lambda d: d["last_seen"], reverse=True)
|
||||
|
||||
def received_dir(self) -> Path:
|
||||
path = self.dir.parent / str(self.get("download_dir", "received"))
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def app_dir() -> Path:
|
||||
"""程序所在目录(兼容 PyInstaller 冻结环境)。"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
return app_dir() / "data"
|
||||
@@ -0,0 +1,205 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""应用内网络自检(原来是一个独立的控制台脚本,现在做进界面里)。
|
||||
|
||||
和独立脚本最大的区别:
|
||||
被控端自己就在监听 8765/8766,所以**不需要再去绑端口**,
|
||||
直接读它自己的计数器就知道手表的包有没有到:
|
||||
* 自动发现请求收到几个
|
||||
* TCP 连接来过几次
|
||||
这比再开一个进程抢端口靠谱得多,也正是"连不上"时最想知道的事。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from .config import app_dir
|
||||
from .discovery import DISCOVERY_PORT
|
||||
from .firewall import check as firewall_check
|
||||
|
||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
def _run(cmd: list[str], timeout: int = 12) -> str:
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
||||
creationflags=_NO_WINDOW,
|
||||
encoding="mbcs" if os.name == "nt" else "utf-8",
|
||||
errors="replace")
|
||||
return (proc.stdout or "") + (proc.stderr or "")
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
|
||||
def local_ips() -> list[tuple[str, str]]:
|
||||
"""本机所有可用的局域网 IPv4。"""
|
||||
out: list[tuple[str, str]] = []
|
||||
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."):
|
||||
out.append((addr, "默认路由"))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
addr = info[4][0]
|
||||
if addr and not addr.startswith("127.") and addr not in [a for a, _ in out]:
|
||||
out.append((addr, "主机名"))
|
||||
except OSError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def wifi_name() -> str:
|
||||
text = _run(["netsh", "wlan", "show", "interfaces"])
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("SSID") and "BSSID" not in line:
|
||||
return line.split(":", 1)[-1].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def collect(server, store) -> dict:
|
||||
"""把自检需要的东西一次性收集出来。"""
|
||||
data: dict = {
|
||||
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"os": f"{platform.system()} {platform.release()}",
|
||||
"ips": local_ips(),
|
||||
"wifi": wifi_name(),
|
||||
"port": getattr(server, "port", 0),
|
||||
"discovery_port": DISCOVERY_PORT,
|
||||
"server_running": bool(getattr(server, "running", False)),
|
||||
"probes": 0,
|
||||
"selfcheck_requests": 0,
|
||||
"tcp_connections": 0,
|
||||
"connected": [],
|
||||
"firewall": {},
|
||||
"app_dir": str(app_dir()),
|
||||
}
|
||||
|
||||
# 服务端状态(含自动发现计数、累计 TCP 连接数)
|
||||
try:
|
||||
status = server.status()
|
||||
data["connected"] = status.get("connected", [])
|
||||
data["pairing_code"] = status.get("pairing_code", "")
|
||||
data["screen_sharing"] = status.get("screen_sharing", False)
|
||||
data["tcp_connections"] = int(status.get("total_connections", 0) or 0)
|
||||
|
||||
info = status.get("discovery") or {}
|
||||
data["probes"] = int(info.get("answered", 0) or 0)
|
||||
data["selfcheck_requests"] = int(info.get("selfcheck_requests", 0) or 0)
|
||||
data["discovery_running"] = bool(info.get("running"))
|
||||
data["discovery_error"] = info.get("error", "")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
data["discovery_running"] = False
|
||||
data["discovery_error"] = str(exc)
|
||||
|
||||
# 防火墙
|
||||
try:
|
||||
data["firewall"] = firewall_check(data["port"] or 8765, DISCOVERY_PORT)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
data["firewall"] = {"supported": False, "message": str(exc)}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def verdict(data: dict) -> tuple[str, str]:
|
||||
"""给出结论:(级别, 文字)。级别是 ok / warn / error。"""
|
||||
fw = data.get("firewall") or {}
|
||||
if not data.get("server_running"):
|
||||
return "error", "被控端没有在运行 —— 先启动它,否则手表一定连不上。"
|
||||
|
||||
if not fw.get("supported"):
|
||||
return "warn", "防火墙状态读不到(可能不是 Windows),无法判断。"
|
||||
|
||||
if fw.get("blocked_count"):
|
||||
return "error", (f"有 {fw['blocked_count']} 条「阻止」规则压着,"
|
||||
"手表能搜到但连不上。去「设置/修复」点一键修复。")
|
||||
|
||||
if not (fw.get("tcp") and fw.get("udp")):
|
||||
return "error", "端口放行规则缺失,其他设备连不上。点「一键修复」。"
|
||||
|
||||
if not fw.get("app_rule"):
|
||||
return "warn", ("现在能连上,但程序级放行规则不完整,"
|
||||
"以后系统可能再生成阻止规则。建议修一次。")
|
||||
|
||||
probes = data.get("probes", 0)
|
||||
conns = data.get("tcp_connections", 0)
|
||||
if probes == 0 and conns == 0:
|
||||
return "warn", ("防火墙没问题,但**一个包都没收到**。"
|
||||
"手表可能不在同一个 WiFi,或路由器开了 AP 隔离。")
|
||||
|
||||
return "ok", "一切正常,手表应该能连上。"
|
||||
|
||||
|
||||
def render(data: dict) -> str:
|
||||
"""把自检结果渲染成可复制的文本。"""
|
||||
level, text = verdict(data)
|
||||
lines: list[str] = []
|
||||
lines.append("===== 电脑端自检 =====")
|
||||
lines.append(f"时间: {data.get('time')}")
|
||||
lines.append(f"系统: {data.get('os')}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("--- 本机网络 ---")
|
||||
ips = data.get("ips") or []
|
||||
if ips:
|
||||
for addr, tag in ips:
|
||||
lines.append(f" {addr} [{tag}]")
|
||||
lines.append(f" >>> 手表上填这个 IP:{ips[0][0]}")
|
||||
else:
|
||||
lines.append(" 没读到 IP(网络没连上?)")
|
||||
if data.get("wifi"):
|
||||
lines.append(f" WiFi: {data['wifi']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("--- 监听 ---")
|
||||
lines.append(f" 控制端口 TCP {data.get('port')}: "
|
||||
f"{'监听中' if data.get('server_running') else '未监听'}")
|
||||
lines.append(f" 发现端口 UDP {data.get('discovery_port')}: "
|
||||
f"{'监听中' if data.get('discovery_running') else '未监听'}")
|
||||
if data.get("discovery_error"):
|
||||
lines.append(f" 错误: {data['discovery_error']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("--- 收到过多少(关键)---")
|
||||
lines.append(f" 自动发现请求: {data.get('probes', 0)} 个")
|
||||
lines.append(f" 自检请求: {data.get('selfcheck_requests', 0)} 个")
|
||||
lines.append(f" TCP 连接: {data.get('tcp_connections', 0)} 次")
|
||||
if data.get("probes", 0) == 0 and data.get("tcp_connections", 0) == 0:
|
||||
lines.append(" !! 一直是 0 说明手表的包根本没到这台电脑")
|
||||
else:
|
||||
lines.append(" 有数字说明手表的包能到达这台电脑")
|
||||
lines.append("")
|
||||
|
||||
lines.append("--- 防火墙 ---")
|
||||
fw = data.get("firewall") or {}
|
||||
lines.append(f" 端口放行 TCP: {'是' if fw.get('tcp') else '否'}")
|
||||
lines.append(f" 端口放行 UDP: {'是' if fw.get('udp') else '否'}")
|
||||
lines.append(f" 程序级放行: {'是' if fw.get('app_rule') else '否'} "
|
||||
f"({os.path.basename(str(fw.get('app_rule_program') or '')) or '-'})")
|
||||
lines.append(f" 阻止规则: {fw.get('blocked_count', 0)} 条(针对本程序)")
|
||||
if fw.get("blocked_others"):
|
||||
lines.append(f" 另外还有 {fw['blocked_others']} 条是针对别的程序的,不影响")
|
||||
lines.append("")
|
||||
|
||||
lines.append("--- 已连接设备 ---")
|
||||
connected = data.get("connected") or []
|
||||
if connected:
|
||||
for item in connected:
|
||||
lines.append(f" {item.get('name', '?')} {item.get('addr', '')}")
|
||||
else:
|
||||
lines.append(" (没有设备连着)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("--- 结论 ---")
|
||||
lines.append(f" [{level.upper()}] {text}")
|
||||
lines.append("")
|
||||
lines.append("===== 结束 =====")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,251 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""局域网自动发现(UDP 广播)。
|
||||
|
||||
目的:**手表端不用输 IP**。
|
||||
手表往局域网广播一个探测包,被控端收到后单播回自己的名字和端口,
|
||||
手表就能自动填好地址直接连。
|
||||
|
||||
这样也顺手解决了另一个坑:电脑换了网络、IP 变了、或者机器上有
|
||||
Tailscale / VMware 之类虚拟网卡时,用户猜不准该填哪个 IP。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
|
||||
from . import __version__ as _APP_VERSION
|
||||
import threading
|
||||
import time
|
||||
|
||||
DISCOVERY_PORT = 8766
|
||||
PROBE_MAGIC = "LANREMOTE_DISCOVER"
|
||||
REPLY_MAGIC = "LANREMOTE_REPLY"
|
||||
# 手表点「连不上?」时广播这个,电脑收到就弹出网络自检窗口
|
||||
SELFCHECK_MAGIC = "LANREMOTE_SELFCHECK"
|
||||
ACK_MAGIC = "LANREMOTE_ACK"
|
||||
PROTOCOL = 1
|
||||
|
||||
|
||||
def build_reply(name: str, tcp_port: int, version: str) -> bytes:
|
||||
payload = {
|
||||
"lanremote": REPLY_MAGIC,
|
||||
"proto": PROTOCOL,
|
||||
"name": name,
|
||||
"port": tcp_port,
|
||||
"version": version,
|
||||
}
|
||||
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def build_selfcheck_ack(name: str, note: str = "") -> bytes:
|
||||
"""自检应答。
|
||||
|
||||
note 带上电脑这边的诊断结论 —— 手表拿到就直接显示,
|
||||
不需要在被控端弹窗口(用户要的是全程无窗口)。
|
||||
"""
|
||||
payload = {"lanremote": ACK_MAGIC, "name": name, "note": note}
|
||||
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def build_selfcheck_request() -> bytes:
|
||||
payload = {"lanremote": SELFCHECK_MAGIC, "proto": PROTOCOL}
|
||||
return json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def parse_selfcheck(data: bytes) -> dict | None:
|
||||
try:
|
||||
obj = json.loads(data.decode("utf-8", errors="replace"))
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
if obj.get("lanremote") != SELFCHECK_MAGIC:
|
||||
return None
|
||||
return obj
|
||||
|
||||
|
||||
def parse_probe(data: bytes) -> dict | None:
|
||||
try:
|
||||
obj = json.loads(data.decode("utf-8", errors="replace"))
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(obj, dict):
|
||||
return None
|
||||
if obj.get("lanremote") != PROBE_MAGIC:
|
||||
return None
|
||||
return obj
|
||||
|
||||
|
||||
class DiscoveryResponder:
|
||||
"""监听 UDP 探测包并回复本机地址。"""
|
||||
|
||||
def __init__(self, store, tcp_port: int, version: str = "",
|
||||
port: int = DISCOVERY_PORT, on_probe=None):
|
||||
self.store = store
|
||||
self.tcp_port = int(tcp_port)
|
||||
self.version = version or _APP_VERSION
|
||||
self.port = int(port)
|
||||
self.on_probe = on_probe # 可选回调,用于界面提示"有人在找这台电脑"
|
||||
# 收到「请打开网络自检」的请求时调用;由上层决定怎么弹窗口
|
||||
self.on_selfcheck = None
|
||||
# 最近一次自检的结论,随 ACK 回给手表
|
||||
self._selfcheck_note = ""
|
||||
self.selfcheck_requests = 0
|
||||
self._sock: socket.socket | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
self.probes_answered = 0
|
||||
self.last_probe_at = 0.0
|
||||
self.last_error = ""
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running and self._thread is not None and self._thread.is_alive()
|
||||
|
||||
def start(self) -> bool:
|
||||
if self.running:
|
||||
return True
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# Windows 上 SO_REUSEADDR 允许第二个进程绑同一 UDP 端口,
|
||||
# 两个实例会互相抢探测包。改用独占绑定,第二个实例直接失败。
|
||||
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)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
except OSError:
|
||||
pass
|
||||
sock.bind(("0.0.0.0", self.port))
|
||||
sock.settimeout(1.0)
|
||||
except OSError as exc:
|
||||
self.last_error = f"无法监听 UDP {self.port}: {exc}"
|
||||
return False
|
||||
|
||||
self._sock = sock
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._loop, name="discovery",
|
||||
daemon=True)
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=3.0)
|
||||
self._thread = None
|
||||
if self._sock is not None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._sock = None
|
||||
|
||||
def _loop(self) -> None:
|
||||
reply = build_reply(str(self.store.get("server_name", "")),
|
||||
self.tcp_port, self.version)
|
||||
while self._running:
|
||||
try:
|
||||
data, addr = self._sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
|
||||
# 先看是不是「请打开网络自检」的请求
|
||||
if parse_selfcheck(data) is not None:
|
||||
self.selfcheck_requests += 1
|
||||
try:
|
||||
self._sock.sendto(
|
||||
build_selfcheck_ack(
|
||||
str(self.store.get("server_name", "")),
|
||||
self._selfcheck_note),
|
||||
addr)
|
||||
except OSError:
|
||||
pass
|
||||
if self.on_selfcheck:
|
||||
try:
|
||||
# 回调可以返回一段话,随 ACK 一起回给手表
|
||||
produced = self.on_selfcheck(addr[0])
|
||||
if isinstance(produced, str) and produced:
|
||||
self._selfcheck_note = produced[:1500]
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
probe = parse_probe(data)
|
||||
if probe is None:
|
||||
continue
|
||||
|
||||
self.probes_answered += 1
|
||||
self.last_probe_at = time.time()
|
||||
# 单播回复给发起方
|
||||
try:
|
||||
self._sock.sendto(reply, addr)
|
||||
except OSError as exc:
|
||||
self.last_error = str(exc)
|
||||
continue
|
||||
|
||||
if self.on_probe:
|
||||
try:
|
||||
self.on_probe(addr[0])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"running": self.running,
|
||||
"port": self.port,
|
||||
"answered": self.probes_answered,
|
||||
"last_probe": self.last_probe_at,
|
||||
"selfcheck_requests": self.selfcheck_requests,
|
||||
"error": self.last_error,
|
||||
}
|
||||
|
||||
|
||||
def discover(timeout: float = 2.5, port: int = DISCOVERY_PORT,
|
||||
broadcast: str = "255.255.255.255") -> list[dict]:
|
||||
"""客户端侧:广播探测并收集回复(手表端用的是同一套逻辑)。"""
|
||||
results: list[dict] = []
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
sock.settimeout(0.4)
|
||||
probe = json.dumps({"lanremote": PROBE_MAGIC, "proto": PROTOCOL}).encode()
|
||||
sock.sendto(probe, (broadcast, port))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
data, addr = sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
try:
|
||||
obj = json.loads(data.decode("utf-8", errors="replace"))
|
||||
except ValueError:
|
||||
continue
|
||||
if obj.get("lanremote") != REPLY_MAGIC:
|
||||
continue
|
||||
results.append({
|
||||
"host": addr[0],
|
||||
"port": int(obj.get("port") or 0),
|
||||
"name": obj.get("name") or "",
|
||||
"version": obj.get("version") or "",
|
||||
})
|
||||
finally:
|
||||
sock.close()
|
||||
# 按主机去重
|
||||
seen = set()
|
||||
unique = []
|
||||
for item in results:
|
||||
if item["host"] in seen:
|
||||
continue
|
||||
seen.add(item["host"])
|
||||
unique.append(item)
|
||||
return unique
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,442 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Windows 防火墙处理。
|
||||
|
||||
真机上踩到的坑(这是「能搜到但连不上」的最终根因):
|
||||
|
||||
Windows 防火墙的**阻止规则优先级高于放行规则**。
|
||||
当 Windows 弹「是否允许此应用通过防火墙」而用户点了取消时,
|
||||
系统会自动为该程序创建 Block 规则。
|
||||
|
||||
只要存在一条针对本程序的 Block 规则,后面加多少条 Allow 都不管用。
|
||||
|
||||
而且 Windows 是按**程序完整路径**建规则的 —— 换个目录解压就会
|
||||
再建一条,所以同一台机器上能攒下十几条。
|
||||
|
||||
又因为防火墙不过滤回环(127.0.0.1)流量,本地自测会全部通过,
|
||||
真机却永远超时。这是最容易漏掉的一类问题。
|
||||
|
||||
本模块负责:检测阻止规则 + 提权清理 + 重建放行。
|
||||
|
||||
注意:netsh 的输出会跟随系统语言本地化(中文系统上是「规则名称/操作/阻止」),
|
||||
所以字段名必须中英文都认,不能只匹配英文。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
IS_WINDOWS = sys.platform.startswith("win")
|
||||
|
||||
RULE_TCP = "LAN Remote TCP"
|
||||
RULE_UDP = "LAN Remote UDP"
|
||||
# 程序级放行规则 —— 这是治本的那一条
|
||||
RULE_APP_TCP = "LAN Remote App TCP"
|
||||
RULE_APP_UDP = "LAN Remote App UDP"
|
||||
|
||||
# Windows 自动创建的规则名 = 小写可执行文件名
|
||||
# 修复防火墙时要清掉的旧规则名。
|
||||
# ykxmz.exe 是现在的名字;lanremote-agent.exe 是改名前的老名字——
|
||||
# 保留它,好让老用户升级上来时把旧规则清掉,不然两条会打架。
|
||||
PROGRAM_RULE_NAMES = ("ykxmz.exe", "lanremote-agent.exe",
|
||||
"python.exe", "pythonw.exe")
|
||||
|
||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
# netsh 字段名(英文 / 中文)
|
||||
_FIELD_LABELS = {
|
||||
"RuleName": ("Rule Name", "规则名称"),
|
||||
"Enabled": ("Enabled", "已启用"),
|
||||
"Direction": ("Direction", "方向"),
|
||||
"Profiles": ("Profiles", "配置文件"),
|
||||
"Protocol": ("Protocol", "协议"),
|
||||
"LocalPort": ("LocalPort", "本地端口"),
|
||||
"Program": ("Program", "程序"),
|
||||
"Action": ("Action", "操作"),
|
||||
}
|
||||
_BLOCK_VALUES = ("block", "阻止")
|
||||
_ALLOW_VALUES = ("allow", "允许")
|
||||
_NO_MATCH_MARKERS = ("no rules match", "没有与指定条件", "没有与指定條件",
|
||||
"匹配的规则", "沒有")
|
||||
|
||||
|
||||
def _run(cmd: list[str], timeout: int = 30) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
cmd, capture_output=True, timeout=timeout,
|
||||
creationflags=_NO_WINDOW,
|
||||
encoding="mbcs" if IS_WINDOWS else "utf-8", errors="replace")
|
||||
|
||||
|
||||
def executable_path() -> str:
|
||||
"""当前程序的可执行文件路径(打包后是 EXE,源码模式是 python.exe)。"""
|
||||
try:
|
||||
return os.path.abspath(sys.executable)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def target_executable() -> str:
|
||||
"""修复脚本应该放行哪个程序。
|
||||
|
||||
正常情况下就是当前进程自己(打包后 sys.executable 是 EXE)。
|
||||
但源码模式下它是 python.exe —— 如果机器上已经有一条指向打包版 EXE
|
||||
的程序级放行规则,就沿用那个目标,避免源码模式跑一次修复
|
||||
把打包版的规则冲掉(真机上就这么踩过一次)。
|
||||
"""
|
||||
current = executable_path()
|
||||
if getattr(sys, "frozen", False):
|
||||
return current
|
||||
existing = app_rule_program()
|
||||
base = os.path.basename(existing or "").lower()
|
||||
if (existing and base.endswith(".exe") and "python" not in base
|
||||
and os.path.isfile(existing)):
|
||||
return existing
|
||||
return current
|
||||
|
||||
|
||||
def app_rule_exists() -> bool:
|
||||
"""是否已经有针对本程序的放行规则。
|
||||
|
||||
没有这条规则时,Windows 会在程序监听端口时弹窗询问;
|
||||
没人点「允许」就会自动生成阻止规则 —— 于是"删了又回来"。
|
||||
"""
|
||||
return rule_exists(RULE_APP_TCP) and rule_exists(RULE_APP_UDP)
|
||||
|
||||
|
||||
def app_rule_program() -> str:
|
||||
"""读出程序级放行规则实际指向的可执行文件。
|
||||
|
||||
真机上踩到的坑:规则被写成了 python.exe(源码模式的值),
|
||||
于是打包后的 ykxmz.exe 仍然没有程序级放行,
|
||||
系统继续自动生成阻止规则 —— 表现为"删了又回来"。
|
||||
所以必须核对路径,不能只看规则是否存在。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return ""
|
||||
try:
|
||||
proc = _run(["netsh", "advfirewall", "firewall", "show", "rule",
|
||||
f"name={RULE_APP_TCP}", "verbose"])
|
||||
except Exception:
|
||||
return ""
|
||||
for info in _split_rules(proc.stdout or ""):
|
||||
program = (info.get("Program") or "").strip('"')
|
||||
if program:
|
||||
return program
|
||||
return ""
|
||||
|
||||
|
||||
def app_rule_ok() -> bool:
|
||||
"""程序级放行规则存在,**且**指向当前真正运行的这个可执行文件。"""
|
||||
if not app_rule_exists():
|
||||
return False
|
||||
want = executable_path()
|
||||
got = app_rule_program()
|
||||
if not want or not got:
|
||||
return False
|
||||
return os.path.normcase(os.path.abspath(got)) == os.path.normcase(os.path.abspath(want))
|
||||
|
||||
|
||||
def is_elevated() -> bool:
|
||||
if not IS_WINDOWS:
|
||||
return hasattr(os, "geteuid") and os.geteuid() == 0
|
||||
try:
|
||||
import ctypes
|
||||
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _parse_fields(chunk: str) -> dict:
|
||||
"""把一段 netsh 规则文本解析成字段字典(中英文冒号都支持)。"""
|
||||
info: dict = {}
|
||||
for raw in chunk.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or ("=" in line and ":" not in line and ":" not in line):
|
||||
continue
|
||||
parts = re.split(r"[::]", line, maxsplit=1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
label = parts[0].strip()
|
||||
value = parts[1].strip()
|
||||
for key, aliases in _FIELD_LABELS.items():
|
||||
if label in aliases:
|
||||
info[key] = value
|
||||
break
|
||||
return info
|
||||
|
||||
|
||||
def _split_rules(text: str) -> list[dict]:
|
||||
"""把 netsh 的长输出切成一条条规则。"""
|
||||
rules = []
|
||||
for chunk in re.split(r"\r?\n\r?\n", text or ""):
|
||||
info = _parse_fields(chunk)
|
||||
if info.get("RuleName"):
|
||||
rules.append(info)
|
||||
return rules
|
||||
|
||||
|
||||
def rule_exists(rule_name: str) -> bool:
|
||||
if not IS_WINDOWS:
|
||||
return True
|
||||
try:
|
||||
proc = _run(["netsh", "advfirewall", "firewall", "show", "rule",
|
||||
f"name={rule_name}"])
|
||||
except Exception:
|
||||
return False
|
||||
if proc.returncode != 0:
|
||||
return False
|
||||
text = ((proc.stdout or "") + (proc.stderr or "")).lower()
|
||||
for marker in _NO_MATCH_MARKERS:
|
||||
if marker in text:
|
||||
return False
|
||||
# 输出为空(或明显不是规则详情)时**不能**当成"规则存在" ——
|
||||
# 本地化提示语没收录、netsh 被拦、超时都可能给出空输出,
|
||||
# 那时候报"已放行"就是在骗用户。
|
||||
if len(text.strip()) < 10:
|
||||
return False
|
||||
if rule_name.lower() not in text:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _port_hits(info: dict, ports: tuple) -> bool:
|
||||
"""这条规则的 LocalPort 是否命中我们关心的端口。"""
|
||||
raw = (info.get("LocalPort") or "").strip()
|
||||
if not raw or raw.lower() in ("any", "任意"):
|
||||
return False
|
||||
pieces = [p.strip() for p in raw.replace(",", ",").split(",")]
|
||||
for piece in pieces:
|
||||
if not piece:
|
||||
continue
|
||||
for port in ports:
|
||||
if not port:
|
||||
continue
|
||||
if piece == str(port):
|
||||
return True
|
||||
# 端口段,如 8000-9000
|
||||
if "-" in piece:
|
||||
try:
|
||||
low, high = piece.split("-", 1)
|
||||
if int(low) <= int(port) <= int(high):
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def list_block_rules(tcp_port: int = 0, udp_port: int = 0) -> list[dict]:
|
||||
"""列出可能挡住我们的入站阻止规则。
|
||||
|
||||
两类都算:
|
||||
* 程序级:Program 指向 lanremote-agent.exe / python.exe
|
||||
* 端口级:LocalPort 命中我们的 TCP/UDP 端口
|
||||
|
||||
第二类原来完全没覆盖。而"UDP 能搜到、TCP 连不上"往往就是
|
||||
一条按端口封的规则 —— 组策略、第三方安全软件、手工建规则都会这样。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
try:
|
||||
proc = _run(["netsh", "advfirewall", "firewall", "show", "rule",
|
||||
"name=all", "dir=in", "verbose"], timeout=60)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
blocks: list[dict] = []
|
||||
for info in _split_rules(proc.stdout or ""):
|
||||
action = (info.get("Action") or "").strip().lower()
|
||||
if action not in _BLOCK_VALUES:
|
||||
continue
|
||||
program = (info.get("Program") or "").strip('"')
|
||||
base = os.path.basename(program).lower() if program else ""
|
||||
if base in PROGRAM_RULE_NAMES:
|
||||
info["ProgramBase"] = base
|
||||
info["Kind"] = "program"
|
||||
blocks.append(info)
|
||||
continue
|
||||
if _port_hits(info, (tcp_port, udp_port)):
|
||||
info["Kind"] = "port"
|
||||
blocks.append(info)
|
||||
return blocks
|
||||
|
||||
|
||||
def check(tcp_port: int, udp_port: int) -> dict:
|
||||
"""综合状态:放行规则 + 阻止规则。"""
|
||||
if not IS_WINDOWS:
|
||||
return {"supported": False, "ok": True, "elevated": True,
|
||||
"tcp": True, "udp": True, "blocked": [], "blocked_count": 0,
|
||||
"message": "非 Windows,无需处理"}
|
||||
|
||||
tcp = rule_exists(RULE_TCP)
|
||||
udp = rule_exists(RULE_UDP)
|
||||
app_rule = app_rule_ok()
|
||||
elevated = is_elevated()
|
||||
|
||||
# 阻止规则按程序完整路径生效,所以只统计**针对当前这份程序**的。
|
||||
# 测试目录的副本、源码模式下的 python.exe 被阻止,与本安装无关,
|
||||
# 不该在这里报红(真机上就是这么误报的)。
|
||||
all_blocked = list_block_rules(tcp_port, udp_port)
|
||||
exe_now = executable_path()
|
||||
if exe_now:
|
||||
want = os.path.normcase(os.path.abspath(exe_now))
|
||||
blocked = [
|
||||
item for item in all_blocked
|
||||
if os.path.normcase(
|
||||
os.path.abspath((item.get("Program") or "").strip('"'))) == want
|
||||
]
|
||||
else:
|
||||
blocked = all_blocked
|
||||
blocked_others = len(all_blocked) - len(blocked)
|
||||
|
||||
# 「能不能连上」只看两件事:端口放行在不在 + 有没有阻止规则。
|
||||
# 程序级放行影响的是「以后会不会复发」,不影响当下能不能连。
|
||||
ok = tcp and udp and not blocked
|
||||
|
||||
if not ok:
|
||||
severity = "error"
|
||||
if blocked:
|
||||
message = f"存在 {len(blocked)} 条阻止规则(优先级高于放行,必须清掉)"
|
||||
elif not (tcp and udp):
|
||||
message = ("端口放行规则缺失,其他设备连不上"
|
||||
if not elevated
|
||||
else "端口放行规则缺失(当前是管理员,可直接修复)")
|
||||
else:
|
||||
message = "防火墙未放行,其他设备无法连接"
|
||||
elif not app_rule:
|
||||
severity = "warn"
|
||||
got = app_rule_program()
|
||||
if app_rule_exists() and got:
|
||||
message = ("能连上。但程序级放行规则指向的不是当前这份程序,"
|
||||
"以后系统可能再生成阻止规则,建议修复一次")
|
||||
else:
|
||||
message = ("能连上。但缺少程序级放行规则,"
|
||||
"以后系统可能再生成阻止规则,建议修复一次")
|
||||
else:
|
||||
severity = "ok"
|
||||
message = "防火墙已放行"
|
||||
|
||||
return {
|
||||
"supported": True,
|
||||
"tcp": tcp,
|
||||
"udp": udp,
|
||||
"app_rule": app_rule,
|
||||
"app_rule_program": app_rule_program(),
|
||||
"blocked": blocked,
|
||||
"blocked_count": len(blocked),
|
||||
"blocked_others": blocked_others,
|
||||
"ok": ok,
|
||||
"severity": severity,
|
||||
"elevated": elevated,
|
||||
"message": message,
|
||||
"tcp_port": tcp_port,
|
||||
"udp_port": udp_port,
|
||||
}
|
||||
|
||||
|
||||
def build_script(tcp_port: int, udp_port: int,
|
||||
program_names: tuple[str, ...] = PROGRAM_RULE_NAMES) -> str:
|
||||
"""生成提权修复脚本:先删阻止规则,再重建放行规则。"""
|
||||
lines = [
|
||||
"@echo off",
|
||||
"chcp 936 >nul",
|
||||
"setlocal",
|
||||
'echo [1/4] 删除针对本程序的「阻止」规则 ...',
|
||||
]
|
||||
for name in program_names:
|
||||
lines.append(f'netsh advfirewall firewall delete rule name="{name}" dir=in >nul 2>nul')
|
||||
lines += [
|
||||
'echo [2/4] 删除旧的放行规则 ...',
|
||||
f'netsh advfirewall firewall delete rule name="{RULE_TCP}" >nul 2>nul',
|
||||
f'netsh advfirewall firewall delete rule name="{RULE_UDP}" >nul 2>nul',
|
||||
'echo [3/4] 重新放行 ...',
|
||||
f'netsh advfirewall firewall add rule name="{RULE_TCP}" '
|
||||
f'dir=in action=allow protocol=TCP localport={tcp_port} profile=any',
|
||||
f'netsh advfirewall firewall add rule name="{RULE_UDP}" '
|
||||
f'dir=in action=allow protocol=UDP localport={udp_port} profile=any',
|
||||
"echo.",
|
||||
"echo ------------------------------------------------------------",
|
||||
"echo [4/4] 添加程序级放行(治本:防止系统再自动生成阻止规则)...",
|
||||
"echo ------------------------------------------------------------",
|
||||
]
|
||||
exe = target_executable()
|
||||
if exe:
|
||||
lines += [
|
||||
f'netsh advfirewall firewall delete rule name="{RULE_APP_TCP}" >nul 2>nul',
|
||||
f'netsh advfirewall firewall delete rule name="{RULE_APP_UDP}" >nul 2>nul',
|
||||
f'netsh advfirewall firewall add rule name="{RULE_APP_TCP}" '
|
||||
f'dir=in action=allow program="{exe}" enable=yes protocol=TCP profile=any',
|
||||
f'netsh advfirewall firewall add rule name="{RULE_APP_UDP}" '
|
||||
f'dir=in action=allow program="{exe}" enable=yes protocol=UDP profile=any',
|
||||
"echo.",
|
||||
]
|
||||
lines += [
|
||||
"echo 完成。",
|
||||
"endlocal",
|
||||
"exit /b 0",
|
||||
"",
|
||||
]
|
||||
return "\r\n".join(lines)
|
||||
|
||||
|
||||
def write_script(tcp_port: int, udp_port: int) -> str:
|
||||
"""写一份可手动右键运行的修复脚本,放在程序目录。"""
|
||||
from .config import app_dir
|
||||
path = app_dir() / "fxfhq.bat"
|
||||
try:
|
||||
# .bat 必须用 GBK 存盘,且 build_script 已保证 CRLF 换行;
|
||||
# 裸 LF 会让 cmd 逐段误读,把每条命令都当成无效命令
|
||||
path.write_text(build_script(tcp_port, udp_port), encoding="gbk", errors="replace")
|
||||
except OSError:
|
||||
pass
|
||||
return str(path)
|
||||
|
||||
|
||||
def apply_elevated(tcp_port: int, udp_port: int) -> tuple[bool, str]:
|
||||
"""提权执行修复。会弹一次 UAC。"""
|
||||
if not IS_WINDOWS:
|
||||
return False, "仅支持 Windows"
|
||||
|
||||
script_path = write_script(tcp_port, udp_port)
|
||||
if not os.path.isfile(script_path):
|
||||
return False, "无法写入修复脚本"
|
||||
|
||||
if is_elevated():
|
||||
try:
|
||||
proc = _run(["cmd", "/c", script_path], timeout=90)
|
||||
ok = rule_exists(RULE_TCP) and not list_block_rules()
|
||||
detail = (proc.stdout or proc.stderr or "").strip()
|
||||
return ok, ("已修复(管理员权限)" if ok else f"未完全生效:{detail[:160]}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, f"执行失败: {exc}"
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
f"Start-Process -FilePath '{script_path}' -Verb RunAs -Wait"],
|
||||
capture_output=True, timeout=240, creationflags=_NO_WINDOW,
|
||||
encoding="mbcs", errors="replace")
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "提权操作超时"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, f"提权失败: {exc}"
|
||||
|
||||
ok = rule_exists(RULE_TCP) and not list_block_rules()
|
||||
if ok:
|
||||
return True, "已修复"
|
||||
combined = ((proc.stdout or "") + (proc.stderr or "")).strip()
|
||||
if "canceled" in combined.lower() or "取消" in combined:
|
||||
return False, "你取消了管理员授权"
|
||||
return False, "修复未完全生效,请手动右键运行「fxfhq.bat」"
|
||||
|
||||
|
||||
def manual_hint(tcp_port: int, udp_port: int) -> str:
|
||||
names = " / ".join(f'"{n}"' for n in PROGRAM_RULE_NAMES)
|
||||
return (
|
||||
"手动修复(管理员 PowerShell):\n"
|
||||
f" netsh advfirewall firewall delete rule name={names} dir=in\n"
|
||||
f' netsh advfirewall firewall add rule name="{RULE_TCP}" '
|
||||
f"dir=in action=allow protocol=TCP localport={tcp_port} profile=any\n"
|
||||
f' netsh advfirewall firewall add rule name="{RULE_UDP}" '
|
||||
f"dir=in action=allow protocol=UDP localport={udp_port} profile=any"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""单实例控制。
|
||||
|
||||
窗口关掉后程序仍在后台跑,用户再次双击 EXE 时应该能**找回窗口**,
|
||||
而不是又起一个进程(那样端口会冲突,用户会以为坏了)。
|
||||
|
||||
做法:主实例占住一个本机回环端口;后启动的实例连上去发个 "show" 就退出。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import threading
|
||||
|
||||
CONTROL_PORT = 8767
|
||||
_CMD_SHOW = b"SHOW"
|
||||
|
||||
|
||||
class InstanceLock:
|
||||
def __init__(self, port: int = CONTROL_PORT):
|
||||
self.port = port
|
||||
self._sock: socket.socket | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
self.on_show = None # 收到"显示窗口"请求时的回调
|
||||
|
||||
@property
|
||||
def is_primary(self) -> bool:
|
||||
return self._sock is not None
|
||||
|
||||
def try_claim(self) -> bool:
|
||||
"""尝试成为主实例。失败说明已有实例在运行。"""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
# 关键:Windows 上 SO_REUSEADDR 会允许第二个进程绑同一个端口,
|
||||
# 导致单实例判定失效。Windows 必须用 SO_EXCLUSIVEADDRUSE。
|
||||
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(("127.0.0.1", self.port))
|
||||
sock.listen(4)
|
||||
sock.settimeout(1.0)
|
||||
except OSError:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
self._sock = sock
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._loop, name="instance",
|
||||
daemon=True)
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def _loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
conn, _addr = self._sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
try:
|
||||
conn.settimeout(2.0)
|
||||
data = conn.recv(32)
|
||||
if data.strip() == _CMD_SHOW and self.on_show:
|
||||
try:
|
||||
self.on_show()
|
||||
except Exception:
|
||||
pass
|
||||
conn.sendall(b"OK")
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def release(self) -> None:
|
||||
self._running = False
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
if self._sock is not None:
|
||||
try:
|
||||
self._sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._sock = None
|
||||
|
||||
|
||||
def signal_show(port: int = CONTROL_PORT, timeout: float = 2.0) -> bool:
|
||||
"""通知已在运行的实例把窗口显示出来。"""
|
||||
try:
|
||||
sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
|
||||
sock.sendall(_CMD_SHOW)
|
||||
try:
|
||||
sock.recv(16)
|
||||
except OSError:
|
||||
pass
|
||||
sock.close()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
@@ -0,0 +1,367 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""网络自检 —— 确认手表的数据包到底有没有到达这台电脑。
|
||||
|
||||
这个工具回答一个二选一的问题:
|
||||
|
||||
手表的连接请求,究竟「到了电脑但被拒绝」,还是「压根没到」?
|
||||
|
||||
用法:
|
||||
由 ykxmz.exe --netcheck 调用(已打包进 EXE,不需要装 Python)
|
||||
|
||||
会监听 90 秒,可以按 Ctrl+C 提前结束。
|
||||
|
||||
编码说明:
|
||||
脚本启动时会把控制台代码页设成 UTF-8(65001) 再输出,
|
||||
所以不论系统默认代码页是什么,中文都不会乱码。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
TCP_PORT = 8765
|
||||
UDP_PORT = 8766
|
||||
LISTEN_SECONDS = 90
|
||||
|
||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
# WSAEADDRINUSE / WSAEACCES
|
||||
_ADDR_IN_USE = (10048, 10013)
|
||||
|
||||
|
||||
def setup_console() -> None:
|
||||
"""把控制台和标准输出都设成 UTF-8,避免中文乱码。"""
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.kernel32.SetConsoleOutputCP(65001)
|
||||
ctypes.windll.kernel32.SetConsoleCP(65001)
|
||||
except Exception:
|
||||
pass
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def line(text: str = "") -> None:
|
||||
print(text, flush=True)
|
||||
|
||||
|
||||
def title(text: str) -> None:
|
||||
line()
|
||||
line("=" * 62)
|
||||
line(f" {text}")
|
||||
line("=" * 62)
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
|
||||
def local_ips() -> list[tuple[str, str]]:
|
||||
found: list[tuple[str, str]] = []
|
||||
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."):
|
||||
found.append((addr, "默认路由"))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
addr = info[4][0]
|
||||
if addr and not addr.startswith("127.") and addr not in [a for a, _ in found]:
|
||||
found.append((addr, "主机名"))
|
||||
except OSError:
|
||||
pass
|
||||
return found
|
||||
|
||||
|
||||
def run(cmd: list[str], timeout: int = 15) -> str:
|
||||
try:
|
||||
proc = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
||||
creationflags=_NO_WINDOW,
|
||||
encoding="mbcs", errors="replace")
|
||||
return (proc.stdout or "") + (proc.stderr or "")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"({exc})"
|
||||
|
||||
|
||||
def agent_running() -> bool:
|
||||
"""被控端是不是正在运行(它会占着 8765/8766)。"""
|
||||
text = run(["tasklist", "/FI", "IMAGENAME eq ykxmz.exe"])
|
||||
return "ykxmz" in text
|
||||
|
||||
|
||||
def stop_agent() -> bool:
|
||||
"""结束被控端进程,好让自检能绑上端口。"""
|
||||
run(["taskkill", "/IM", "ykxmz.exe", "/F"])
|
||||
time.sleep(1.5)
|
||||
return not agent_running()
|
||||
|
||||
|
||||
def ask_stop_agent() -> bool:
|
||||
"""询问是否关掉被控端。非交互环境直接返回 False。"""
|
||||
if not sys.stdin.isatty():
|
||||
return False
|
||||
line()
|
||||
line(" 被控端正在运行,占着 8765/8766,本自检绑不上端口。")
|
||||
line(" 要抓包必须先让它让出端口。")
|
||||
line()
|
||||
try:
|
||||
answer = input(" >>> 现在自动关掉被控端并继续抓包吗?(Y/N) ").strip().lower()
|
||||
except Exception:
|
||||
return False
|
||||
if not answer.startswith("y"):
|
||||
line(" 好,那本次不抓包。")
|
||||
line(" (这不是故障 —— 被控端在跑就说明它能收到包;")
|
||||
line(" 想抓包时先在它窗口点「退出」再重跑本脚本。)")
|
||||
return False
|
||||
if stop_agent():
|
||||
line(" 已关闭被控端,继续抓包。")
|
||||
line(" 抓完记得重新启动它。")
|
||||
return True
|
||||
line(" [警告] 关闭失败,可能是权限不够。请手动在它窗口点「退出」。")
|
||||
return False
|
||||
|
||||
|
||||
def show_network() -> str:
|
||||
title("1. 本机网络")
|
||||
ips = local_ips()
|
||||
if ips:
|
||||
for addr, tag in ips:
|
||||
line(f" 本机 IP:{addr} [{tag}]")
|
||||
line()
|
||||
line(f" >>> 手表上就填这个 IP:{ips[0][0]} <<<")
|
||||
else:
|
||||
line(" 没能读到本机 IP(网络可能没连上)")
|
||||
|
||||
line()
|
||||
wlan = run(["netsh", "wlan", "show", "interfaces"])
|
||||
ssid = None
|
||||
for raw in wlan.splitlines():
|
||||
text = raw.strip()
|
||||
if text.startswith("SSID") and "BSSID" not in text:
|
||||
ssid = text.split(":", 1)[-1].strip()
|
||||
if ssid:
|
||||
line(f" 当前 WiFi:{ssid}")
|
||||
line(" (手表必须连的是同一个 WiFi)")
|
||||
else:
|
||||
line(" 当前不是 WiFi 连接,或有线连接")
|
||||
|
||||
return ips[0][0] if ips else ""
|
||||
|
||||
|
||||
def show_ports() -> None:
|
||||
title("2. 端口占用情况")
|
||||
tcp = run(["netstat", "-ano", "-p", "TCP"])
|
||||
udp = run(["netstat", "-ano", "-p", "UDP"])
|
||||
tcp_hit = [l.strip() for l in tcp.splitlines()
|
||||
if f":{TCP_PORT}" in l and "LISTENING" in l.upper()]
|
||||
udp_hit = [l.strip() for l in udp.splitlines() if f":{UDP_PORT}" in l]
|
||||
|
||||
if tcp_hit:
|
||||
for item in tcp_hit:
|
||||
line(f" TCP {TCP_PORT} 已被监听: {item}")
|
||||
else:
|
||||
line(f" TCP {TCP_PORT} 没有监听")
|
||||
if udp_hit:
|
||||
for item in udp_hit:
|
||||
line(f" UDP {UDP_PORT} 已被监听: {item}")
|
||||
else:
|
||||
line(f" UDP {UDP_PORT} 没有监听")
|
||||
|
||||
line()
|
||||
if agent_running():
|
||||
line(" >>> 被控端(ykxmz.exe)正在运行。")
|
||||
line(" 它会占着 8765/8766,本自检就绑不上这两个端口。")
|
||||
line(" 想用自检抓包:先在它窗口上点「退出」,再重跑本脚本。")
|
||||
line(" 其实不用自检也可以 —— 被控端在跑就说明它能收包。")
|
||||
else:
|
||||
line(" 被控端没有在运行。想连手表的话需要先把它启动。")
|
||||
|
||||
|
||||
def show_firewall() -> None:
|
||||
title("3. 防火墙放行状态")
|
||||
for name in ("LAN Remote TCP", "LAN Remote UDP", "LAN Remote App TCP"):
|
||||
out = run(["netsh", "advfirewall", "firewall", "show", "rule",
|
||||
f"name={name}"])
|
||||
if not out.strip() or "No rules match" in out or "没有与指定条件" in out:
|
||||
line(f" {name}:未放行")
|
||||
else:
|
||||
enabled = action = ""
|
||||
for raw in out.splitlines():
|
||||
text = raw.strip()
|
||||
if text.startswith("Enabled") or text.startswith("已启用"):
|
||||
enabled = text.split(":", 1)[-1].strip()
|
||||
elif text.startswith("Action") or text.startswith("操作"):
|
||||
action = text.split(":", 1)[-1].strip()
|
||||
line(f" {name}:已放行 Enabled={enabled or '?'} Action={action or '?'}")
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 监听:这段才是关键
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
received_tcp: list[str] = []
|
||||
received_udp: list[str] = []
|
||||
bind_errors: list[str] = []
|
||||
|
||||
|
||||
def _bind(sock: socket.socket, port: int) -> bool:
|
||||
"""独占绑定。Windows 上必须用 SO_EXCLUSIVEADDRUSE,
|
||||
否则第二个进程也能绑上,抓包结果会失真。"""
|
||||
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)
|
||||
try:
|
||||
sock.bind(("0.0.0.0", port))
|
||||
return True
|
||||
except OSError as exc:
|
||||
reason = ""
|
||||
if exc.errno in _ADDR_IN_USE or (exc.winerror or 0) in _ADDR_IN_USE:
|
||||
reason = "端口被占用"
|
||||
bind_errors.append(f"{port}: {reason or exc}")
|
||||
return False
|
||||
|
||||
|
||||
def tcp_listener(stop: threading.Event) -> None:
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
if not _bind(server, TCP_PORT):
|
||||
line(f" !! 无法监听 TCP {TCP_PORT}")
|
||||
if agent_running():
|
||||
line(" 原因:被控端正在运行,占着这个端口。")
|
||||
line(" -> 先在它窗口上点「退出」,再重跑本自检。")
|
||||
return
|
||||
server.listen(8)
|
||||
server.settimeout(0.5)
|
||||
line(f" 正在监听 TCP {TCP_PORT} …")
|
||||
while not stop.is_set():
|
||||
try:
|
||||
conn, addr = server.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
src = f"{addr[0]}:{addr[1]}"
|
||||
received_tcp.append(src)
|
||||
line(f" [{stamp()}] >>> 收到 TCP 连接:来自 {src} <<<")
|
||||
try:
|
||||
conn.sendall(b"LANREMOTE-SELFTEST\n")
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
server.close()
|
||||
|
||||
|
||||
def udp_listener(stop: threading.Event) -> None:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
if not _bind(sock, UDP_PORT):
|
||||
line(f" !! 无法监听 UDP {UDP_PORT}")
|
||||
if agent_running():
|
||||
line(" 原因:被控端正在运行,占着这个端口。")
|
||||
return
|
||||
sock.settimeout(0.5)
|
||||
line(f" 正在监听 UDP {UDP_PORT} …")
|
||||
while not stop.is_set():
|
||||
try:
|
||||
data, addr = sock.recvfrom(2048)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except OSError:
|
||||
break
|
||||
src = f"{addr[0]}:{addr[1]}"
|
||||
received_udp.append(src)
|
||||
preview = data[:80].decode("utf-8", errors="replace")
|
||||
line(f" [{stamp()}] >>> 收到 UDP 包:来自 {src} {preview} <<<")
|
||||
sock.close()
|
||||
|
||||
|
||||
def run_check(seconds: int = 90) -> int:
|
||||
global LISTEN_SECONDS
|
||||
LISTEN_SECONDS = int(seconds or 90)
|
||||
setup_console()
|
||||
|
||||
title("局域网遥控 · 网络自检")
|
||||
line(" 这个工具监听端口,看手表的请求有没有到达这台电脑。")
|
||||
|
||||
show_network()
|
||||
show_ports()
|
||||
show_firewall()
|
||||
|
||||
title(f"4. 开始监听({LISTEN_SECONDS} 秒)")
|
||||
if agent_running():
|
||||
ask_stop_agent()
|
||||
line()
|
||||
line(" 现在去手表上:打开 App →「设备诊断」→ 底部填上面的 IP → 点「测一下」")
|
||||
line()
|
||||
|
||||
stop = threading.Event()
|
||||
for target, args in ((tcp_listener, (stop,)), (udp_listener, (stop,))):
|
||||
threading.Thread(target=target, args=args, daemon=True).start()
|
||||
time.sleep(0.2)
|
||||
|
||||
deadline = time.time() + LISTEN_SECONDS
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.5)
|
||||
left = int(deadline - time.time())
|
||||
print(f"\r 剩余 {left:3d} 秒 … 已收到 TCP {len(received_tcp)} 个 / "
|
||||
f"UDP {len(received_udp)} 个", end="", flush=True)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
stop.set()
|
||||
line()
|
||||
line()
|
||||
|
||||
title("5. 结论")
|
||||
if received_tcp or received_udp:
|
||||
line(f" TCP 收到 {len(received_tcp)} 个连接,UDP 收到 {len(received_udp)} 个包。")
|
||||
line()
|
||||
line(" >>> 网络是通的!手表的数据包能到达这台电脑。")
|
||||
line(" 既然包能到,连不上就只可能是:")
|
||||
line(" * 被控端没在运行")
|
||||
line(" * 或端口被别的程序占了")
|
||||
elif bind_errors and agent_running():
|
||||
line(" 没能抓包 —— 被控端正占着 8765/8766。")
|
||||
line()
|
||||
line(" >>> 这其实说明被控端是好的。要抓包请先退出它再重跑。")
|
||||
else:
|
||||
line(" TCP 和 UDP 都一个包都没收到。")
|
||||
line()
|
||||
line(" >>> 手表的数据包根本没到这台电脑。依次排查:")
|
||||
line(" 1) 电脑是不是休眠/关机了")
|
||||
line(" 2) 手表连的 WiFi 和电脑是不是同一个")
|
||||
line(" 3) 路由器有没有开「AP 隔离 / 客户端隔离」")
|
||||
line(" 4) 电脑上是不是有 VPN / 加速器在改路由表")
|
||||
line()
|
||||
if sys.stdin.isatty():
|
||||
line(" 按回车退出…")
|
||||
try:
|
||||
input()
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 直接 `python netcheck.py [秒数]` 也能用。
|
||||
# 原来这里写的是 main(),但本文件没有 main() ——
|
||||
# 入口是 run_check(),直接跑会 NameError。
|
||||
sys.exit(run_check(int(sys.argv[1]) if len(sys.argv) > 1 else 90))
|
||||
@@ -0,0 +1,179 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""通信协议常量与编解码。
|
||||
|
||||
设计目标:双端零外部依赖。
|
||||
* Windows 端:Python 标准库 socket + json
|
||||
* 安卓端:java.net.Socket + 系统自带 org.json
|
||||
|
||||
传输格式:TCP 长连接,每条消息一行 UTF-8 JSON,以 \\n 结尾。
|
||||
二进制负载(图片/音频/文件)用 base64 分块传输。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
DEFAULT_PORT = 8765
|
||||
PROTOCOL_VERSION = 1
|
||||
MAX_LINE_BYTES = 8 * 1024 * 1024 # 单行上限 8MB(约 6MB 原始数据)
|
||||
CHUNK_SIZE = 256 * 1024
|
||||
# 单次传输(文件 / 图片 / 音频)的总体积上限
|
||||
MAX_TOTAL_BYTES = 256 * 1024 * 1024 # 分块大小 256KB
|
||||
|
||||
# ---- 客户端 -> 服务端 命令 ----
|
||||
CMD_PAIR = "pair"
|
||||
CMD_AUTH = "auth"
|
||||
CMD_PING = "ping"
|
||||
CMD_IMAGE = "image"
|
||||
CMD_AUDIO = "audio"
|
||||
CMD_MEDIA = "media"
|
||||
CMD_CLIP_SET = "clip_set"
|
||||
CMD_CLIP_GET = "clip_get"
|
||||
CMD_POWER = "power"
|
||||
CMD_RUN = "run"
|
||||
CMD_SAY = "say"
|
||||
CMD_VOICE_LIST = "voice_list"
|
||||
CMD_SET_VOICE = "set_voice"
|
||||
CMD_AUDIO_LIST = "audio_list"
|
||||
CMD_PLAY_LOCAL = "play_local"
|
||||
CMD_SHUTDOWN_TIMER = "shutdown_timer" # 定时关机 / 取消
|
||||
CMD_MONITOR = "monitor" # 关闭 / 打开显示器
|
||||
CMD_OPEN_URL = "open_url" # 在电脑浏览器打开网址
|
||||
CMD_QUICK = "quick" # 快捷启动程序
|
||||
CMD_OPEN_FOLDER = "open_folder" # 打开常用文件夹
|
||||
CMD_PROC_LIST = "proc_list" # 进程列表
|
||||
CMD_PROC_KILL = "proc_kill" # 结束进程
|
||||
CMD_BATTERY = "battery" # 电量
|
||||
CMD_DISK = "disk" # 磁盘占用
|
||||
CMD_PERF = "perf" # CPU / 内存占用
|
||||
CMD_CLIP_HISTORY = "clip_history" # 剪贴板历史
|
||||
CMD_SET_WALLPAPER = "set_wallpaper" # 把收到的图设为壁纸
|
||||
CMD_SAY_CLIP = "say_clip" # 朗读剪贴板
|
||||
CMD_FEATURE_LIST = "feature_list" # 电脑端能做哪些功能
|
||||
CMD_FEATURE_RUN = "feature_run" # 执行某个功能
|
||||
CMD_AUDIO_PAUSE = "audio_pause" # 暂停电脑正在播的音频
|
||||
CMD_AUDIO_RESUME = "audio_resume" # 继续播放
|
||||
CMD_AUDIO_STATUS = "audio_status" # 当前播放状态
|
||||
CMD_NOTIFY = "notify"
|
||||
CMD_FILE_PUSH = "file_push"
|
||||
CMD_FILE_LIST = "file_list"
|
||||
CMD_FILE_PULL = "file_pull"
|
||||
CMD_INFO = "info"
|
||||
CMD_SCREEN_START = "screen_start"
|
||||
CMD_SCREEN_STOP = "screen_stop"
|
||||
CMD_SCREENSHOT = "screenshot"
|
||||
|
||||
# ---- 服务端 -> 客户端 事件 ----
|
||||
EVT_FILE = "file"
|
||||
EVT_CLIP = "clip"
|
||||
EVT_STATUS = "status"
|
||||
EVT_LOG = "log"
|
||||
EVT_FRAME = "frame"
|
||||
EVT_SCREEN_STOPPED = "screen_stopped"
|
||||
|
||||
MEDIA_ACTIONS = {
|
||||
"playpause": 0xB3,
|
||||
"next": 0xB0,
|
||||
"prev": 0xB1,
|
||||
"stop": 0xB2,
|
||||
"volup": 0xAF,
|
||||
"voldown": 0xAE,
|
||||
"mute": 0xAD,
|
||||
}
|
||||
|
||||
POWER_ACTIONS = {"shutdown", "restart", "lock", "sleep", "abort"}
|
||||
|
||||
# 手表端会用到的中文动作名(便于 UI 直接显示)
|
||||
MEDIA_LABELS = {
|
||||
"playpause": "播放/暂停",
|
||||
"next": "下一曲",
|
||||
"prev": "上一曲",
|
||||
"stop": "停止",
|
||||
"volup": "音量+",
|
||||
"voldown": "音量-",
|
||||
"mute": "静音",
|
||||
}
|
||||
|
||||
POWER_LABELS = {
|
||||
"shutdown": "关机",
|
||||
"restart": "重启",
|
||||
"lock": "锁屏",
|
||||
"sleep": "睡眠",
|
||||
"abort": "取消关机",
|
||||
}
|
||||
|
||||
|
||||
def dumps(obj: dict) -> bytes:
|
||||
"""编码成一行 JSON(UTF-8 + 换行)。"""
|
||||
return (json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def loads(line: bytes) -> dict:
|
||||
"""解析一行 JSON。"""
|
||||
return json.loads(line.decode("utf-8", errors="replace").strip())
|
||||
|
||||
|
||||
def b64encode(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def b64decode(text: str) -> bytes:
|
||||
return base64.b64decode(text.encode("ascii"))
|
||||
|
||||
|
||||
def chunk_bytes(data: bytes, size: int = CHUNK_SIZE):
|
||||
"""把字节流切成若干块,产出 (seq, is_last, chunk)。"""
|
||||
if not data:
|
||||
yield 0, True, b""
|
||||
return
|
||||
total = (len(data) + size - 1) // size
|
||||
for index in range(total):
|
||||
piece = data[index * size:(index + 1) * size]
|
||||
yield index, index == total - 1, piece
|
||||
|
||||
|
||||
class ChunkTooLarge(Exception):
|
||||
"""分块累积超过上限,或顺序不对。"""
|
||||
|
||||
|
||||
class ChunkAssembler:
|
||||
"""接收端:按 seq 收集分块,拼回完整数据。
|
||||
|
||||
MAX_LINE_BYTES 只管单行,累积体积必须另外设上限 ——
|
||||
否则已配对设备可以一直发 chunk 把内存吃干(最后 bytes() 还要再翻一倍)。
|
||||
"""
|
||||
|
||||
def __init__(self, limit: int = MAX_TOTAL_BYTES):
|
||||
self._buf = bytearray()
|
||||
self._expected = 0
|
||||
self._limit = int(limit)
|
||||
|
||||
def feed(self, seq: int, is_last: bool, data: bytes) -> bytes | None:
|
||||
"""返回完整数据(当收到最后一块时),否则 None。
|
||||
|
||||
顺序不对或超限时抛 ChunkTooLarge,不静默拼出坏文件。
|
||||
"""
|
||||
if seq == 0:
|
||||
self._buf = bytearray()
|
||||
self._expected = 0
|
||||
if seq != self._expected:
|
||||
self._buf = bytearray()
|
||||
self._expected = 0
|
||||
raise ChunkTooLarge(
|
||||
f"分块顺序不对(收到 {seq},应为 {self._expected})")
|
||||
if len(self._buf) + len(data) > self._limit:
|
||||
self._buf = bytearray()
|
||||
self._expected = 0
|
||||
raise ChunkTooLarge(f"文件超过上限({self._limit // 1048576} MB)")
|
||||
self._buf.extend(data)
|
||||
self._expected = seq + 1
|
||||
if is_last:
|
||||
result = bytes(self._buf)
|
||||
self._buf = bytearray()
|
||||
self._expected = 0
|
||||
return result
|
||||
return None
|
||||
|
||||
def reset(self) -> None:
|
||||
self._buf = bytearray()
|
||||
self._expected = 0
|
||||
@@ -0,0 +1,740 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""被控端走中转到手表(跨网络)。
|
||||
|
||||
整体思路
|
||||
--------
|
||||
现有服务端是"监听 TCP -> accept -> Session(conn) -> 逐条处理命令"。
|
||||
跨网络时被控端在 NAT 后面,没法被直连,所以改成**主动连出去**:
|
||||
|
||||
被控端 --HTTPS 长轮询--> 中转服务器 <--HTTPS 长轮询-- 手表
|
||||
|
||||
关键技巧:`RelaySocket` 假装成一个 socket。它只实现服务端用到的那几个
|
||||
方法(recv / sendall / settimeout / setsockopt / close / shutdown),
|
||||
内部把数据压到中转的队列里、从队列里取。
|
||||
|
||||
于是 `RemoteServer`、`Session`、所有命令处理**一行都不用改** ——
|
||||
原来的配对、令牌校验、权限判断全部继续生效。
|
||||
中转服务器只是个不认识内容的管道,被攻破也进不了用户的电脑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# 长轮询:中转那边挂 25 秒,客户端超时给宽一点
|
||||
POLL_TIMEOUT = 40.0
|
||||
# 单次请求体上限(一个 256KB 分块 Base64 后约 350KB,给足余量)
|
||||
MAX_BODY = 8 * 1024 * 1024
|
||||
# 网络断了之后的退避
|
||||
RETRY_MIN = 2.0
|
||||
RETRY_MAX = 20.0
|
||||
# 多久没成功通信就算掉线(用于界面上显示状态)
|
||||
STALE_SECONDS = 90.0
|
||||
|
||||
|
||||
# 请求头。**必须带 User-Agent**:
|
||||
# Cloudflare 的 Browser Integrity Check 会把 Python 默认 UA
|
||||
# (Python-urllib/3.11)判为可疑客户端,返回 403 + error code 1010。
|
||||
# 实测 LanRemote/1.4.0 能过,用自家标识比伪装浏览器合适。
|
||||
USER_AGENT = "LanRemote/1.4.0"
|
||||
|
||||
|
||||
# 私有网段 + 回环 + mDNS 名字 —— 这些肯定是局域网,用明文 HTTP
|
||||
_PRIVATE_HOST = re.compile(
|
||||
r"^(?:"
|
||||
r"localhost"
|
||||
r"|127\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
||||
r"|10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
||||
r"|192\.168\.\d{1,3}\.\d{1,3}"
|
||||
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}"
|
||||
r"|\d{1,3}(?:\.\d{1,3}){3}" # 任意纯 IP(含公网)
|
||||
r"|[^./:]+\.local" # mDNS 名字,如 lanremote.local
|
||||
r")"
|
||||
r"(?::\d{1,5})?$",
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def _guess_scheme(address: str) -> str:
|
||||
"""用户没写 http/https 时,猜一个。
|
||||
|
||||
IP 地址 / localhost / *.local -> http://
|
||||
局域网里的中转服务是明文 HTTP。猜成 https 会永远连不上,
|
||||
而且报的是 SSL 错误,很容易把人带去查证书(查半天查不出)。
|
||||
域名 -> https://
|
||||
走公网必须加密(Cloudflare 隧道本来就是 https)。
|
||||
"""
|
||||
host = address.split("/", 1)[0]
|
||||
return "http://" if _PRIVATE_HOST.match(host) else "https://"
|
||||
|
||||
|
||||
|
||||
"""用户没写 http/https 时,猜一个。
|
||||
|
||||
IP 地址 / localhost -> http://
|
||||
局域网里的中转服务是明文 HTTP。如果猜成 https,永远连不上,
|
||||
而且报的是 SSL 错误,很容易误导人去查证书。
|
||||
域名 -> https://
|
||||
走公网必须加密(Cloudflare 隧道本来就是 https)。
|
||||
"""
|
||||
host = address.split("/", 1)[0]
|
||||
return "http://" if _PRIVATE_HOST.match(host) else "https://"
|
||||
|
||||
|
||||
def _ssl_context() -> ssl.SSLContext:
|
||||
"""给 HTTPS 用的 SSL 上下文。
|
||||
|
||||
不依赖 certifi:Python 在 Windows 上会从系统证书库加载根证书,
|
||||
打包成 EXE 之后也能用。
|
||||
"""
|
||||
context = ssl.create_default_context()
|
||||
try:
|
||||
context.load_default_certs()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return context
|
||||
|
||||
|
||||
class RelayError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RelaySocket:
|
||||
"""假装成一个 socket,实际数据走中转服务器。
|
||||
|
||||
服务端只用到这几个方法,所以只实现这几个就够了。
|
||||
`recv()` 在没有数据时按 `settimeout()` 的设定阻塞等待。
|
||||
"""
|
||||
|
||||
def __init__(self, link: "RelayLink", session_token: str) -> None:
|
||||
self._link = link
|
||||
self.session_token = session_token
|
||||
self._inbox = bytearray()
|
||||
self._lock = threading.Lock()
|
||||
self._ready = threading.Event()
|
||||
self._closed = False
|
||||
self._timeout: float | None = None
|
||||
self.peer = ("中转服务器", 0)
|
||||
|
||||
# ---- 服务端会调用的接口 ----
|
||||
|
||||
def recv(self, size: int) -> bytes:
|
||||
deadline = (time.time() + self._timeout) if self._timeout else None
|
||||
while True:
|
||||
with self._lock:
|
||||
if self._inbox:
|
||||
data = bytes(self._inbox[:size])
|
||||
del self._inbox[:size]
|
||||
if not self._inbox:
|
||||
self._ready.clear()
|
||||
return data
|
||||
if self._closed:
|
||||
return b""
|
||||
if deadline is not None and time.time() >= deadline:
|
||||
raise socket.timeout("等待中转数据超时")
|
||||
self._ready.wait(0.5)
|
||||
if self._closed:
|
||||
return b""
|
||||
|
||||
def sendall(self, data: bytes) -> None:
|
||||
if self._closed:
|
||||
raise OSError("中转连接已关闭")
|
||||
self._link.send_to_client(data)
|
||||
|
||||
def settimeout(self, value) -> None:
|
||||
self._timeout = value
|
||||
|
||||
def setsockopt(self, *_args, **_kwargs) -> None:
|
||||
"""TCP_NODELAY 之类,这里没有意义,忽略掉就行。"""
|
||||
|
||||
def getpeername(self):
|
||||
return self.peer
|
||||
|
||||
def shutdown(self, _how=0) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
self._ready.set()
|
||||
|
||||
def fileno(self) -> int:
|
||||
# 有些代码会拿它做 select;这里不支持,返回 -1 明确表示
|
||||
return -1
|
||||
|
||||
# ---- 中转那边把数据喂进来 ----
|
||||
|
||||
def feed(self, data: bytes) -> None:
|
||||
if self._closed or not data:
|
||||
return
|
||||
with self._lock:
|
||||
self._inbox.extend(data)
|
||||
self._ready.set()
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed
|
||||
|
||||
|
||||
class RelayLink:
|
||||
"""被控端到中转服务器的长连接。
|
||||
|
||||
一条链路,一个"当前手表会话"。中转那边一台设备同时只允许一个
|
||||
手表会话,所以这里也只需要维护一个 RelaySocket。
|
||||
"""
|
||||
|
||||
def __init__(self, store, log=None, on_session=None, on_alert=None) -> None:
|
||||
self.store = store
|
||||
self._log = log or (lambda _m: None)
|
||||
self._on_session = on_session
|
||||
# 出需要用户处理的事(比如配对码被占)时调这个,用来弹通知
|
||||
self._on_alert = on_alert
|
||||
|
||||
self.url = ""
|
||||
self.candidates: list = []
|
||||
self._fail_count = 0
|
||||
self.device_id = ""
|
||||
self.device_name = str(store.get("server_name") or "")
|
||||
self.token = ""
|
||||
self.code = ""
|
||||
self.online = False
|
||||
self.last_ok = 0.0
|
||||
self.last_error = ""
|
||||
self.sessions_served = 0
|
||||
# 配对码冲突的说明。非空表示现在卡在这,等用户换码。
|
||||
self.code_conflict = ""
|
||||
self._alerted_code = ""
|
||||
|
||||
self._thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
self._socket: RelaySocket | None = None
|
||||
self._socket_lock = threading.Lock()
|
||||
self._retry = RETRY_MIN
|
||||
# 复用的 HTTP 连接。
|
||||
# 为什么必须复用:这条链路丢包约 40%,每次新建 TLS 连接有约
|
||||
# 30% 概率握手失败;连接建好后后续请求就 100% 成功。
|
||||
# 实测:每次新建 70% 可用,复用 100%。
|
||||
self._http = None
|
||||
self._http_lock = threading.RLock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 对外
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def start(self, url: str) -> bool:
|
||||
"""开始连中转。
|
||||
|
||||
url 支持用**分号**分隔多个地址,按顺序尝试:
|
||||
http://192.168.2.156:8080;https://relay.xxmxxs.top
|
||||
|
||||
为什么需要多个:
|
||||
* 电脑和随身 WiFi 常在同一局域网 —— 直连局域网中转更快更稳,
|
||||
而且地址永远不变(不用管隧道地址变来变去)
|
||||
* 电脑在外面时才走隧道
|
||||
"""
|
||||
candidates = []
|
||||
for piece in (url or "").replace(",", ";").split(";"):
|
||||
piece = piece.strip().rstrip("/")
|
||||
if not piece:
|
||||
continue
|
||||
if not piece.startswith(("http://", "https://")):
|
||||
piece = _guess_scheme(piece) + piece
|
||||
if piece not in candidates:
|
||||
candidates.append(piece)
|
||||
|
||||
if not candidates:
|
||||
self._log("没配置中转地址,跳过")
|
||||
return False
|
||||
|
||||
self.candidates = candidates
|
||||
# 先探一遍,挑第一个通的
|
||||
chosen = self._pick_candidate(candidates)
|
||||
self.url = chosen
|
||||
if len(candidates) > 1:
|
||||
self._log(f"中转地址候选 {len(candidates)} 个,选用 {chosen}")
|
||||
|
||||
self.device_id = self._ensure_device_id()
|
||||
self.code = str(self.store.pairing_code() or "")
|
||||
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._run, daemon=True,
|
||||
name="relay-link")
|
||||
self._thread.start()
|
||||
self._log(f"中转已启用:{self.url}")
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
with self._socket_lock:
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
if self.token:
|
||||
try:
|
||||
self._post("/v1/agent/bye", b"", {"X-Token": self.token},
|
||||
timeout=5)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def status(self) -> dict:
|
||||
return {
|
||||
"enabled": bool(self.url),
|
||||
"url": self.url,
|
||||
"candidates": list(self.candidates),
|
||||
"codeConflict": self.code_conflict,
|
||||
"deviceId": self.device_id,
|
||||
"code": self.code,
|
||||
"online": self.online and (time.time() - self.last_ok < STALE_SECONDS),
|
||||
"idleSeconds": int(time.time() - self.last_ok) if self.last_ok else -1,
|
||||
"sessions": self.sessions_served,
|
||||
"error": self.last_error,
|
||||
}
|
||||
|
||||
def send_to_client(self, data: bytes) -> None:
|
||||
"""把数据发给当前手表。"""
|
||||
if not self.token:
|
||||
raise OSError("还没连上中转")
|
||||
if not data:
|
||||
return
|
||||
for start in range(0, len(data), 256 * 1024):
|
||||
piece = data[start:start + 256 * 1024]
|
||||
self._post("/v1/agent/send", piece, {"X-Token": self.token},
|
||||
timeout=POLL_TIMEOUT)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _probe(self, base: str, timeout: float = 8.0) -> bool:
|
||||
"""这个地址通不通(打一下 /v1/health)。"""
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
base + "/v1/health", headers={"User-Agent": USER_AGENT})
|
||||
context = _ssl_context() if base.startswith("https://") else None
|
||||
with urllib.request.urlopen(request, timeout=timeout,
|
||||
context=context) as response:
|
||||
if response.status != 200:
|
||||
return False
|
||||
body = response.read(4096).decode("utf-8", "replace")
|
||||
return '"ok"' in body
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def _pick_candidate(self, candidates: list) -> str:
|
||||
for base in candidates:
|
||||
if self._probe(base):
|
||||
return base
|
||||
self._log(f" {base} 不通,试下一个")
|
||||
# 都不通也先返回第一个,让主循环去重试
|
||||
return candidates[0]
|
||||
|
||||
def _prefer_lan(self) -> None:
|
||||
"""定期回头看看:局域网那条路是不是又通了?
|
||||
|
||||
为什么需要:
|
||||
实测局域网中转 3.5ms、公网隧道 1.5 秒(还只有 75% 可用)。
|
||||
原来的逻辑只在**报错时**才切换地址,
|
||||
所以一旦切到隧道,就算局域网恢复了也不会切回来 ——
|
||||
白背着 400 倍的延迟和 25% 的丢包。
|
||||
|
||||
策略:每 60 秒挑一个"比当前更靠前"的候选试一下
|
||||
(候选表是按优先级排的:局域网在前,隧道在后),
|
||||
通了就切回去。只往前切、不往后切,避免来回抖。
|
||||
"""
|
||||
if not self.candidates or not self.url:
|
||||
return
|
||||
try:
|
||||
current = self.candidates.index(self.url)
|
||||
except ValueError:
|
||||
current = len(self.candidates)
|
||||
# 只试排在当前前面的那些(优先级更高)
|
||||
for base in self.candidates[:current]:
|
||||
if self._probe(base, timeout=3):
|
||||
self._log(f"发现更好的中转地址,切回 {base}")
|
||||
self.url = base
|
||||
self._drop_http() # 换地址了,旧连接作废
|
||||
return
|
||||
|
||||
def _refresh_url(self) -> None:
|
||||
"""主地址连不上时,去候选里找找有没有能用的。
|
||||
|
||||
如果某个候选本身就是局域网中转,还能顺便问它
|
||||
「现在的隧道地址是什么」(/url 接口),自动跟随地址变化。
|
||||
"""
|
||||
for base in list(self.candidates):
|
||||
if base == self.url:
|
||||
continue
|
||||
if not self._probe(base, timeout=5):
|
||||
continue
|
||||
self._log(f"切换到备用中转地址:{base}")
|
||||
self.url = base
|
||||
return
|
||||
|
||||
def _lan_ips(self) -> list:
|
||||
"""本机在局域网里的 IP(可能有多张网卡)。
|
||||
|
||||
手表靠这个判断"电脑是不是和我同一个网络"。
|
||||
拿不到就返回空列表 —— 中转那边会当成"不知道",
|
||||
手表就继续走中转,不会出错。
|
||||
"""
|
||||
found = []
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
# 不会真的发包,只是让系统选出默认出口网卡
|
||||
probe.connect(("8.8.8.8", 80))
|
||||
addr = probe.getsockname()[0]
|
||||
if addr and not addr.startswith("127."):
|
||||
found.append(addr)
|
||||
finally:
|
||||
probe.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None,
|
||||
socket.AF_INET):
|
||||
addr = info[4][0]
|
||||
if (addr and not addr.startswith("127.")
|
||||
and not addr.startswith("169.254.")
|
||||
and addr not in found):
|
||||
found.append(addr)
|
||||
except OSError:
|
||||
pass
|
||||
return found
|
||||
|
||||
def _check_code(self, code: str) -> dict:
|
||||
"""问中转:这个码被占了吗?(只读,不会占用它)
|
||||
|
||||
返回 {"ok": True, "taken": bool, "deviceName": str}
|
||||
连不上中转就返回 {"ok": False},调用方自己决定怎么办。
|
||||
"""
|
||||
bases = list(self.candidates) or ([self.url] if self.url else [])
|
||||
if not bases:
|
||||
return {"ok": False, "error": "还没配置中转地址"}
|
||||
quoted = urllib.parse.quote(code)
|
||||
for base in bases:
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
f"{base}/v1/code/check?code={quoted}",
|
||||
headers={"User-Agent": USER_AGENT})
|
||||
context = (_ssl_context() if base.startswith("https://")
|
||||
else None)
|
||||
with urllib.request.urlopen(request, timeout=10,
|
||||
context=context) as response:
|
||||
return json.loads(response.read(4096).decode(
|
||||
"utf-8", "replace"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last = f"{type(exc).__name__}: {exc}"
|
||||
continue
|
||||
return {"ok": False, "error": last if "last" in dir() else "连不上中转"}
|
||||
|
||||
def _ensure_device_id(self) -> str:
|
||||
"""设备唯一标识。存下来,重装/重启都不变 —— 单设备单配对码要用它。"""
|
||||
existing = str(self.store.get("relay_device_id") or "").strip()
|
||||
if existing:
|
||||
return existing
|
||||
# 带上机器名,便于在服务器管理页上认出来
|
||||
name = str(self.store.get("server_name") or socket.gethostname())
|
||||
safe = "".join(ch for ch in name if ch.isalnum() or ch in "-_")[:24]
|
||||
device_id = f"{safe or 'pc'}-{secrets.token_hex(6)}"
|
||||
self.store.set("relay_device_id", device_id)
|
||||
return device_id
|
||||
|
||||
def _run(self) -> None:
|
||||
backoff = RETRY_MIN
|
||||
while self._running:
|
||||
try:
|
||||
if not self.token:
|
||||
self._register()
|
||||
self._poll_once()
|
||||
backoff = RETRY_MIN
|
||||
# 每分钟回头看看有没有更快的中转(局域网优先)
|
||||
now = time.time()
|
||||
if now - getattr(self, "_last_prefer", 0.0) >= 60.0:
|
||||
self._last_prefer = now
|
||||
self._prefer_lan()
|
||||
except _AuthLost:
|
||||
self._log("中转说令牌失效,重新登记")
|
||||
self.token = ""
|
||||
time.sleep(1)
|
||||
except RelayCodeTaken as exc:
|
||||
# 配对码被别的设备占了。这不是网络问题,重试没用。
|
||||
# 弹一次通知,然后**停下来等用户换码**。
|
||||
message = str(exc) or "该配对码已被使用,请换一个配对码"
|
||||
self.online = False
|
||||
self.token = ""
|
||||
self.last_error = message
|
||||
self.code_conflict = message
|
||||
current = self.store.pairing_code()
|
||||
if current != self._alerted_code:
|
||||
# 只在换了新码之后再弹,避免反复弹同一个码的通知
|
||||
self._alerted_code = current
|
||||
self._log(f"配对码冲突:{message}")
|
||||
if self._on_alert:
|
||||
try:
|
||||
self._on_alert("配对码被占用", message)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# 等用户换码。每 5 秒看一次配对码变没变。
|
||||
for _ in range(12):
|
||||
if not self._running:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
if self.store.pairing_code() != current:
|
||||
self._log("配对码变了,重新登记")
|
||||
self._alerted_code = ""
|
||||
break
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.online = False
|
||||
self.last_error = f"{type(exc).__name__}: {exc}"
|
||||
self._log(f"中转连接出问题:{self.last_error}")
|
||||
# 网络断了:把当前会话关掉,让上层重开
|
||||
self._drop_socket()
|
||||
self.token = ""
|
||||
# 连续失败几次就换一个候选地址试试
|
||||
fail_count = getattr(self, "_fail_count", 0) + 1
|
||||
self._fail_count = fail_count
|
||||
if fail_count >= 3 and len(getattr(self, "candidates", [])) > 1:
|
||||
self._fail_count = 0
|
||||
self._refresh_url()
|
||||
time.sleep(backoff)
|
||||
backoff = min(RETRY_MAX, backoff * 1.6)
|
||||
|
||||
def _register(self) -> None:
|
||||
# 每次登记都从设置里重新读配对码。
|
||||
# 用户可能刚改过(比如上一个码被别的设备占了),
|
||||
# 不重读就会一直拿旧码去报,永远卡在冲突里出不来。
|
||||
latest = str(self.store.pairing_code() or "").strip()
|
||||
if latest and latest != self.code:
|
||||
self._log(f"配对码已改为 {latest},用新的重新登记")
|
||||
self.code = latest
|
||||
|
||||
try:
|
||||
listen_port = int(self.store.get("port") or 0)
|
||||
except (TypeError, ValueError):
|
||||
listen_port = 0
|
||||
payload = json.dumps({
|
||||
"deviceId": self.device_id,
|
||||
"code": self.code,
|
||||
"name": self.device_name or self.device_id,
|
||||
"version": _version(),
|
||||
# 手表用这两个判断要不要切成局域网直连
|
||||
"lanIps": self._lan_ips(),
|
||||
"port": listen_port,
|
||||
}, ensure_ascii=False).encode("utf-8")
|
||||
try:
|
||||
status, body = self._post("/v1/agent/register", payload, {},
|
||||
timeout=30)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# 中转用 400 表示"登记被拒",错误详情在响应体里。
|
||||
# urlopen 抛 HTTPError 时不会把 body 交出来,得自己读。
|
||||
# 不读的话配对码冲突就被当成普通网络错误,
|
||||
# 用户永远看不到"该配对码已被使用"的通知。
|
||||
body = exc.read(MAX_BODY)
|
||||
try:
|
||||
reply = json.loads(body.decode("utf-8", "replace"))
|
||||
except Exception: # noqa: BLE001
|
||||
raise RelayError(f"登记响应看不懂:{body[:120]!r}") from None
|
||||
if not reply.get("ok"):
|
||||
message = str(reply.get("error") or "登记被拒")
|
||||
# 配对码被占:精确判断(服务端会带 codeTaken),
|
||||
# 兼容老服务端就匹配关键字。
|
||||
if reply.get("codeTaken") or "已被" in message and "配对码" in message:
|
||||
raise RelayCodeTaken(message)
|
||||
raise RelayError(message)
|
||||
# 登记成功 -> 之前的冲突解除了
|
||||
self.code_conflict = ""
|
||||
|
||||
self.token = str(reply.get("token") or "")
|
||||
# 中转可能按「单设备单配对码」把码改回旧的,这里跟着改
|
||||
server_code = str(reply.get("code") or "")
|
||||
if server_code and server_code != self.code:
|
||||
self.code = server_code
|
||||
try:
|
||||
self.store.set_pairing_code(server_code)
|
||||
self._log(f"中转按单设备单配对码把配对码改回 {server_code}")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
note = str(reply.get("note") or "")
|
||||
if note:
|
||||
self._log(note)
|
||||
self.online = True
|
||||
self.last_ok = time.time()
|
||||
self.last_error = ""
|
||||
self._log(f"已登记到中转:{self.device_name} 配对码 {self.code}")
|
||||
|
||||
def _poll_once(self) -> None:
|
||||
headers = {"X-Token": self.token}
|
||||
try:
|
||||
status, body, extra = self._post(
|
||||
"/v1/agent/poll", b"", headers, timeout=POLL_TIMEOUT,
|
||||
want_headers=True)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 401:
|
||||
raise _AuthLost() from None
|
||||
raise
|
||||
|
||||
self.online = True
|
||||
self.last_ok = time.time()
|
||||
self.last_error = ""
|
||||
|
||||
# 会话变了 -> 手表换了一台/重连了 -> 重开一条会话
|
||||
session_token = (extra.get("X-Relay-Session") or "-").strip()
|
||||
self._sync_session(session_token)
|
||||
|
||||
if body and session_token not in ("", "-"):
|
||||
with self._socket_lock:
|
||||
if self._socket is not None:
|
||||
self._socket.feed(body)
|
||||
|
||||
def _sync_session(self, session_token: str) -> None:
|
||||
"""中转报的会话令牌和本地不一致时,重建 RelaySocket。"""
|
||||
with self._socket_lock:
|
||||
current = self._socket
|
||||
if current is not None and current.session_token == session_token:
|
||||
return
|
||||
if session_token in ("", "-"):
|
||||
# 手表都不在了,把当前会话关掉
|
||||
if current is not None:
|
||||
current.close()
|
||||
self._socket = None
|
||||
return
|
||||
if current is not None:
|
||||
current.close()
|
||||
new_socket = RelaySocket(self, session_token)
|
||||
self._socket = new_socket
|
||||
self.sessions_served += 1
|
||||
|
||||
self._log(f"手表接入中转(会话 {session_token[:8]}…)")
|
||||
if self._on_session is not None:
|
||||
try:
|
||||
self._on_session(new_socket)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(f"建立会话失败:{exc}")
|
||||
|
||||
def _drop_socket(self) -> None:
|
||||
with self._socket_lock:
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
|
||||
# ---- HTTP ----
|
||||
|
||||
def _open_http(self):
|
||||
"""按 self.url 建一条 HTTP(S) 连接。"""
|
||||
import http.client
|
||||
parts = urllib.parse.urlsplit(self.url)
|
||||
host = parts.hostname or ""
|
||||
if parts.scheme == "https":
|
||||
return http.client.HTTPSConnection(
|
||||
host, parts.port or 443, timeout=POLL_TIMEOUT,
|
||||
context=_ssl_context())
|
||||
return http.client.HTTPConnection(host, parts.port or 80,
|
||||
timeout=POLL_TIMEOUT)
|
||||
|
||||
def _drop_http(self) -> None:
|
||||
"""把缓存的连接关掉(出错后调用,下次会重开)。"""
|
||||
with self._http_lock:
|
||||
connection, self._http = self._http, None
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _post(self, path: str, body: bytes, headers: dict,
|
||||
timeout: float = POLL_TIMEOUT, want_headers: bool = False):
|
||||
"""POST 一个请求,**复用同一条连接**。
|
||||
|
||||
为什么要复用(真机实测):
|
||||
这条链路丢包约 40%,每次新建 TLS 连接约 30% 概率握手失败。
|
||||
但连接建好之后,后续请求 100% 成功(TCP 会自己重传)。
|
||||
实测:每次新建 -> 70% 可用;复用 -> 100%。
|
||||
|
||||
所以这里:
|
||||
* 优先用缓存的那条连接
|
||||
* 断了就重开一次再试(短抖动自己扛过去)
|
||||
* 两次都失败才算真失败,往上抛
|
||||
"""
|
||||
send = dict(headers or {})
|
||||
send["Content-Type"] = "application/octet-stream"
|
||||
send["User-Agent"] = "LanRemote-Agent/" + _version()
|
||||
send["Connection"] = "keep-alive"
|
||||
|
||||
last_error = None
|
||||
for attempt in (1, 2):
|
||||
with self._http_lock:
|
||||
connection = self._http
|
||||
if connection is None:
|
||||
connection = self._open_http()
|
||||
self._http = connection
|
||||
try:
|
||||
connection.timeout = timeout
|
||||
connection.request("POST", path, body=body, headers=send)
|
||||
response = connection.getresponse()
|
||||
payload = response.read(MAX_BODY)
|
||||
status = response.status
|
||||
# 4xx/5xx:抛 HTTPError,但**必须把响应体带上**。
|
||||
# 中转用 400 + JSON body 表示"登记被拒"(配对码被占),
|
||||
# 上层要读这个 body 才能区分"码被占"和"普通网络错误"。
|
||||
# 早先这里传 fp=None,导致 exc.read() 永远是 b'',
|
||||
# 配对码冲突就识别不出来了。
|
||||
if status >= 400:
|
||||
import io as _io
|
||||
self._drop_http()
|
||||
reason = "unauthorized" if status == 401 else "http error"
|
||||
raise urllib.error.HTTPError(
|
||||
self.url + path, status, reason,
|
||||
response.headers, _io.BytesIO(payload))
|
||||
if want_headers:
|
||||
extra = {name: value
|
||||
for name, value in response.headers.items()}
|
||||
return status, payload, extra
|
||||
return status, payload
|
||||
except urllib.error.HTTPError:
|
||||
raise # HTTP 状态码是业务层的,不算连接问题
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 连接层面的问题:关掉,下一轮重开
|
||||
last_error = exc
|
||||
self._drop_http()
|
||||
if attempt == 2:
|
||||
break
|
||||
self._log_quiet(f"连接断了,重开一次再试({exc})")
|
||||
|
||||
raise RelayError(f"请求失败: {last_error}")
|
||||
|
||||
def _log_quiet(self, message: str) -> None:
|
||||
"""记一条不打扰用户的日志(重连这种事不用弹通知)。"""
|
||||
try:
|
||||
self._log(message)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
class RelayCodeTaken(RelayError):
|
||||
"""配对码被别的设备占了。
|
||||
|
||||
这**不是网络问题**,重试没用 —— 要等用户换一个配对码。
|
||||
所以主循环遇到它不能当成普通错误一直重试(会刷屏),
|
||||
而是弹一次通知然后停下等。
|
||||
"""
|
||||
|
||||
|
||||
class _AuthLost(Exception):
|
||||
"""中转不认识我们的令牌了(重启过 / 被清理),要重新登记。"""
|
||||
|
||||
|
||||
def _version() -> str:
|
||||
try:
|
||||
from . import __version__
|
||||
return __version__
|
||||
except Exception: # noqa: BLE001
|
||||
return "1.4.0"
|
||||
@@ -0,0 +1,243 @@
|
||||
# -*- 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""被控端唯一的窗口:首次设置窗口(重做版)。
|
||||
|
||||
整个程序只有这一个窗口,而且只在**第一次运行**时出现一次:
|
||||
|
||||
双击 exe
|
||||
→ 弹出这个窗口:设 6 位配对码 + 勾「开机自启」「放行防火墙」
|
||||
→ 点确定 → 先检查这个码在服务器上有没有被占
|
||||
被占就弹框让你换,换好了才继续
|
||||
→ 黑终端一闪(放行防火墙)
|
||||
→ 防火墙没放行成功的话,**不把码交给服务器**,并说明原因
|
||||
→ 之后永远后台静默,屏幕上什么都没有
|
||||
→ 手表输这 6 位数字就连上了
|
||||
|
||||
界面重做的要点:
|
||||
* 用作者给的图标(原来是代码画的一个蓝方块)
|
||||
* 分区卡片:配对码单独一块,选项单独一块,不再是一堆裸控件
|
||||
* 配对码框大字号、居中、字距拉开,一眼能看清
|
||||
* 状态提示带颜色(红=错误,黄=提醒,绿=通过)
|
||||
* 配对码统一成 **6 位数字**(中转就是要这个格式,
|
||||
以前允许 6-16 位字母数字,用户填 10 位会在检查那步卡死)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 配色(浅色,和手表端一致)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
BG = "#eef1f6"
|
||||
CARD = "#ffffff"
|
||||
FG = "#1f2328"
|
||||
ACCENT = "#185ce8"
|
||||
ACCENT_DARK = "#0f47bd"
|
||||
OK = "#1a7f37"
|
||||
WARN = "#9a6700"
|
||||
DANGER = "#cf222e"
|
||||
DIM = "#656d76"
|
||||
MUTED = "#8c959f"
|
||||
BORDER = "#d8dee4"
|
||||
|
||||
FONT_UI = ("Microsoft YaHei UI", 10)
|
||||
FONT_TITLE = ("Microsoft YaHei UI", 17, "bold")
|
||||
FONT_SUB = ("Microsoft YaHei UI", 9)
|
||||
FONT_SMALL = ("Microsoft YaHei UI", 9)
|
||||
FONT_LABEL = ("Microsoft YaHei UI", 10, "bold")
|
||||
FONT_CODE = ("Consolas", 26, "bold")
|
||||
FONT_BTN = ("Microsoft YaHei UI", 10, "bold")
|
||||
|
||||
# 图标源图(作者给的)
|
||||
ICON_SOURCE = Path(__file__).resolve().parent.parent.parent / "_build" / "icon_source.png"
|
||||
|
||||
|
||||
def make_icon_image(size: int = 64):
|
||||
"""取图标。优先用作者给的那张图,读不到再退回代码画的那个。"""
|
||||
try:
|
||||
from PIL import Image
|
||||
if ICON_SOURCE.is_file():
|
||||
img = Image.open(ICON_SOURCE).convert("RGBA")
|
||||
width, height = img.size
|
||||
side = min(width, height)
|
||||
left = (width - side) // 2
|
||||
top = (height - side) // 2
|
||||
img = img.crop((left, top, left + side, top + side))
|
||||
return img.resize((size, size), Image.LANCZOS)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return _drawn_icon(size)
|
||||
|
||||
|
||||
ICON_BLUE = (24, 92, 232, 255)
|
||||
|
||||
|
||||
def _drawn_icon(size: int = 64):
|
||||
"""兜底:代码画一个蓝底手表。"""
|
||||
from PIL import Image, ImageDraw
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
margin = max(1, size // 16)
|
||||
draw.rounded_rectangle([margin, margin, size - margin, size - margin],
|
||||
radius=max(2, size // 5), fill=ICON_BLUE)
|
||||
cx = size // 2
|
||||
box_w = int(size * 0.36)
|
||||
box_h = int(size * 0.30)
|
||||
top = int(size * 0.35)
|
||||
strap_w = int(size * 0.16)
|
||||
draw.rounded_rectangle([cx - strap_w // 2, top - int(size * 0.10),
|
||||
cx + strap_w // 2, top],
|
||||
radius=max(1, size // 32), fill=(255, 255, 255, 255))
|
||||
draw.rounded_rectangle([cx - strap_w // 2, top + box_h,
|
||||
cx + strap_w // 2, top + box_h + int(size * 0.10)],
|
||||
radius=max(1, size // 32), fill=(255, 255, 255, 255))
|
||||
draw.rounded_rectangle([cx - box_w // 2, top, cx + box_w // 2, top + box_h],
|
||||
radius=max(2, size // 16), fill=(255, 255, 255, 255))
|
||||
return img
|
||||
|
||||
|
||||
class SetupWindow:
|
||||
"""首次使用的设置窗口。
|
||||
|
||||
用法:
|
||||
result = SetupWindow(store).show()
|
||||
if result is None: # 用户关掉了
|
||||
...
|
||||
result["code"] # 用户设的配对码
|
||||
result["autostart"] # 要不要开机自启
|
||||
result["firewall"] # 要不要顺手放行防火墙
|
||||
"""
|
||||
|
||||
CODE_LEN = 6
|
||||
|
||||
def __init__(self, store, suggested: str = ""):
|
||||
self.store = store
|
||||
self.suggested = (suggested or store.pairing_code()
|
||||
or store.new_pairing_code())
|
||||
self.result: dict | None = None
|
||||
self.root: tk.Tk | None = None
|
||||
self._after_ids: list = []
|
||||
self._icon = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def show(self, on_ready=None) -> dict | None:
|
||||
"""弹出窗口并阻塞,直到用户点确定或关掉。
|
||||
|
||||
on_ready 是窗口建好之后的回调(自动化测试用)。
|
||||
"""
|
||||
self.root = tk.Tk()
|
||||
self.root.title("ykxmz · 第一次设置")
|
||||
self.root.configure(bg=BG)
|
||||
self.root.resizable(False, False)
|
||||
self._set_icon()
|
||||
self._build()
|
||||
|
||||
self.root.update_idletasks()
|
||||
if on_ready is not None:
|
||||
def _fire():
|
||||
if self._alive():
|
||||
on_ready(self)
|
||||
self._after_ids.append(self.root.after(300, _fire))
|
||||
|
||||
self._center()
|
||||
self.root.lift()
|
||||
self.root.attributes("-topmost", True)
|
||||
|
||||
def _drop_topmost():
|
||||
if self._alive():
|
||||
try:
|
||||
self.root.attributes("-topmost", False)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
self._after_ids.append(self.root.after(700, _drop_topmost))
|
||||
self.root.mainloop()
|
||||
|
||||
for after_id in self._after_ids:
|
||||
try:
|
||||
self.root.after_cancel(after_id)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self._after_ids = []
|
||||
try:
|
||||
self.root.destroy()
|
||||
except tk.TclError:
|
||||
pass
|
||||
self.root = None
|
||||
return self.result
|
||||
|
||||
def _alive(self) -> bool:
|
||||
"""窗口还在吗。after 回调必须在销毁后自己退出,否则会报
|
||||
invalid command name。"""
|
||||
try:
|
||||
return self.root is not None and bool(self.root.winfo_exists())
|
||||
except tk.TclError:
|
||||
return False
|
||||
|
||||
def _set_icon(self) -> None:
|
||||
try:
|
||||
from PIL import ImageTk
|
||||
self._icon = ImageTk.PhotoImage(make_icon_image(64))
|
||||
self.root.iconphoto(True, self._icon)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _center(self) -> None:
|
||||
width = self.root.winfo_reqwidth()
|
||||
height = self.root.winfo_reqheight()
|
||||
screen_w = self.root.winfo_screenwidth()
|
||||
screen_h = self.root.winfo_screenheight()
|
||||
x = max(0, (screen_w - width) // 2)
|
||||
y = max(0, (screen_h - height) // 3)
|
||||
self.root.geometry(f"{width}x{height}+{x}+{y}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _card(self, parent) -> tk.Frame:
|
||||
"""一个白底圆角感的卡片(tkinter 没有圆角,用边框色模拟)。
|
||||
|
||||
返回的是**外层** frame —— 调用处要 pack 它。
|
||||
踩过的坑:一开始返回 inner,而 inner 的父控件是 outer,
|
||||
于是 inner.pack() 挂回 outer,outer 自己从没被 pack,
|
||||
结果卡片完全不显示(窗口高度只剩 199px)。
|
||||
"""
|
||||
outer = tk.Frame(parent, bg=BORDER)
|
||||
inner = tk.Frame(outer, bg=CARD)
|
||||
inner.pack(fill="both", expand=True, padx=1, pady=1)
|
||||
return outer
|
||||
|
||||
def _build(self) -> None:
|
||||
root = self.root
|
||||
|
||||
# ---- 顶部强调条 ----
|
||||
tk.Frame(root, bg=ACCENT, height=5).pack(fill="x")
|
||||
|
||||
page = tk.Frame(root, bg=BG)
|
||||
page.pack(fill="both", expand=True, padx=20, pady=18)
|
||||
|
||||
# ================= 标题区 =================
|
||||
header = tk.Frame(page, bg=BG)
|
||||
header.pack(fill="x")
|
||||
|
||||
try:
|
||||
from PIL import ImageTk
|
||||
badge = ImageTk.PhotoImage(make_icon_image(46))
|
||||
icon_label = tk.Label(header, image=badge, bg=BG, bd=0)
|
||||
icon_label.image = badge # 留引用
|
||||
icon_label.pack(side="left", padx=(0, 12))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
titles = tk.Frame(header, bg=BG)
|
||||
titles.pack(side="left", anchor="w")
|
||||
tk.Label(titles, text="ykxmz", bg=BG, fg=FG, font=FONT_TITLE).pack(
|
||||
anchor="w")
|
||||
tk.Label(titles, text="远程控制 · 第一次设置", bg=BG, fg=DIM,
|
||||
font=FONT_SUB).pack(anchor="w")
|
||||
|
||||
# ================= 配对码卡片 =================
|
||||
card = self._card(page)
|
||||
card.pack(fill="x", pady=(16, 0))
|
||||
|
||||
inner = tk.Frame(card.winfo_children()[0], bg=CARD)
|
||||
inner.pack(fill="x", padx=16, pady=14)
|
||||
|
||||
tk.Label(inner, text="配对码", bg=CARD, fg=FG, font=FONT_LABEL).pack(
|
||||
anchor="w")
|
||||
|
||||
code_row = tk.Frame(inner, bg=CARD)
|
||||
code_row.pack(fill="x", pady=(8, 0))
|
||||
|
||||
code_box = tk.Frame(code_row, bg="#f6f8fa",
|
||||
highlightthickness=1,
|
||||
highlightbackground=BORDER,
|
||||
highlightcolor=ACCENT)
|
||||
code_box.pack(side="left")
|
||||
|
||||
self.entry = tk.Entry(
|
||||
code_box, bg="#f6f8fa", fg=ACCENT_DARK, relief="flat",
|
||||
font=FONT_CODE, width=7, justify="center",
|
||||
insertbackground=ACCENT)
|
||||
self.entry.insert(0, self.suggested)
|
||||
self.entry.pack(padx=10, pady=6)
|
||||
self.entry.select_range(0, "end")
|
||||
self.entry.focus_set()
|
||||
# 只允许输入数字,且最多 6 位
|
||||
self.entry.bind("<KeyRelease>", self._on_code_key)
|
||||
|
||||
self._flat_button(code_row, "换一个", self._reroll).pack(
|
||||
side="left", padx=(10, 0))
|
||||
|
||||
tk.Label(inner, text="6 位数字。手表上输这 6 位就能连上这台电脑。",
|
||||
bg=CARD, fg=MUTED, font=FONT_SMALL).pack(
|
||||
anchor="w", pady=(8, 0))
|
||||
|
||||
self.status = tk.Label(inner, text="", bg=CARD, fg=DANGER,
|
||||
font=FONT_SMALL, wraplength=330,
|
||||
justify="left", anchor="w")
|
||||
self.status.pack(fill="x", pady=(6, 0))
|
||||
|
||||
# ================= 选项卡片 =================
|
||||
options = self._card(page)
|
||||
options.pack(fill="x", pady=(12, 0))
|
||||
opt_inner = tk.Frame(options.winfo_children()[0], bg=CARD)
|
||||
opt_inner.pack(fill="x", padx=16, pady=12)
|
||||
|
||||
tk.Label(opt_inner, text="选项", bg=CARD, fg=FG,
|
||||
font=FONT_LABEL).pack(anchor="w", pady=(0, 6))
|
||||
|
||||
self.var_auto = tk.BooleanVar(value=True)
|
||||
self._check(opt_inner, "开机自动运行", self.var_auto,
|
||||
"开机后在后台跑,不用手动开")
|
||||
self.var_firewall = tk.BooleanVar(value=True)
|
||||
self._check(opt_inner, "放行防火墙", self.var_firewall,
|
||||
"会弹一次管理员确认,然后黑窗口一闪而过")
|
||||
tk.Label(opt_inner,
|
||||
text="没放行成功的话,配对码不会提交给服务器,"
|
||||
"会告诉你怎么处理。",
|
||||
bg=CARD, fg=MUTED, font=FONT_SMALL, justify="left",
|
||||
wraplength=396).pack(anchor="w", pady=(6, 0))
|
||||
|
||||
# ================= 按钮区 =================
|
||||
buttons = tk.Frame(page, bg=BG)
|
||||
buttons.pack(fill="x", pady=(16, 0))
|
||||
|
||||
primary = tk.Button(
|
||||
buttons, text="确定,开始使用", command=self._confirm,
|
||||
relief="flat", bd=0, font=FONT_BTN, cursor="hand2",
|
||||
bg=ACCENT, fg="#ffffff",
|
||||
activebackground=ACCENT_DARK, activeforeground="#ffffff",
|
||||
padx=22, pady=10)
|
||||
primary.pack(side="left")
|
||||
|
||||
tk.Button(
|
||||
buttons, text="取消", command=self._cancel,
|
||||
relief="flat", bd=0, font=FONT_UI, cursor="hand2",
|
||||
bg=BG, fg=DIM, activebackground="#e2e6ec", activeforeground=FG,
|
||||
padx=14, pady=10).pack(side="left", padx=(8, 0))
|
||||
|
||||
tk.Label(page, text="设完之后程序就在后台跑,不会再出现任何窗口。"
|
||||
"需要重设就跑 ykxmz.exe --setup",
|
||||
bg=BG, fg=MUTED, font=FONT_SMALL).pack(anchor="w",
|
||||
pady=(12, 0))
|
||||
|
||||
root.bind("<Return>", lambda _e: self._confirm())
|
||||
root.bind("<Escape>", lambda _e: self._cancel())
|
||||
root.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||
|
||||
def _check(self, parent, title: str, variable, hint: str) -> None:
|
||||
"""一行勾选框 + 灰字说明。"""
|
||||
row = tk.Frame(parent, bg=CARD)
|
||||
row.pack(fill="x", pady=(2, 0))
|
||||
tk.Checkbutton(row, text=title, variable=variable, bg=CARD, fg=FG,
|
||||
activebackground=CARD, activeforeground=FG,
|
||||
selectcolor="#ffffff", font=FONT_UI, bd=0,
|
||||
highlightthickness=0,
|
||||
cursor="hand2").pack(side="left")
|
||||
tk.Label(row, text=hint, bg=CARD, fg=MUTED,
|
||||
font=FONT_SMALL).pack(side="left", padx=(8, 0))
|
||||
|
||||
def _flat_button(self, parent, text, command, primary: bool = False):
|
||||
button = tk.Button(
|
||||
parent, text=text, command=command, relief="flat", bd=0,
|
||||
font=FONT_UI if not primary else FONT_BTN, cursor="hand2",
|
||||
bg="#eaeef2" if not primary else ACCENT,
|
||||
fg=FG if not primary else "#ffffff",
|
||||
activebackground="#dfe3e8" if not primary else ACCENT_DARK,
|
||||
activeforeground=FG if not primary else "#ffffff",
|
||||
padx=12, pady=6)
|
||||
return button
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_code_key(self, _event=None) -> None:
|
||||
"""输入时过滤:只留数字,最多 6 位。"""
|
||||
raw = self.entry.get()
|
||||
cleaned = "".join(ch for ch in raw if ch.isdigit())[:self.CODE_LEN]
|
||||
if cleaned != raw:
|
||||
position = self.entry.index("insert")
|
||||
self.entry.delete(0, "end")
|
||||
self.entry.insert(0, cleaned)
|
||||
self.entry.icursor(min(position, len(cleaned)))
|
||||
if len(cleaned) == self.CODE_LEN:
|
||||
self._set_status("", MUTED)
|
||||
|
||||
def _set_status(self, text: str, color: str) -> None:
|
||||
try:
|
||||
self.status.configure(text=text, fg=color)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _reroll(self) -> None:
|
||||
self.entry.delete(0, "end")
|
||||
self.entry.insert(0, self.store.new_pairing_code())
|
||||
self._set_status("", MUTED)
|
||||
self.entry.focus_set()
|
||||
|
||||
def _confirm(self) -> None:
|
||||
typed = self.entry.get().strip()
|
||||
cleaned = "".join(ch for ch in typed if ch.isdigit())
|
||||
|
||||
if not cleaned:
|
||||
self._set_status("请填一个 6 位数字", DANGER)
|
||||
return
|
||||
if len(cleaned) != self.CODE_LEN:
|
||||
self._set_status(
|
||||
f"配对码要正好 {self.CODE_LEN} 位数字,现在是 {len(cleaned)} 位",
|
||||
DANGER)
|
||||
return
|
||||
|
||||
self.result = {
|
||||
"code": cleaned,
|
||||
"autostart": bool(self.var_auto.get()),
|
||||
"firewall": bool(self.var_firewall.get()),
|
||||
}
|
||||
try:
|
||||
self.root.quit()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _cancel(self) -> None:
|
||||
self.result = None
|
||||
try:
|
||||
self.root.quit()
|
||||
except tk.TclError:
|
||||
pass
|
||||
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""卸载:一条命令把程序从这台电脑上清干净。
|
||||
|
||||
ykxmz.exe --uninstall
|
||||
|
||||
清这些:
|
||||
1. 停掉正在跑的进程
|
||||
2. 删掉开机自启项
|
||||
3. 删掉数据目录(配对码、已配对手表……)
|
||||
|
||||
**故意做这个**:一个能开机自启、又能远程控制的软件,
|
||||
必须能干净地卸载掉 —— 否则它就成了"赖着不走"的东西。
|
||||
电脑的主人应该随时能把它彻底清掉。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .autostart import VALUE_NAME, RUN_KEY, LEGACY_VALUE_NAMES, _run
|
||||
from .config import app_dir, data_dir
|
||||
|
||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
def _kill_running() -> str:
|
||||
"""把正在跑的进程停掉(除了自己)。"""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return "(源码模式,跳过进程清理)"
|
||||
exe = Path(sys.executable).name
|
||||
code, out = _run(["taskkill", "/F", "/IM", exe])
|
||||
if code == 0:
|
||||
return f"已停止进程 {exe}"
|
||||
if "not found" in out.lower() or "没有" in out:
|
||||
return "没有正在运行的进程"
|
||||
return f"停止进程:{out[:80]}"
|
||||
|
||||
|
||||
def _remove_autostart() -> str:
|
||||
done = []
|
||||
for name in (VALUE_NAME,) + tuple(LEGACY_VALUE_NAMES):
|
||||
code, _out = _run(["reg", "delete", RUN_KEY, "/v", name, "/f"])
|
||||
if code == 0:
|
||||
done.append(name)
|
||||
if done:
|
||||
return f"已删除开机自启项 {', '.join(done)}"
|
||||
return "没有开机自启项"
|
||||
|
||||
|
||||
def _remove_data() -> str:
|
||||
"""删数据目录。删不掉就留着,不影响卸载。"""
|
||||
target = Path(data_dir())
|
||||
if not target.is_dir():
|
||||
return "没有数据目录"
|
||||
try:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
if not target.is_dir():
|
||||
return f"已删除数据目录 {target}"
|
||||
return f"数据目录没删干净(可能被占用):{target}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"删数据目录失败:{exc}"
|
||||
|
||||
|
||||
def _schedule_self_delete() -> str:
|
||||
"""把自己这个 exe 也删掉。
|
||||
|
||||
进程还在跑的时候删不掉自己,所以要起一个延迟的小命令,
|
||||
等我们退出后再删目录。
|
||||
"""
|
||||
if not getattr(sys, "frozen", False):
|
||||
return "(源码模式,不删自己)"
|
||||
folder = Path(sys.executable).resolve().parent
|
||||
cmd = (f'timeout /t 2 /nobreak >nul & '
|
||||
f'rmdir /s /q "{folder}"')
|
||||
try:
|
||||
subprocess.Popen(["cmd", "/c", cmd],
|
||||
creationflags=_NO_WINDOW | 0x00000008)
|
||||
return f"已安排删除程序目录:{folder}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"(没安排上自删,请手动删 {folder}:{exc})"
|
||||
|
||||
|
||||
def run(keep_data: bool = False) -> str:
|
||||
"""执行卸载,返回给人看的说明。"""
|
||||
lines = ["开始卸载 " + VALUE_NAME]
|
||||
lines.append(" " + _remove_autostart())
|
||||
if keep_data:
|
||||
lines.append(" 保留数据目录(你要求保留)")
|
||||
else:
|
||||
lines.append(" " + _remove_data())
|
||||
lines.append(" " + _schedule_self_delete())
|
||||
lines.append("")
|
||||
lines.append("卸载完成。程序目录会在几秒后自动删除。")
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user