新增功能:

- 节点菜单标注 IPv6 专用节点(nodes.json 的 ipv6_only 字段):本机有 IPv6 出口时标 [IPv6],没有时标 [IPv6 专用 · 本机不可用] 并置灰不可选(带原因 tooltip),避免选中后连不上
- 新增 _local_has_ipv6()(查本机全局 IPv6 地址)/_node_reachable()(ipv6_only 节点真实探测 IPv6 连通性,按域名缓存)/_node_ipv6_only();_reload_nodes() 时清空可达性缓存
- 开启代理时若当前节点 IPv6 专用且本机无 IPv6 出口,直接报错提示切换节点(不再静默失败断网)
- 节点菜单构建拆出 _build_node_menu() 便于离屏测试
This commit is contained in:
RoyirinRosmontis
2026-09-10 21:01:02 +08:00
parent 259a1ae41f
commit 9e766d8132
+88 -2
View File
@@ -146,10 +146,76 @@ def _save_current_node(domain):
data["current_node"] = str(domain or "")
_settings_save(data)
# ── IPv6-only 节点可达性(客户端标注用)─────────────────────
# nodes.json 里 ipv6_only: true 的节点域名只有 AAAA 记录(无 A 记录),
# 本机没有 IPv6 出口时连不上。判定结果按域名缓存,避免每次开菜单都探测。
_IPV6_OK = {} # domain -> 本机能否连上该节点的 IPv6 地址
_IPV6_PROBE_TIMEOUT = 4 # 单次 TCP 探测超时(秒)
def _local_has_ipv6():
"""本机是否有全局 IPv6 地址(排除回环/链路本地/唯一本地地址)。
没有则不可能连上 IPv6-only 节点,直接短路,省去探测。"""
try:
import socket
import ipaddress
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET6):
addr = info[4][0].split("%")[0] # 去掉 zone id(fe80::1%eth0)
try:
ip = ipaddress.ip_address(addr)
except ValueError:
continue
if ip.is_loopback or ip.is_link_local or ip.is_private:
continue
return True
except Exception:
return False
return False
def _node_reachable(node):
"""节点在本机是否可达:普通节点恒为 True;ipv6_only 节点需真的能建立 IPv6 连接。
结果按域名缓存(探测一次)。"""
if not isinstance(node, dict) or not node.get("ipv6_only"):
return True
domain = str(node.get("domain") or "").strip()
if not domain:
return False
if domain in _IPV6_OK:
return _IPV6_OK[domain]
ok = False
if _local_has_ipv6():
import socket
try:
infos = socket.getaddrinfo(domain, None, socket.AF_INET6, socket.SOCK_STREAM)
for info in infos:
try:
s = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
s.settimeout(_IPV6_PROBE_TIMEOUT)
s.connect(info[4][:2])
s.close()
ok = True
break
except Exception:
try:
s.close()
except Exception:
pass
except Exception:
ok = False
_IPV6_OK[domain] = ok
log(f"节点 {domain} 为 IPv6 专用:本机 IPv6 "
f"{'可用' if ok else '不可用'},{'可正常使用' if ok else '已标注为不可用'}")
return ok
def _node_ipv6_only(node):
"""节点是否标记为 IPv6 专用(可选字段,缺省视为 False)。"""
return bool(isinstance(node, dict) and node.get("ipv6_only"))
def _reload_nodes():
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
global _NODES
_NODES = None
_IPV6_OK.clear() # 节点列表变了,IPv6 可达性缓存作废
return _load_nodes()
def _node_val(key, default=""):
@@ -2289,6 +2355,9 @@ class MainWindow(QWidget):
if _endpoint_domain() == "???":
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
if not _node_reachable(_current_node()):
# 当前选中节点是 IPv6 专用而本机没有 IPv6 出口:转发的流量全部连不上
raise RuntimeError("当前节点仅提供 IPv6 地址,本机没有 IPv6 出口,请在节点菜单切换其他节点")
_traffic_reset()
log("TLS 直连模式")
ep = _endpoint_domain()
@@ -2386,6 +2455,11 @@ class MainWindow(QWidget):
"nodes.json 中没有可用节点。\n"
"可在右下角「设置」中退出登录后重新登录获取节点配置。")
return
menu = self._build_node_menu(nodes)
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
def _build_node_menu(self, nodes):
"""构建节点切换菜单:IPv6 专用节点加标注,本机无 IPv6 出口时置灰不可选。"""
menu = QMenu(self)
menu.setStyleSheet(f"""
QMenu {{
@@ -2412,11 +2486,23 @@ class MainWindow(QWidget):
text = f"{domain} ({country})"
if remark and remark != domain:
text += f" {remark}"
ipv6_only = _node_ipv6_only(n)
reachable = _node_reachable(n)
if ipv6_only:
text += " [IPv6]" if reachable else " [IPv6 专用 · 本机不可用]"
if i == cur:
text = "✓ " + text
menu.addAction(text).triggered.connect(
act = menu.addAction(text)
if not reachable:
# 本机无 IPv6 出口:置灰不可选,避免选中后连不上
act.setEnabled(False)
act.setToolTip("该节点仅提供 IPv6 地址,当前网络没有 IPv6 出口,无法连接")
else:
if ipv6_only:
act.setToolTip("该节点仅提供 IPv6 地址(本机 IPv6 可用)")
act.triggered.connect(
lambda _=False, idx=i: self._on_node_selected(idx))
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
return menu
def _on_node_selected(self, idx):
"""切换节点:更新标题。"""