Files
ykxmz/windows/lanremote/server.py
T
xmz7891 3f18e6bb40 2.1.0:网站自动抢救 + 后台抢救报告分区 + 网络热门音效 + 全量测试后台静默
按用户定的规矩,这一轮有新功能,升第二位:2.0.0 -> 2.1.0。

== 一、网站挂了会自己救 ==

用户原话:「网站还是容易挂,建议继续增加隧道稳定性,并在后台设立
检测单元,实时检测网站状态,一旦检测到网站挂了,立即启动紧急预案」。

新增 relay/monitor.sh + lanremote-monitor.timer:
    每 60 秒从**公网**查一次 /v1/health(不是查 127.0.0.1)

    为什么不查本机:本机 200 只说明中转进程活着,用户真正关心的是
    经过 Cloudflare 边缘 + 隧道 + 本机三段之后能不能打开。实测过
    隧道两条实例都掉了、本机 curl 照样 200,只有从公网测才发现 502。

    连续 3 次不通才动手(这条 4G 线路本来就有 2~4% 偶发失败),
    救完 10 分钟内不再动手(否则预案刚重启完隧道还没连上,
    下一次检查又救一遍,来回重启反而永远连不上)。

新增 relay/rescue.sh:五级阶梯,每级做完立刻复验,好了就停
    1 重启隧道          不动中转,在线设备不掉线
    2 重启中转服务      设备要重连,但很快
    3 重新施加网络调优  MSS / MTU 跑偏时用
    4 重启无线网卡      4G / WiFi 卡死时用
    5 重启整机          最后的底牌,**默认不开**(要 RESCUE_REBOOT=1)

    理由:如果问题出在供电或上游线路,重启只会让恢复更慢;
    而且半夜自动重启一台没人管的机器风险太大。

== 二、抢救报告 + 后台分区 ==

用户原话:「如果紧急预案未能解决,则将问题打包成 .txt,然后在
中转站设置里面加一个分区专门放还有下载这个 txt。解决后将解决问题
的方法加入紧急预案」。

    * 五级都没救回来 -> 把现场(内存/进程/隧道连接数/路由和 MTU、
      三份日志尾部、内核异常)整理成 rescue_reports/<时间戳>.txt
    * 后台新增「抢救报告」分区,列出报告(带结论摘要),点一下下载
    * 下载走单独的 /rescue/<名字>,**不发缓存头**(内容会变,
      也不该让 Cloudflare 边缘留着)。和 /download/ 分开是有意的:
      那个是给用户的安装包,文件名带版本号、内容永不改变,
      所以敢发 604800 长缓存
    * **要登录才能下** —— 报告里有内网 IP、进程表、日志尾部,
      是运维信息。第一版把这条路由插在 _admin_gate() 之前,
      等于匿名可下,自己查出来补上了
    * 每次自动抢救成功,把「第几级奏效」写进 rescue_knowledge.md;
      监测单元读它,下次同类症状优先试历史上管用的那一级
      (这就是用户说的"解决后将解决问题的方法加入紧急预案")

== 三、网络热门音效 ==

用户原话:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈,
各种老牧师音效,可以直接远程播放」。

**版权说明**:网上那些「冰冰冰」「啊哈哈哈」是**有版权的真实录音**
(影视片段 / 别人的作品),不能下载打包进安装包 —— 那既侵权,
也会让这个项目在 Gitee 那边直接踩线(这个仓库已经被判过一次
"内容可能含有违规信息")。

所以:
    * 内置 8 个**程序合成**的音效(正弦/方波现场算,零版权风险、
      不占安装包体积):冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 /
      警报 / 鼓点 / 弹簧。名字对着感觉来,但不是那些录音本身
    * 做「自己放进来」的目录:mp3/wav 丢进 data/sfx/ 就自动出现在
      列表里,照样能远程播。目录里放了一份说明
    * 手表首页新增「音效」按钮,点开选一个,电脑就响
      (复用现成的 showChooser,不新建面板)

协议加 sfx_list / sfx_play 两条命令。

== 四、修「安卓设置页一片空白」 ==

用户报的。根因:设置页**建好了但忘了 root.addView()** ——
showOnly() 让它 VISIBLE 也没用,它根本不在视图树里。

这类"漏挂"编译完全不报,只在运行时表现为空白。已加断言
_tests/test_watch_panels_wired.py 盯着:每个面板都必须挂到 root、
并且纳入 showOnly()。

== 五、全量测试改成后台静默 ==

用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。

会开窗口的 6 个阶段(含开**全屏**那个)现在跑在独立的 Windows
隐藏桌面上(_tests/hidden_desktop.py)。窗口是**真的** —— 全屏、
可见、几何、Win32 枚举都真实,测试有效性一点没损失 ——
只是不在用户眼前,全屏也不会锁屏。

顺带修了一个一直在骗人的检测:判断"锁屏"时只看 OpenInputDesktop
成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。
于是截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了
(用户电脑锁了一夜,我就是被这个误导过)。改成看输入桌面的**名字**。
现在锁屏也能跑全量:该 SKIP 的 SKIP,该跑的照跑。

== 六、顺手修的真 bug ==

* windows/lanremote/server.py 的 sfx_play 分支用了不存在的变量名 body
  (应该是 msg)-> **一调就 NameError**。pyflakes 那关抓出来的,
  说明"未定义名"这条检查有用。
* _build/package_remote.py 的文件白名单只有 .py/.sh/.ico,
  **.service/.timer 收不进服务端 zip** -> 新机器上监测根本不启动,
  而且不报错、只是静默不跑。已加。
* protocol.py 被我补丁插入了 LF 行到 CRLF 文件里 -> 行尾混用,编码审计红。
* deploy.sh 的 watchdog 块被我改锚点时吃掉了 start/say 两行
  (bash -n 通过、只是少两条执行语句)—— 改完读回来核对才发现。
* _build/CHANGELOG.md 的 2.1.0 说明里写了"管理后台",
  触发对外材料清洁度检查。改成不提管理页(用户定的规矩:
  对外只能提官网)。

全量回归 46 个阶段全部通过。
2026-10-08 18:36:50 +08:00

