Files
ykxmz/windows/lanremote/server.py
T
xmz7891 8880e7033d 修「经中转几次后永久哑掉」——「按一下没反应」的真因
== 现象 ==

用户报:
  * 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
  * 走中转「已配对」,但点按钮没反应

局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。

== 证据链(插桩复现出来的)==

被控端日志里直接抛了未捕获异常,线程当场死:

    Exception in thread client-relay:
      server.py:407  session.send(reply)
      relay.py:166   self._link.send_to_client(data)
      relay.py:357   self._post("/v1/agent/send", ...)
      relay.py:766   raise RelayError(f"请求失败: {last_error}")
    lanremote.relay.RelayError: 请求失败: Idle

之后的状态:
  * client-relay 线程没了 —— 电脑再也不往手表回任何东西
  * relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
  * **手表看到的就是「连上了,但按一下毫无反应」**

写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。

== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==

_post() 里锁只包住「取连接」那一段:

    with self._http_lock:
        connection = self._http      # 锁到这里就放了
    try:
        connection.request("POST", ...)   # 锁外发请求

于是两个线程会同时用同一条 connection:
  * relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
  * client-relay 线程同时 POST /v1/agent/send

HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。

_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。

修法:
  * **发命令用独立连接**(_post 加 dedicated 参数)。
    长轮询那条常年被占着,发命令不该去挤它,各走各的。
  * 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
    不再只锁取连接那一下。
  * 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
  * _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
    (否则那条连接会被永久标记为忙)。

== 根因 2:回包失败会打死整个会话线程 ==

server.py 的 _client_loop 里:

    if reply is not None:
        session.send(reply)      # <- 不在任何 try 里
    ...
    except OSError:              # <- 外层只捕获 OSError
        pass

而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。

修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。

== 修复效果 ==

    修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
    修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常

_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**

这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。

== 同批修掉验证阶段找到的其它问题 ==

1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
   register() 用 `device_id in self.devices` 判断老设备,但超时清理
   把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
   第二天开机就会被自己的开关挡住,而且**重启也救不回来**
   (清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
   管理员在页面上还看不到这台设备,不知道该放开谁。
   修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
   register 改查这份历史;清理后补 _save();delete_device 才真正忘记。

2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
   admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
   `\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
   整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
   元凶是我加改码功能那次提交。改成 `\\n`。

3. **--relay / --relay-status 崩溃**(严重)
   RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
   run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
   1 required positional argument」(用户以为地址填错了),
   一处直接报栈。两处都补上 url。

4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
   字母数字 + - _ . :)

5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
   返回值反映真实运行状态;不再用「对象建过没有」判断)

6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节

== 局域网全面优化(task-2,另一个智能体完成)==

4 个真问题:
  * **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
    被判成不同网络,明明能直连却去走中转还费流量。
    改成按网卡真实掩码比较。
  * **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
  * **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
  * **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
    新增定期回切(先便宜地 TCP ping,通了才动真格,
    不破坏正在工作的中转连接)。

实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
  现已修复。)

顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。

== 服务端一键部署脚本(task-1,另一个智能体完成)==

relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
  * 自动检查并安装 cloudflared
  * 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
  * **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
    不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
    不对外开放,绝不能把私人域名烧成默认值)
  * 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动

顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。

== 默认中转地址改成空白 ==

用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。

== 文档同步(task-5,另一个智能体完成)==

README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
  * 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
  * 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
  * 删掉「地址已经内置好了」(现在是错的)
  * AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
  * RELAY_README 原来写「两条隧道同时跑会互相争抢」,
    和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
2026-10-07 17:59:39 +08:00

1289 lines
51 KiB
Python
Raw 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_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"