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:
xmz7891
2026-10-06 19:01:05 +08:00
commit b62ac865ea
3195 changed files with 203292 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
"""局域网遥控 · Windows 被控端。"""
__version__ = "1.5.0"
APP_NAME = "局域网遥控 被控端"
File diff suppressed because it is too large Load Diff
+108
View File
@@ -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(),
}
+221
View File
@@ -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"
+205
View File
@@ -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)
+251
View File
@@ -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
+442
View File
@@ -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"
)
+512
View File
@@ -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)
+110
View File
@@ -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
+367
View File
@@ -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))
+179
View File
@@ -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
+740
View File
@@ -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"
+243
View File
@@ -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
+407
View File
@@ -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
+98
View File
@@ -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)