1307 lines
52 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""被控端 TCP 服务:配对、鉴权、命令分发、文件收发。"""
from __future__ import annotations
import os
import re
import secrets
import socket
import threading
import time
from pathlib import Path
from . import protocol as P
from .actions import ActionError
from .config import Store
from .discovery import DISCOVERY_PORT, DiscoveryResponder
from .features import Features
from .screen import FrameStreamer, ScreenCapturer, ScreenError
def _truthy(value) -> bool:
"""字符串 "false"/"0"/"off" 不能被当成真(bool("false") 是 True)。"""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "on", "yes", "是", "开")
return bool(value)
class Session:
"""一条客户端连接。"""
def __init__(self, conn: socket.socket, addr, server: "RemoteServer"):
self.conn = conn
self.addr = addr
self.server = server
self.device_id = ""
self.device_name = ""
self.authed = False
self.wants_screen = False # 是否正在观看屏幕镜像
self.connected_at = time.time()
self.last_seen = time.time()
self.buffers = {
P.CMD_IMAGE: P.ChunkAssembler(),
P.CMD_AUDIO: P.ChunkAssembler(),
P.CMD_FILE_PUSH: P.ChunkAssembler(),
}
self._send_lock = threading.Lock()
def send(self, obj: dict) -> bool:
try:
with self._send_lock:
self.conn.sendall(P.dumps(obj))
return True
except OSError:
return False
def send_chunks(self, event: str, name: str, blob: bytes) -> bool:
"""把二进制分块发给客户端。"""
for seq, last, piece in P.chunk_bytes(blob):
ok = self.send({
"event": event, "name": name,
"seq": seq, "last": last, "data": P.b64encode(piece),
})
if not ok:
return False
return True
def close(self) -> None:
try:
self.conn.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self.conn.close()
except OSError:
pass
# 内置的中转地址:**默认留空**,由用户自己在设置里填。
#
# 为什么不再内置一个:
# 原来写死了一台随身 WiFi 的地址 + 一个公网隧道域名。问题是
# 那台设备的 IP 会变(DHCP),写死的地址很快就过期,用户开箱
# 就一直在连一个不存在的主机,表现成"一直转圈/连不上"。
# 与其猜一个迟早会错的地址,不如留空 —— 局域网本身完全不受影响。
#
# 空字符串的含义:**只走局域网**。
# * UDP 广播发现、TCP 直连、配对、命令下发,全都照常工作
# * 不会去连任何中转,不报错、不卡住(RelayLink.start("") 会直接跳过)
# * 用户想要跨网络时,在「中转设置」里填自己的地址即可
# * 多个地址可用分号隔开,按顺序尝试,例如:
# http://192.168.1.10:8080;https://你自己的域名
DEFAULT_RELAY_URL = ""
class RemoteServer:
"""局域网被控端服务。"""
def __init__(self, store: Store, actions, ui=None):
self.store = store
self.actions = actions
self.features = Features(actions) if actions else None
self.ui = ui
self.sessions: list[Session] = []
# 配对失败记录:{来源 IP: [时间戳]},用于限速防枚举
self._pair_failures: dict = {}
self._lock = threading.RLock()
# 跨网络中转链路(配了 relay_url 才会启用)
self._relay = None
self._sock: socket.socket | None = None
self._accept_thread: threading.Thread | None = None
# 累计收到过多少次 TCP 连接 —— 自检用它判断"手表的包有没有到"
self._total_connections = 0
self._clip_thread: threading.Thread | None = None
self._running = False
self._last_clip = ""
self.pairing_enabled = True
# 屏幕镜像
self._capturer: ScreenCapturer | None = None
# 建采集器时用的 (分辨率, 画质)。变了就重建,不然改设置不生效。
self._capturer_signature = None
self._streamer: FrameStreamer | None = None
self._screen_indicator_on = False
# 局域网自动发现(手表不用输 IP)
self._discovery: DiscoveryResponder | None = None
# ---------------- 生命周期 ----------------
@property
def port(self) -> int:
return int(self.store.get("port", P.DEFAULT_PORT))
def start(self) -> None:
if self._running:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Windows 上 SO_REUSEADDR 允许第二个进程抢占同一端口,
# 会让"端口被占用"检测失效。改用独占绑定。
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
except OSError:
pass
else:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", self.port))
sock.listen(16)
sock.settimeout(1.0)
self._sock = sock
self._running = True
# ── 先起局域网发现 ──
#
# 顺序很重要:enable_relay() 会挨个试中转地址,
# 每个候选都要等到超时才算"不通",地址越烂等得越久(实测 10 秒+)。
# 原来这一步排在发现服务前面,导致:
# 电脑刚开机的十几秒里,手表广播搜不到它。
# 局域网发现是纯本机能力,不该被"连不连得上外网"拖住,
# 所以挪到前面,开机即可被搜到。
self._discovery = DiscoveryResponder(self.store, self.port)
# 手表点「连不上?」会广播请求,这里就把网络自检结论算出来
self._discovery.on_selfcheck = self._on_selfcheck_request
if self._discovery.start():
self._log(f"自动发现已开启(UDP {DISCOVERY_PORT})")
else:
self._log(f"自动发现未开启:{self._discovery.last_error}")
# ── 再起收发线程 ──
self._accept_thread = threading.Thread(target=self._accept_loop,
name="accept", daemon=True)
self._accept_thread.start()
self._clip_thread = threading.Thread(target=self._clip_loop,
name="clip", daemon=True)
self._clip_thread.start()
# ── 最后才连中转(会阻塞,所以放最后)──
# 配了中转地址就同时启用跨网络通道(和局域网监听并存,不冲突)
try:
self.enable_relay()
except Exception as exc: # noqa: BLE001
self._log(f"启用中转失败:{exc}")
self._log(f"服务已启动,监听 0.0.0.0:{self.port}")
def _selfcheck_report(self, from_ip: str) -> str:
"""生成一段给手表看的诊断结论(不弹任何窗口)。
用户要的是「电脑上什么都不出现」,所以结论直接回传给手表显示。
"""
try:
from .diagnostics import collect, verdict
data = collect(self, self.store)
_level, text = verdict(data)
ips = data.get("ips") or []
port = self.port
blocked = (data.get("firewall") or {}).get("blocked_count", 0)
lines = [
f"电脑:{data.get('name') or '被控端'}",
f"本机地址:{', '.join(ips[:2]) if ips else '读不到'}",
f"监听端口:{port}",
f"收到过的连接:{data.get('tcp_connections', 0)} 次",
f"阻止规则:{blocked} 条",
"",
f"结论:{text}",
]
return "\n".join(lines)
except Exception as exc: # noqa: BLE001
return f"电脑端诊断失败:{exc}"
def _on_selfcheck_request(self, from_ip: str) -> str:
"""手表说「我连不上」→ 生成诊断结论,随 ACK 回给手表。
走 UDP 是有意的:连不上的典型原因就是 TCP 被防火墙拦了,
而 UDP 广播往往还通(那正是"能搜到但连不上"的原因)。
注意:**不往电脑上弹窗口**。结论直接回传给手表,
用户看的就是手表,电脑上保持干净。
"""
note = self._selfcheck_report(from_ip)
first = note.splitlines()[0] if note else ""
self._log(f"[自检] 收到来自 {from_ip} 的自检请求,已生成诊断结论({first})")
return note
def _open_selfcheck_window(self, from_ip: str) -> None:
"""有窗口的模式下才用得上(静默模式不会走到这里)。"""
self._log(f"[自检] 收到来自 {from_ip} 的请求,已生成诊断结论")
def launch() -> None:
target = getattr(self, "ui", None)
if target is not None and hasattr(target, "open_diagnostics"):
try:
target.ui_call(lambda: target.open_diagnostics(from_ip), timeout=3.0)
return
except Exception as exc: # noqa: BLE001
self._log(f"[自检] 打开界面失败:{exc}")
return
self._log("[自检] 没有可用的界面,跳过")
# 弹窗口这件事要放到主线程做,避免和界面的消息循环打架
target = getattr(self, "ui", None)
if target is not None and hasattr(target, "ui_call"):
try:
target.ui_call(launch, timeout=3.0)
return
except Exception:
pass
try:
import threading
threading.Thread(target=launch, name="selfcheck", daemon=True).start()
except Exception:
pass
def stop(self) -> None:
self._running = False
self._stop_relay()
self._stop_streamer()
if self._discovery is not None:
self._discovery.on_selfcheck = None
self._discovery.stop()
self._discovery = None
with self._lock:
sessions = list(self.sessions)
self.sessions.clear()
for session in sessions:
session.close()
if self._sock:
try:
self._sock.close()
except OSError:
pass
self._sock = None
self._log("服务已停止")
def restart(self, new_port: int | None = None) -> None:
self.stop()
if new_port:
self.store.set("port", int(new_port))
time.sleep(0.3)
self.start()
@property
def running(self) -> bool:
return self._running
# ---------------- 中转(跨网络)----------------
def adopt_relay_socket(self, relay_socket) -> None:
"""把一条中转连接当成普通 TCP 连接接纳进来。
中转那边说"有手表接入了",这里就造一个 Session 跑同样的
_client_loop —— 上面的协议、配对、鉴权一行都不用改。
"""
address = ("中转服务器", 0)
session = Session(relay_socket, address, self)
with self._lock:
self.sessions.append(session)
self._log("设备接入(经中转,跨网络)")
self._notify_ui()
threading.Thread(target=self._client_loop, args=(session,),
name="client-relay", daemon=True).start()
def enable_relay(self, url: str = "") -> bool:
"""启用中转。地址优先用参数,其次读设置。"""
from .relay import RelayLink
target = (url or "").strip() or str(self.store.get("relay_url") or "")
if not target.strip():
# 没配过就用内置默认值 —— 现在是空字符串,表示"只走局域网"。
# 这时候**什么都不做**是正确的:局域网监听已经起来了,
# 不报错、不重试、不占用任何后台线程。
target = DEFAULT_RELAY_URL
if not target.strip():
self._log("没配中转地址,只走局域网"
"(需要跨网络时在设置里填中转地址)")
# ⚠ 这里以前是 `if self._relay is not None: return True` ——
# 只要对象建过就报成功。而"没配中转地址"那条路也会把对象建起来
# (start("") 返回 False),于是第二次调用会谎报中转在跑。
# 改成看**真实运行状态**。
if self._relay is not None:
return bool(getattr(self._relay, "running", False))
self._relay = RelayLink(self.store, log=self._log,
on_session=self.adopt_relay_socket,
on_alert=self._alert)
started = self._relay.start(target)
if started:
self._log(f"已启用中转:{self._relay.url} 配对码 {self.store.pairing_code()}")
return started
def _alert(self, title: str, text: str) -> None:
"""给用户看的通知(配对码被占这类必须让人知道的事)。
走界面的通知,用户在电脑上能直接看到。
界面还没起来时退回写日志,至少不丢信息。
"""
self._log(f"[需要处理] {title}:{text}")
try:
ui = getattr(self.actions, "ui", None)
if ui is not None and hasattr(ui, "notify"):
ui.notify(title, text)
except Exception: # noqa: BLE001
pass
def relay_status(self) -> dict:
if self._relay is None:
return {"enabled": False}
return self._relay.status()
def _stop_relay(self) -> None:
if self._relay is None:
return
try:
self._relay.stop()
except Exception: # noqa: BLE001
pass
self._relay = None
# ---------------- 接受连接 ----------------
def _accept_loop(self) -> None:
while self._running:
try:
conn, addr = self._sock.accept()
self._total_connections += 1
except socket.timeout:
continue
except OSError:
break
conn.settimeout(None)
try:
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except OSError:
pass
session = Session(conn, addr, self)
with self._lock:
self.sessions.append(session)
self._log(f"设备接入 {addr[0]}:{addr[1]}")
self._notify_ui()
threading.Thread(target=self._client_loop, args=(session,),
name=f"client-{addr[0]}", daemon=True).start()
def _client_loop(self, session: Session) -> None:
try:
for line in self._read_lines(session.conn):
session.last_seen = time.time()
try:
msg = P.loads(line)
except ValueError:
session.send({"ok": False, "error": "消息格式错误"})
continue
if not isinstance(msg, dict):
continue
try:
reply = self._dispatch(session, msg)
except ActionError as exc:
reply = {"ok": False, "error": str(exc)}
except Exception as exc: # noqa: BLE001
reply = {"ok": False, "error": f"内部错误: {exc}"}
self._log(f"[错误] {exc}")
if reply is not None:
# ⚠ 这里必须兜住**所有**异常。
# 以前只在外层捕获 OSError,而 session.send() 在
# 中转链路上会抛 RelayError(实测:"请求失败: Idle")——
# 那个异常一路冒出去,把 client-relay 线程整个打死。
# 线程一死,电脑就再也不往手表回任何东西,
# 而 relay-link 还在长轮询、中转看着"设备在线",
# 用户看到的就是"连上了,按一下毫无反应"。
# 发失败最多踢掉这个会话让手表重连,绝不能丢线程。
try:
session.send(reply)
except Exception as exc: # noqa: BLE001
self._log(f"[错误] 回包失败,断开该会话:{exc}")
break
except OSError:
pass
except Exception as exc: # noqa: BLE001
self._log(f"[错误] 会话循环异常退出:{exc}")
finally:
with self._lock:
if session in self.sessions:
self.sessions.remove(session)
session.close()
session.wants_screen = False
# 没有观看者了就立刻停掉抓屏,不留后台耗电/耗资源
if not any(s.wants_screen for s in self._authed_sessions()):
self._stop_streamer()
self._log(f"设备断开 {session.addr[0]}"
f"{' / ' + session.device_name if session.device_name else ''}")
self._notify_ui()
@staticmethod
def _read_lines(conn: socket.socket):
buf = b""
while True:
try:
data = conn.recv(65536)
except OSError:
return
if not data:
return
buf += data
if len(buf) > P.MAX_LINE_BYTES and b"\n" not in buf:
return
while b"\n" in buf:
line, buf = buf.split(b"\n", 1)
if line.strip():
yield line
# ---------------- 命令分发 ----------------
def _dispatch(self, session: Session, msg: dict) -> dict | None:
cmd = str(msg.get("cmd") or "")
# --- 握手 ---
if cmd == P.CMD_PAIR:
return self._do_pair(session, msg)
if cmd == P.CMD_AUTH:
return self._do_auth(session, msg)
if not session.authed:
return {"ok": False, "error": "未鉴权:请先配对或使用已保存的设备令牌"}
if cmd == P.CMD_PING:
return {"ok": True, "pong": True, "time": time.time()}
if cmd == P.CMD_IMAGE:
return self._recv_media(session, msg, P.CMD_IMAGE, "图片")
if cmd == P.CMD_AUDIO:
return self._recv_media(session, msg, P.CMD_AUDIO, "音频")
if cmd == P.CMD_FILE_PUSH:
return self._recv_media(session, msg, P.CMD_FILE_PUSH, "文件")
if cmd == P.CMD_MEDIA:
note = self.actions.media_control(str(msg.get("action") or ""))
self._log(f"[媒体] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_CLIP_SET:
note = self.actions.clip_set(str(msg.get("text") or ""))
self._last_clip = str(msg.get("text") or "")
self._log(f"[剪贴板] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_CLIP_GET:
text = self.actions.clip_get()
return {"ok": True, "text": text}
if cmd == P.CMD_POWER:
note = self.actions.power(str(msg.get("action") or ""))
self._log(f"[电源] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_RUN:
result = self.actions.run_command(str(msg.get("command") or ""))
return {"ok": True, **result}
if cmd == P.CMD_SAY:
try:
rate = int(msg.get("rate") or 0)
except (TypeError, ValueError):
rate = 0
note = self.actions.say(str(msg.get("text") or ""),
str(msg.get("voice") or ""), rate)
self._log(f"[播报] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_VOICE_LIST:
voices = self.actions.list_voices()
return {"ok": True, "voices": voices,
"current": str(self.store.get("tts_voice") or "")}
if cmd == P.CMD_SFX_LIST:
items = self.actions.list_sfx()
# 同时给一个扁平的 names —— 手表端的 stringList(reply, key)
# 只认字符串数组,和 audio_list 的约定保持一致。
return {"ok": True, "sfx": items, "count": len(items),
"names": [it.get("name", "") for it in items],
"dir": self.actions.sfx_dir_hint()}
if cmd == P.CMD_SFX_PLAY:
name = str(msg.get("name") or "").strip()
if not name:
return {"ok": False, "error": "没说要放哪个音效"}
try:
msg = self.actions.play_sfx(name)
except Exception as exc:
return {"ok": False, "error": str(exc)}
return {"ok": True, "message": msg, "name": name}
if cmd == P.CMD_AUDIO_LIST:
files = self.actions.list_local_audio()
# 顺手给出扁平的 names/paths,手表端不用再解析嵌套结构
return {"ok": True,
"files": files,
"names": [f.get("name", "") for f in files],
"paths": [f.get("path", "") for f in files],
"roots": [str(p) for p in self.actions.audio_roots()]}
if cmd == P.CMD_SHUTDOWN_TIMER:
cancel = _truthy(msg.get("cancel"))
try:
minutes = int(msg.get("minutes") or 0)
except (TypeError, ValueError):
minutes = 0
note = self.actions.shutdown_timer(minutes, cancel)
self._log(f"[电源] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_MONITOR:
note = self.actions.monitor_power(_truthy(msg.get("on")))
self._log(f"[显示] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_OPEN_URL:
note = self.actions.open_url(str(msg.get("url") or ""))
self._log(f"[网址] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_QUICK:
note = self.actions.quick_launch(str(msg.get("app") or ""))
self._log(f"[快捷] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_OPEN_FOLDER:
note = self.actions.open_folder(str(msg.get("folder") or ""))
self._log(f"[文件夹] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_PROC_LIST:
procs = self.actions.list_processes()
return {"ok": True,
"names": [f"{p['name']} ({p['mem_kb'] // 1024} MB)" for p in procs],
"pids": [p["pid"] for p in procs]}
if cmd == P.CMD_PROC_KILL:
try:
pid = int(msg.get("pid") or 0)
except (TypeError, ValueError):
pid = 0
note = self.actions.kill_process(pid, str(msg.get("name") or ""))
self._log(f"[进程] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_BATTERY:
info = self.actions.battery()
if not info.get("present"):
return {"ok": True, "note": info.get("note") or "没有电池(台式机)"}
detail = f"{info['percent']}%"
if info.get("charging"):
detail += "(充电中)"
return {"ok": True, "note": detail, **info}
if cmd == P.CMD_DISK:
drives = self.actions.disk()
lines = [f"{d['drive']} {d['free_gb']}/{d['total_gb']} GB 可用"
f"(已用 {d['used_percent']}%)" for d in drives]
return {"ok": True, "note": " | ".join(lines) or "读不到磁盘",
"drives": drives}
if cmd == P.CMD_PERF:
info = self.actions.perf()
note = (f"CPU {info.get('cpu_percent')}% "
f"内存 {info.get('mem_percent')}%"
f"({info.get('mem_used_gb')}/{info.get('mem_total_gb')} GB)")
return {"ok": True, "note": note, **info}
if cmd == P.CMD_CLIP_HISTORY:
items = self.actions.clip_history()
return {"ok": True, "note": f"共 {len(items)} 条",
"texts": [i.get("text", "") for i in items],
"times": [i.get("at", "") for i in items]}
if cmd == P.CMD_SET_WALLPAPER:
note = self.actions.set_wallpaper(str(msg.get("path") or ""))
self._log(f"[壁纸] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_SAY_CLIP:
note = self.actions.say_clipboard()
self._log(f"[朗读] 剪贴板内容")
return {"ok": True, "note": note}
if cmd == P.CMD_AUDIO_PAUSE:
note = self.actions.audio_pause()
self._log(f"[音频] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_AUDIO_RESUME:
note = self.actions.audio_resume()
self._log(f"[音频] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_AUDIO_STATUS:
return {"ok": True, "note": self.actions.audio_status()}
if cmd == P.CMD_FEATURE_LIST:
if self.features is None:
raise ActionError("功能不可用")
return {"ok": True, "features": self.features.defs(),
"extra_apps": self.features.extra_apps(),
"extra_commands": self.features.extra_commands(),
"roots": [str(p) for p in self.features.allowed_roots()]}
if cmd == P.CMD_FEATURE_RUN:
if self.features is None:
raise ActionError("功能不可用")
name = str(msg.get("name") or "")
params = msg.get("params") or {}
if not isinstance(params, dict):
params = {}
try:
note = self.features.run(name, params)
except ValueError as exc:
raise ActionError(str(exc)) from exc
self._log(f"[功能] {name} -> {note}")
return {"ok": True, "note": str(note)[:4000]}
if cmd == P.CMD_SET_VOICE:
voice = str(msg.get("voice") or "").strip()
self.store.set("tts_voice", voice)
self._log(f"[音色] 已设为 {voice or '系统默认'}")
return {"ok": True, "note": voice or "系统默认"}
if cmd == P.CMD_PLAY_LOCAL:
note = self.actions.play_local(str(msg.get("path") or ""))
self._log(f"[本机播放] {note}")
return {"ok": True, "note": note}
if cmd == P.CMD_NOTIFY:
note = self.actions.notify(str(msg.get("title") or ""),
str(msg.get("text") or ""))
return {"ok": True, "note": note}
if cmd == P.CMD_FILE_LIST:
return self._file_list()
if cmd == P.CMD_FILE_PULL:
return self._file_pull(session, str(msg.get("path") or ""))
if cmd == P.CMD_SCREEN_START:
return self._screen_start(session, msg)
if cmd == P.CMD_SCREEN_STOP:
return self._screen_stop(session)
if cmd == P.CMD_SCREENSHOT:
return self._screenshot(session, msg)
if cmd == P.CMD_INFO:
return {"ok": True, "info": self.actions.info()}
return {"ok": False, "error": f"未知命令: {cmd}"}
# ---------------- 握手实现 ----------------
def _do_pair(self, session: Session, msg: dict) -> dict:
code = str(msg.get("code") or "").strip()
expected = self.store.pairing_code()
if not expected:
return {"ok": False, "error": "被控端尚未生成配对码"}
# 配对窗口关着、但对方报的码是对的 —— 照样放行。
#
# 为什么:配对码本身就是密钥。老逻辑是"首台设备配对成功后
# 自动关闭配对窗口"(close_pair_after_first),结果手表重装、
# 换新表、或清掉 App 数据之后令牌没了,重新配对会被拒,
# 用户看到"被控端已关闭新设备配对"就彻底连不上了 ——
# 而这时候他能做的只有跑去管理页手动开窗口,完全不合理。
#
# 码不对时才用"已关闭配对"这个说法挡回去,
# 防暴力枚举的按 IP 限速(下面那段)仍然生效。
if not self.pairing_enabled and code != expected:
return {"ok": False,
"error": "被控端已关闭新设备配对" +
"(配对码不对;要在管理页临时开启的话,"
"打开中转站管理页找「允许新设备配对」)"}
# 防暴力枚举:6 位数字只有 100 万种,不限速的话局域网里慢慢试就行。
# 按**来源 IP**分别计数 —— 全局计数的话,别人发 10 个错码
# 就能把你这个合法用户也锁在门外,那本身就是个拒绝服务的口子。
now = time.time()
ip = session.addr[0]
buckets = getattr(self, "_pair_failures", None)
if not isinstance(buckets, dict):
buckets = {}
mine = [t for t in buckets.get(ip, []) if now - t < 300]
buckets = {k: [t for t in v if now - t < 300]
for k, v in buckets.items() if any(now - t < 300 for t in v)}
buckets[ip] = mine
self._pair_failures = buckets
if len(mine) >= 10:
wait = max(1, int(300 - (now - mine[0])))
self._log(f"[配对] {ip} 失败次数过多,锁定中({wait} 秒)")
return {"ok": False,
"error": f"配对码错误次数过多,请等 {wait} 秒后再试"}
# 用 compare_digest,避免按字符比较带来的时间侧信道
if not secrets.compare_digest(code, expected):
mine.append(now)
self._log(f"[配对失败] 来自 {ip},配对码不匹配({len(mine)}/10)")
return {"ok": False, "error": "配对码错误"}
self._pair_failures[ip] = []
device_id = str(msg.get("deviceId") or "").strip() or f"dev-{session.addr[0]}"
device_name = str(msg.get("deviceName") or "手表").strip()
token = self.store.remember_device(device_id, device_name)
session.device_id = device_id
session.device_name = device_name
session.authed = True
# 首次配对成功后自动把配对窗口关上 —— 用户设完码只需配一次,
# 之后不用再操心"要不要关配对"。
if self.store.get("close_pair_after_first"):
self.pairing_enabled = False
self.store.set("allow_new_pairing", False)
self.store.set("close_pair_after_first", False)
self._log("[配对] 首台设备已配对,配对窗口已自动关闭")
self._log(f"[配对成功] {device_name} ({session.addr[0]})")
self._notify_ui()
return {
"ok": True,
"token": token,
"serverName": self.store.get("server_name", ""),
"protocol": P.PROTOCOL_VERSION,
}
def _do_auth(self, session: Session, msg: dict) -> dict:
device_id = str(msg.get("deviceId") or "").strip()
token = str(msg.get("token") or "").strip()
if not device_id or not token:
return {"ok": False, "error": "缺少设备标识或令牌"}
if not self.store.verify_token(device_id, token):
self._log(f"[鉴权失败] {device_id} 令牌无效")
return {"ok": False, "error": "令牌无效,请重新配对", "needPair": True}
session.device_id = device_id
session.device_name = str(msg.get("deviceName") or "").strip() or \
self.store.devices.get(device_id, {}).get("name", "设备")
session.authed = True
self._log(f"[已连接] {session.device_name} ({session.addr[0]})")
self._notify_ui()
return {
"ok": True,
"serverName": self.store.get("server_name", ""),
"protocol": P.PROTOCOL_VERSION,
}
# ---------------- 媒体/文件接收 ----------------
def _recv_media(self, session: Session, msg: dict, kind: str, label: str) -> dict:
seq = int(msg.get("seq") or 0)
last = _truthy(msg.get("last"))
data = P.b64decode(str(msg.get("data") or ""))
name = os.path.basename(str(msg.get("name") or "")) or f"{kind}_{int(time.time())}"
assembler = session.buffers[kind]
try:
blob = assembler.feed(seq, last, data)
except P.ChunkTooLarge as exc:
# 顺序不对或超限:丢掉这个 kind 的缓冲,回一条明确的错误
session.buffers.pop(kind, None)
raise ActionError(str(exc)) from exc
if blob is None:
# 中间分块不回包:避免回复洪泛与后续命令的消息错位
return None
target = self.store.received_dir() / name
target = _unique_path(target)
try:
target.write_bytes(blob)
except OSError as exc:
raise ActionError(f"保存失败: {exc}") from exc
size = len(blob)
import hashlib
digest = hashlib.sha256(blob).hexdigest()[:16]
self._log(f"[{label}] 收到 {name} ({_human(size)}) sha256={digest}")
if kind == P.CMD_IMAGE:
note = self.actions.show_image(str(target),
str(msg.get("title") or ""))
elif kind == P.CMD_AUDIO:
note = self.actions.play_audio(str(target))
else:
note = f"已保存到 {target}"
return {"ok": True, "note": note, "path": str(target), "size": size}
# 一次最多返回多少个文件。
# 为什么不返回全部:手表屏幕就那么大,几千条也翻不动,
# 而且一条 JSON 太长会把单条消息撑爆。
FILE_LIST_LIMIT = 100
def _file_list(self) -> dict:
directory = self.store.received_dir()
try:
entries = [p for p in directory.iterdir() if p.is_file()]
except OSError as exc:
raise ActionError(f"读取目录失败: {exc}") from exc
total = len(entries)
entries.sort(key=lambda p: p.stat().st_mtime, reverse=True)
shown = entries[:self.FILE_LIST_LIMIT]
files = []
for entry in shown:
try:
files.append({
"name": entry.name,
"size": entry.stat().st_size,
"mtime": entry.stat().st_mtime,
})
except OSError:
# 正好被别的进程删掉了,跳过就好,别让整条列表失败
continue
return {
"ok": True,
"dir": str(directory),
"files": files,
# 说清楚有没有被截断 —— 不能默默藏起来,
# 用户会以为文件丢了(这个坑在测试里踩过一次)。
"total": total,
"shown": len(files),
"truncated": total > len(files),
}
def _file_pull(self, session: Session, path: str) -> dict:
target = self._resolve_received(path)
if target is None:
raise ActionError(f"文件不存在: {path}")
blob = target.read_bytes()
if not session.send_chunks(P.EVT_FILE, target.name, blob):
raise ActionError("发送文件失败:连接已断开")
return {"ok": True, "name": target.name, "size": len(blob)}
def _is_sensitive(self, path: Path) -> bool:
"""程序自己的数据目录:配对码、设备令牌都在这儿。
注意:接收目录必须豁免 —— 它在程序目录里面,
不豁免的话拉文件会全被挡住("文件不存在")。
"""
try:
received = Path(self.store.received_dir()).resolve()
path.resolve().relative_to(received)
return False
except (ValueError, OSError):
pass
if self.features is not None:
try:
return self.features.is_sensitive(path)
except Exception: # noqa: BLE001
pass
try:
folder = Path(self.store.dir).resolve()
resolved = path.resolve()
return resolved == folder or folder in resolved.parents
except OSError:
return False
def _resolve_received(self, path: str) -> Path | None:
"""按名字在接收目录里定位文件。
同名文件会被 _unique_path 存成 name_1.bin / name_2.bin,
所以这里必须取**最新的一份**,否则「同名文件发两次」时
客户端拉回的会是上一轮的旧文件。
"""
raw = Path(path)
if raw.is_absolute() and raw.is_file():
# 只允许拉取「接收目录」或「用户自己的目录」里的文件。
# 否则这条命令等于"能下载电脑上任意文件",是个大洞。
try:
resolved = raw.resolve()
except OSError:
return None
allowed = [self.store.received_dir()]
if self.features is not None:
allowed.extend(self.features.allowed_roots())
if self._is_sensitive(resolved):
self._log(f"[文件] 拒绝拉取数据目录: {raw}")
return None
for root in allowed:
try:
resolved.relative_to(root.resolve())
return resolved
except (ValueError, OSError):
continue
self._log(f"[文件] 拒绝拉取允许范围外的路径: {raw}")
return None
# 相对路径也要过一遍检查。Path("data/settings.json").is_absolute() 是 False,
# 上面那一段挡不住它 —— 而 EXE 的 CWD 就是程序目录,
# 于是已配对设备能把 data/settings.json(配对码 + 全部设备令牌)拉走。
#
# 注意:这里**不能**因为"不在允许目录内"就 return None ——
# 后面的逻辑还要按文件名在接收目录里找。只有命中敏感目录时才直接拒绝。
try:
relative = (Path.cwd() / raw).resolve()
except OSError:
relative = None
if relative is not None and relative.is_file():
# 只有真的命中一个**存在的文件**时才做敏感判断。
# 否则 (cwd/文件名) 会拼出一个程序目录内的假路径,
# 被误判成敏感目录 -> 直接返回 None -> 走不到下面的按名字查找,
# 表现就是"接收文件永远拿不到东西"。
if self._is_sensitive(relative):
self._log(f"[文件] 拒绝拉取数据目录: {raw}")
return None
allowed_roots = [self.store.received_dir()]
if self.features is not None:
allowed_roots.extend(self.features.allowed_roots())
for root in allowed_roots:
try:
relative.relative_to(root.resolve())
return relative
except (ValueError, OSError):
continue
self._log(f"[文件] 拒绝拉取允许范围外的路径: {relative}")
return None
# 落到下面:按文件名在「接收目录」里找最新的那一份
directory = self.store.received_dir()
basename = os.path.basename(path)
if not basename:
return None
stem, suffix = os.path.splitext(basename)
pattern = re.compile(
rf"^{re.escape(stem)}(?:_\d+)?{re.escape(suffix)}$", re.IGNORECASE)
matches: list[Path] = []
try:
for entry in directory.iterdir():
if entry.is_file() and pattern.match(entry.name):
matches.append(entry)
except OSError:
return None
if matches:
return max(matches, key=lambda p: p.stat().st_mtime)
return None
# ---------------- 屏幕镜像 / 截屏 ----------------
def _ensure_capturer(self) -> ScreenCapturer | None:
"""拿到屏幕采集器。**设置变了要重建。**
以前只判断 self._capturer is None,于是改了分辨率/画质之后
正在推流的会话永远用旧设置 —— 用户会以为"改了没用"。
现在记住建它时用的参数,参数一变就重建。
"""
width = int(self.store.get("screen_max_width", 480) or 480)
quality = int(self.store.get("screen_quality", 55) or 55)
signature = (width, quality)
if self._capturer is not None and \
getattr(self, "_capturer_signature", None) == signature:
return self._capturer
self._capturer = ScreenCapturer(max_width=width, quality=quality)
self._capturer_signature = signature
return self._capturer
def _require_screen(self) -> ScreenCapturer:
if not self.store.get("allow_screen", True):
raise ActionError("被控端已关闭屏幕共享(可在被控端窗口重新开启)")
capturer = self._ensure_capturer()
if capturer is None or not capturer.available():
raise ActionError("当前系统不支持屏幕捕获")
return capturer
def _screen_start(self, session: Session, msg: dict) -> dict:
capturer = self._require_screen()
fps = _clamp(int(msg.get("fps") or self.store.get("screen_fps", 3) or 3), 1, 15)
quality = _clamp(int(msg.get("quality") or self.store.get("screen_quality", 55) or 55),
20, 90)
max_width = _clamp(int(msg.get("maxWidth") or self.store.get("screen_max_width", 480) or 480),
160, 1920)
capturer.quality = quality
capturer.max_width = max_width
session.wants_screen = True
self._ensure_streamer().start(fps)
self._set_indicator(True)
who = session.device_name or session.addr[0]
self._log(f"[屏幕] {who} 开始观看屏幕镜像({fps} fps, {max_width}px, 质量 {quality})")
self._notify_ui()
return {"ok": True, "fps": fps, "maxWidth": max_width, "quality": quality,
"note": f"屏幕共享已开始({fps} fps)"}
def _screen_stop(self, session: Session) -> dict:
session.wants_screen = False
if not any(s.wants_screen for s in self._authed_sessions()):
self._stop_streamer()
self._log(f"[屏幕] {session.device_name or session.addr[0]} 停止观看")
self._notify_ui()
return {"ok": True, "note": "屏幕共享已停止"}
def _screenshot(self, session: Session, msg: dict) -> dict:
capturer = self._require_screen()
quality = _clamp(int(msg.get("quality") or self.store.get("screen_quality", 55) or 55),
30, 95)
max_width = _clamp(int(msg.get("maxWidth") or 1280), 160, 3840)
capturer.quality = quality
capturer.max_width = max_width
# 截屏也是一次屏幕读取,同样给出可见提示(短暂闪烁)
self._flash_indicator()
try:
jpeg, width, height = capturer.grab_jpeg()
except ScreenError as exc:
raise ActionError(str(exc)) from exc
name = f"screenshot_{time.strftime('%Y%m%d_%H%M%S')}.jpg"
who = session.device_name or session.addr[0]
self._log(f"[屏幕] {who} 截取屏幕 {width}x{height}({_human(len(jpeg))})")
return {"ok": True, "name": name, "w": width, "h": height,
"size": len(jpeg), "data": P.b64encode(jpeg), "note": "截屏已获取"}
# ---- 帧流 ----
def _ensure_streamer(self) -> FrameStreamer:
if self._streamer is None:
self._streamer = FrameStreamer(
self._ensure_capturer(),
self._on_frame,
on_stop=self._on_stream_stopped)
return self._streamer
def _on_frame(self, jpeg: bytes, width: int, height: int) -> bool:
"""把一帧推给所有正在观看的设备。返回 False 表示应停止推流。"""
viewers = [s for s in self._authed_sessions() if s.wants_screen]
if not viewers:
return False
payload = {
"event": P.EVT_FRAME,
"w": width, "h": height,
"size": len(jpeg),
"ts": time.time(),
"data": P.b64encode(jpeg),
}
delivered = 0
for viewer in viewers:
if viewer.send(payload):
delivered += 1
else:
viewer.wants_screen = False
return delivered > 0
def _on_stream_stopped(self) -> None:
self._set_indicator(False)
self._notify_ui()
def _stop_streamer(self) -> None:
if self._streamer is not None and self._streamer.running:
self._log("[屏幕] 已停止屏幕镜像")
if self._streamer is not None:
self._streamer.stop()
self._set_indicator(False)
def _set_indicator(self, active: bool) -> None:
"""开启/关闭被控端上的「屏幕正在被共享」可见提示。
这个提示是**强制**的:只要在推流就一定显示,界面里没有关闭开关。
远控工具必须让使用者知道屏幕正在被共享。
"""
if self._screen_indicator_on == active:
return
self._screen_indicator_on = active
if self.ui and hasattr(self.ui, "set_screen_sharing"):
try:
self.ui.set_screen_sharing(active)
except Exception:
pass
def _flash_indicator(self) -> None:
"""截屏时短暂显示提示。"""
if self.ui and hasattr(self.ui, "flash_screen_indicator"):
try:
self.ui.flash_screen_indicator(2.5)
except Exception:
pass
# ---------------- 剪贴板同步 ----------------
def _clip_loop(self) -> None:
time.sleep(2.0)
while self._running:
time.sleep(1.5)
if not self._authed_sessions():
continue
try:
text = self.actions.clip_get()
except Exception:
continue
if text and text != self._last_clip:
self._last_clip = text
self.broadcast({"event": P.EVT_CLIP, "text": text})
def _authed_sessions(self) -> list[Session]:
with self._lock:
return [s for s in self.sessions if s.authed]
# ---------------- 广播 / UI 通知 ----------------
def broadcast(self, obj: dict) -> None:
for session in self._authed_sessions():
session.send(obj)
def stop_screen_sharing(self) -> int:
"""本机使用者主动停止屏幕共享(点击红色提示条上的按钮)。
这是被控端的最终否决权:任何时候都能一键掐断。
"""
with self._lock:
sessions = list(self.sessions)
affected = 0
for session in sessions:
if session.wants_screen:
session.wants_screen = False
affected += 1
self._stop_streamer()
self._set_indicator(False)
self.broadcast({"event": "screen_stopped", "reason": "被控端本机已停止共享"})
self._log(f"[屏幕] 本机使用者停止了屏幕共享(影响 {affected} 台设备)")
self._notify_ui()
return affected
def disconnect_all(self) -> int:
with self._lock:
sessions = list(self.sessions)
for session in sessions:
session.close()
self._log(f"已断开 {len(sessions)} 个连接")
self._notify_ui()
return len(sessions)
def status(self) -> dict:
"""给界面用的实时状态。"""
with self._lock:
sessions = list(self.sessions)
devices = []
for s in sessions:
devices.append({
"addr": s.addr[0],
"name": s.device_name or "未鉴权",
"authed": s.authed,
"since": s.connected_at,
"last_seen": s.last_seen,
})
return {
"running": self._running,
"port": self.port,
"pairing_code": self.store.pairing_code(),
"pairing_enabled": self.pairing_enabled,
"connected": devices,
"paired_saved": self.store.device_list(),
"screen_sharing": bool(self._streamer and self._streamer.running),
"screen_viewers": sum(1 for s in sessions if s.wants_screen),
"screen_fps": round(self._streamer.actual_fps, 1)
if self._streamer and self._streamer.running else 0.0,
"discovery": self._discovery.status() if self._discovery else {"running": False},
"total_connections": self._total_connections,
"local_ip": local_ip(),
}
def _notify_ui(self) -> None:
if self.ui and hasattr(self.ui, "on_state_changed"):
try:
self.ui.on_state_changed()
except Exception:
pass
def _log(self, text: str) -> None:
if self.ui and hasattr(self.ui, "log"):
try:
self.ui.log(text)
except Exception:
pass
def _clamp(value: int, low: int, high: int) -> int:
return max(low, min(high, int(value)))
def _unique_path(path: Path) -> Path:
if not path.exists():
return path
stem, suffix = path.stem, path.suffix
for index in range(1, 1000):
candidate = path.with_name(f"{stem}_{index}{suffix}")
if not candidate.exists():
return candidate
return path.with_name(f"{stem}_{int(time.time())}{suffix}")
def _human(num: float) -> str:
for unit in ("B", "KB", "MB", "GB"):
if abs(num) < 1024.0:
return f"{num:.1f}{unit}"
num /= 1024.0
return f"{num:.1f}TB"
def lan_ips() -> list[str]:
"""列出所有可用的局域网 IPv4 地址,优先真实网卡。
这台机器上装了 Tailscale,`gethostbyname(hostname)` 会返回 Tailscale 的
169.254 链路地址——那是没用的。所以这里枚举网卡并做优先级排序。
"""
candidates: list[tuple[int, str]] = []
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
addr = info[4][0]
if addr and not addr.startswith("127."):
candidates.append((_ip_rank(addr), addr))
except OSError:
pass
# 主路由地址(UDP 连 8.8.8.8 不会真的发包,只用来问内核选哪张网卡)
try:
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
probe.connect(("8.8.8.8", 80))
addr = probe.getsockname()[0]
probe.close()
if addr and not addr.startswith("127."):
candidates.append((0, addr)) # 最优
except OSError:
pass
ranked: dict[str, int] = {}
for rank, addr in candidates:
if addr not in ranked or rank < ranked[addr]:
ranked[addr] = rank
return [addr for addr, _ in sorted(ranked.items(), key=lambda kv: kv[1])]
def _ip_rank(addr: str) -> int:
"""数字越小越可能是真实局域网地址。"""
if addr.startswith("169.254."):
return 90 # APIPA / Tailscale 链路地址
if addr.startswith("192.168."):
return 10
if addr.startswith("10."):
return 20
if addr.startswith("172."):
try:
second = int(addr.split(".")[1])
if 16 <= second <= 31:
return 25
except (ValueError, IndexError):
pass
return 60
def local_ip() -> str:
"""本机在局域网中最可能的地址。"""
ips = lan_ips()
return ips[0] if ips else "127.0.0.1"