platform: modularize api/gui, add docs-tests-web foundation, and refresh root config

This commit is contained in:
beckline
2026-03-26 22:40:54 +03:00
parent 0e2d7f61ea
commit 6a56d734c2
562 changed files with 70151 additions and 16423 deletions

View File

@@ -0,0 +1,26 @@
from .constants import (
LOCATION_TARGET_ROLE,
LoginPage,
SINGBOX_EDITOR_PROTOCOL_IDS,
SINGBOX_EDITOR_PROTOCOL_OPTIONS,
SINGBOX_PROTOCOL_SEED_SPEC,
SINGBOX_STATUS_ROLE,
)
from .runtime_actions_mixin import MainWindowRuntimeActionsMixin
from .singbox_mixin import SingBoxMainWindowMixin
from .ui_shell_mixin import MainWindowUIShellMixin
from .workers import EventThread, LocationsThread
__all__ = [
"EventThread",
"LOCATION_TARGET_ROLE",
"MainWindowRuntimeActionsMixin",
"MainWindowUIShellMixin",
"LocationsThread",
"LoginPage",
"SingBoxMainWindowMixin",
"SINGBOX_EDITOR_PROTOCOL_IDS",
"SINGBOX_EDITOR_PROTOCOL_OPTIONS",
"SINGBOX_PROTOCOL_SEED_SPEC",
"SINGBOX_STATUS_ROLE",
]

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
import re
from typing import Any
from typing import Literal
from PySide6.QtCore import Qt
_NEXT_CHECK_RE = re.compile(r"(?i)next check in \d+s")
LoginPage = Literal["main", "login"]
LOCATION_TARGET_ROLE = Qt.UserRole + 1
SINGBOX_STATUS_ROLE = Qt.UserRole + 2
SINGBOX_EDITOR_PROTOCOL_OPTIONS = [
("VLESS", "vless"),
("Trojan", "trojan"),
("Shadowsocks", "shadowsocks"),
("Hysteria2", "hysteria2"),
("TUIC", "tuic"),
("WireGuard", "wireguard"),
]
SINGBOX_EDITOR_PROTOCOL_IDS = tuple([pid for _label, pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS])
SINGBOX_PROTOCOL_SEED_SPEC: dict[str, dict[str, Any]] = {
"vless": {
"port": 443,
"security": "none",
"proxy_defaults": {
"uuid": "",
},
},
"trojan": {
"port": 443,
"security": "tls",
"proxy_defaults": {
"password": "",
},
},
"shadowsocks": {
"port": 443,
"security": "none",
"proxy_defaults": {
"method": "aes-128-gcm",
"password": "",
},
},
"hysteria2": {
"port": 443,
"security": "tls",
"proxy_defaults": {
"password": "",
},
"tls_security": "tls",
},
"tuic": {
"port": 443,
"security": "tls",
"proxy_defaults": {
"uuid": "",
"password": "",
},
"tls_security": "tls",
},
"wireguard": {
"port": 51820,
"security": "none",
"proxy_defaults": {
"private_key": "",
"peer_public_key": "",
"local_address": [],
},
},
}
__all__ = [
"LOCATION_TARGET_ROLE",
"LoginPage",
"SINGBOX_EDITOR_PROTOCOL_IDS",
"SINGBOX_EDITOR_PROTOCOL_OPTIONS",
"SINGBOX_PROTOCOL_SEED_SPEC",
"SINGBOX_STATUS_ROLE",
"_NEXT_CHECK_RE",
]

View File

@@ -0,0 +1,18 @@
from __future__ import annotations
from main_window.runtime_auth_mixin import RuntimeAuthMixin
from main_window.runtime_ops_mixin import RuntimeOpsMixin
from main_window.runtime_refresh_mixin import RuntimeRefreshMixin
from main_window.runtime_state_mixin import RuntimeStateMixin
class MainWindowRuntimeActionsMixin(
RuntimeOpsMixin,
RuntimeAuthMixin,
RuntimeRefreshMixin,
RuntimeStateMixin,
):
"""Facade mixin for backward-compatible MainWindow inheritance."""
__all__ = ["MainWindowRuntimeActionsMixin"]

View File

@@ -0,0 +1,209 @@
from __future__ import annotations
import subprocess
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox
class RuntimeAuthMixin:
def on_auth_button(self) -> None:
def work():
view = self.ctrl.get_login_view()
if view.logged_in:
self.on_logout()
else:
# при логине всегда переходим на вкладку AdGuardVPN и
# показываем страницу логина
self.tabs.setCurrentWidget(self.tab_vpn)
self._show_vpn_page("login")
self.on_start_login()
self._safe(work, title="Auth error")
def on_login_banner_clicked(self) -> None:
def work():
txt = self.ctrl.login_banner_cli_text()
QMessageBox.information(self, "AdGuard VPN", txt)
self._safe(work, title="Login banner error")
# ---------------- LOGIN FLOW ACTIONS ----------------
def on_start_login(self) -> None:
def work():
self.ctrl.log_gui("Top Login clicked")
self._show_vpn_page("login")
self._login_flow_reset_ui()
start = self.ctrl.login_flow_start()
self._login_cursor = int(start.cursor)
self.lbl_login_flow_status.setText(
f"Status: {start.status_text or ''}"
)
self.lbl_login_flow_email.setText(
f"User: {start.email}" if start.email else ""
)
self.edit_login_url.setText(start.url or "")
self._login_flow_set_buttons(
can_open=start.can_open,
can_check=start.can_check,
can_cancel=start.can_cancel,
)
if start.lines:
cleaned = self._clean_ui_lines(start.lines)
if cleaned:
self._append_text(self.txt_login_flow, cleaned + "\n")
if not start.alive:
self._login_flow_autopoll_stop()
self._login_flow_set_buttons(
can_open=False, can_check=False, can_cancel=False
)
self.btn_login_stop.setEnabled(False)
QTimer.singleShot(250, self.refresh_login_banner)
return
self._login_flow_autopoll_start()
self._safe(work, title="Login start error")
def _login_flow_reset_ui(self) -> None:
self._login_cursor = 0
self._login_url_opened = False
self.edit_login_url.setText("")
self.lbl_login_flow_status.setText("Status: —")
self.lbl_login_flow_email.setText("")
self._set_text(self.txt_login_flow, "")
def _login_flow_set_buttons(
self,
*,
can_open: bool,
can_check: bool,
can_cancel: bool,
) -> None:
self.btn_login_open.setEnabled(bool(can_open))
self.btn_login_copy.setEnabled(bool(self.edit_login_url.text().strip()))
self.btn_login_check.setEnabled(bool(can_check))
self.btn_login_close.setEnabled(bool(can_cancel))
self.btn_login_stop.setEnabled(True)
def _login_flow_autopoll_start(self) -> None:
self._login_flow_active = True
if not self.login_poll_timer.isActive():
self.login_poll_timer.start()
def _login_flow_autopoll_stop(self) -> None:
self._login_flow_active = False
if self.login_poll_timer.isActive():
self.login_poll_timer.stop()
def _login_poll_tick(self) -> None:
if not self._login_flow_active:
return
def work():
view = self.ctrl.login_flow_poll(self._login_cursor)
self._login_cursor = int(view.cursor)
self.lbl_login_flow_status.setText(
f"Status: {view.status_text or ''}"
)
self.lbl_login_flow_email.setText(
f"User: {view.email}" if view.email else ""
)
if view.url:
self.edit_login_url.setText(view.url)
self._login_flow_set_buttons(
can_open=view.can_open,
can_check=view.can_check,
can_cancel=view.can_cancel,
)
cleaned = self._clean_ui_lines(view.lines)
if cleaned:
self._append_text(self.txt_login_flow, cleaned + "\n")
if (not self._login_url_opened) and view.url:
self._login_url_opened = True
try:
subprocess.Popen(["xdg-open", view.url])
except Exception:
pass
phase = (view.phase or "").strip().lower()
if (not view.alive) or phase in (
"success",
"failed",
"cancelled",
"already_logged",
):
self._login_flow_autopoll_stop()
self._login_flow_set_buttons(
can_open=False, can_check=False, can_cancel=False
)
self.btn_login_stop.setEnabled(False)
QTimer.singleShot(250, self.refresh_login_banner)
self._safe(work, title="Login flow error")
def on_login_copy(self) -> None:
def work():
u = self.edit_login_url.text().strip()
if u:
QApplication.clipboard().setText(u)
self.ctrl.log_gui("Login flow: copy-url")
self._safe(work, title="Login copy error")
def on_login_open(self) -> None:
def work():
u = self.edit_login_url.text().strip()
if u:
try:
subprocess.Popen(["xdg-open", u])
except Exception:
pass
self.ctrl.log_gui("Login flow: open")
self._safe(work, title="Login open error")
def on_login_check(self) -> None:
def work():
# если ещё ничего не запущено — считаем это стартом логина
if (
not self._login_flow_active
and self._login_cursor == 0
and not self.edit_login_url.text().strip()
and not self.txt_login_flow.toPlainText().strip()
):
self.on_start_login()
return
self.ctrl.login_flow_action("check")
self.ctrl.log_gui("Login flow: check")
self._safe(work, title="Login check error")
def on_login_cancel(self) -> None:
def work():
self.ctrl.login_flow_action("cancel")
self.ctrl.log_gui("Login flow: cancel")
self._safe(work, title="Login cancel error")
def on_login_stop(self) -> None:
def work():
self.ctrl.login_flow_stop()
self.ctrl.log_gui("Login flow: stop")
self._login_flow_autopoll_stop()
QTimer.singleShot(250, self.refresh_login_banner)
self._safe(work, title="Login stop error")
def on_logout(self) -> None:
def work():
self.ctrl.log_gui("Top Logout clicked")
res = self.ctrl.vpn_logout()
self._set_text(self.txt_vpn, res.pretty_text or str(res))
QTimer.singleShot(250, self.refresh_login_banner)
self._safe(work, title="Logout error")

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
from typing import Literal
from PySide6.QtWidgets import QApplication, QMessageBox
from dns_benchmark_dialog import DNSBenchmarkDialog
from main_window.constants import LOCATION_TARGET_ROLE
from traffic_mode_dialog import TrafficModeDialog
class RuntimeOpsMixin:
def on_toggle_autoconnect(self) -> None:
def work():
current = self.ctrl.vpn_autoconnect_enabled()
enable = not current
self.ctrl.vpn_set_autoconnect(enable)
self.ctrl.log_gui(f"VPN autoconnect set to {enable}")
self.refresh_vpn_tab()
self._safe(work, title="Autoconnect error")
def on_location_activated(self, _index: int) -> None:
self._safe(self._apply_selected_location, title="Location error")
def on_set_location(self) -> None:
self._safe(self._apply_selected_location, title="Location error")
def _apply_selected_location(self) -> None:
idx = self.cmb_locations.currentIndex()
if idx < 0:
return
iso = str(self.cmb_locations.currentData() or "").strip().upper()
target = str(self.cmb_locations.currentData(LOCATION_TARGET_ROLE) or "").strip()
label = str(self.cmb_locations.currentText() or "").strip()
if not target:
target = iso
if not iso or not target:
return
desired = (self._vpn_desired_location or "").strip().lower()
if desired and desired in (iso.lower(), target.lower()):
return
self.lbl_locations_meta.setText(f"Applying location {target}...")
self.lbl_locations_meta.setStyleSheet("color: orange;")
self._start_vpn_location_switching(target)
self.refresh_login_banner()
QApplication.processEvents()
try:
self.ctrl.vpn_set_location(target=target, iso=iso, label=label)
except Exception:
self._stop_vpn_location_switching()
self.refresh_login_banner()
raise
self.ctrl.log_gui(f"VPN location set to {target} ({iso})")
self._vpn_desired_location = target
self.refresh_vpn_tab()
self._trigger_vpn_egress_refresh(reason=f"location switch to {target}")
# ---- Routes actions ------------------------------------------------
def on_routes_action(
self, action: Literal["start", "stop", "restart"]
) -> None:
def work():
res = self.ctrl.routes_service_action(action)
self._set_text(self.txt_routes, res.pretty_text or str(res))
self.refresh_status_tab()
self._safe(work, title="Routes error")
def _append_routes_log(self, msg: str) -> None:
line = (msg or "").strip()
if not line:
return
self._append_text(self.txt_routes, line + "\n")
self.ctrl.log_gui(line)
def on_open_traffic_settings(self) -> None:
def work():
def refresh_all_traffic() -> None:
self.refresh_routes_tab()
self.refresh_status_tab()
dlg = TrafficModeDialog(
self.ctrl,
log_cb=self._append_routes_log,
refresh_cb=refresh_all_traffic,
parent=self,
)
dlg.exec()
refresh_all_traffic()
self._safe(work, title="Traffic mode dialog error")
def on_test_traffic_mode(self) -> None:
def work():
view = self.ctrl.traffic_mode_test()
msg = (
f"Traffic mode test: desired={view.desired_mode}, applied={view.applied_mode}, "
f"iface={view.active_iface or '-'}, probe_ok={view.probe_ok}, "
f"healthy={view.healthy}, auto_local_bypass={view.auto_local_bypass}, "
f"bypass_routes={view.bypass_candidates}, overrides={view.overrides_applied}, "
f"cgroup_uids={view.cgroup_resolved_uids}, cgroup_warning={view.cgroup_warning or '-'}, "
f"message={view.message}, probe={view.probe_message}"
)
self._append_routes_log(msg)
self.refresh_routes_tab()
self.refresh_status_tab()
self._safe(work, title="Traffic mode test error")
def on_routes_precheck_debug(self) -> None:
def work():
res = self.ctrl.routes_precheck_debug(run_now=True)
txt = (res.pretty_text or "").strip()
if res.ok:
QMessageBox.information(self, "Resolve precheck debug", txt or "OK")
else:
QMessageBox.critical(self, "Resolve precheck debug", txt or "ERROR")
self.refresh_routes_tab()
self.refresh_status_tab()
self.refresh_trace_tab()
self._safe(work, title="Resolve precheck debug error")
def on_toggle_timer(self) -> None:
def work():
enabled = self.chk_timer.isChecked()
res = self.ctrl.routes_timer_set(enabled)
self.ctrl.log_gui(f"Routes timer set to {enabled}")
self._set_text(self.txt_routes, res.pretty_text or str(res))
self.refresh_routes_tab()
self._safe(work, title="Timer error")
def on_fix_policy_route(self) -> None:
def work():
res = self.ctrl.routes_fix_policy_route()
self._set_text(self.txt_routes, res.pretty_text or str(res))
self.refresh_status_tab()
self._safe(work, title="Policy route error")
# ---- DNS actions ---------------------------------------------------
def _schedule_dns_autosave(self, _text: str = "") -> None:
if self._dns_ui_refresh:
return
self.dns_save_timer.start()
def _apply_dns_autosave(self) -> None:
def work():
if self._dns_ui_refresh:
return
self.ctrl.dns_mode_set(
self.chk_dns_via_smartdns.isChecked(),
self.ent_smartdns_addr.text().strip(),
)
self.ctrl.log_gui("DNS settings autosaved")
self._safe(work, title="DNS save error")
def on_open_dns_benchmark(self) -> None:
def work():
dlg = DNSBenchmarkDialog(
self.ctrl,
settings=self._ui_settings,
refresh_cb=self.refresh_dns_tab,
parent=self,
)
dlg.exec()
self.refresh_dns_tab()
self._safe(work, title="DNS benchmark error")
def on_dns_mode_toggle(self) -> None:
def work():
via = self.chk_dns_via_smartdns.isChecked()
self.ctrl.dns_mode_set(via, self.ent_smartdns_addr.text().strip())
mode = "hybrid_wildcard" if via else "direct"
self.ctrl.log_gui(f"DNS mode changed: mode={mode}")
self.refresh_dns_tab()
self._safe(work, title="DNS mode error")
def on_smartdns_unit_toggle(self) -> None:
def work():
enable = self.chk_dns_unit_relay.isChecked()
action = "start" if enable else "stop"
self.ctrl.smartdns_service_action(action)
self.ctrl.log_smartdns(f"SmartDNS unit action from GUI: {action}")
self.refresh_dns_tab()
self.refresh_status_tab()
self._safe(work, title="SmartDNS error")
def on_smartdns_runtime_toggle(self) -> None:
def work():
if self._dns_ui_refresh:
return
enable = self.chk_dns_runtime_nftset.isChecked()
st = self.ctrl.smartdns_runtime_set(enabled=enable, restart=True)
self.ctrl.log_smartdns(
f"SmartDNS runtime accelerator set from GUI: enabled={enable} changed={st.changed} restarted={st.restarted} source={st.wildcard_source}"
)
self.refresh_dns_tab()
self.refresh_trace_tab()
self._safe(work, title="SmartDNS runtime error")
def on_smartdns_prewarm(self) -> None:
def work():
aggressive = bool(self.chk_routes_prewarm_aggressive.isChecked())
result = self.ctrl.smartdns_prewarm(aggressive_subs=aggressive)
mode_txt = "aggressive_subs=on" if aggressive else "aggressive_subs=off"
self.ctrl.log_smartdns(f"SmartDNS prewarm requested from GUI: {mode_txt}")
txt = (result.pretty_text or "").strip()
if result.ok:
QMessageBox.information(self, "SmartDNS prewarm", txt or "OK")
else:
QMessageBox.critical(self, "SmartDNS prewarm", txt or "ERROR")
self.refresh_trace_tab()
self._safe(work, title="SmartDNS prewarm error")
def _update_prewarm_mode_label(self, _state: int = 0) -> None:
aggressive = bool(self.chk_routes_prewarm_aggressive.isChecked())
if aggressive:
self.lbl_routes_prewarm_mode.setText("Prewarm mode: aggressive (subs enabled)")
self.lbl_routes_prewarm_mode.setStyleSheet("color: orange;")
else:
self.lbl_routes_prewarm_mode.setText("Prewarm mode: wildcard-only")
self.lbl_routes_prewarm_mode.setStyleSheet("color: gray;")
def _on_prewarm_aggressive_changed(self, _state: int = 0) -> None:
self._update_prewarm_mode_label(_state)
self._save_ui_preferences()
# ---- Domains actions -----------------------------------------------
def on_domains_load(self) -> None:
def work():
name = self._get_selected_domains_file()
content, source, path = self._load_file_content(name)
is_readonly = name in ("last-ips-map-direct", "last-ips-map-wildcard", "wildcard-observed-hosts")
self.txt_domains.setReadOnly(is_readonly)
self.btn_domains_save.setEnabled(not is_readonly)
self._set_text(self.txt_domains, content)
ro = "read-only" if is_readonly else "editable"
self.lbl_domains_info.setText(f"{name} ({source}, {ro}) [{path}]")
self._safe(work, title="Domains load error")
def on_domains_save(self) -> None:
def work():
name = self._get_selected_domains_file()
content = self.txt_domains.toPlainText()
self._save_file_content(name, content)
self.ctrl.log_gui(f"Domains file saved: {name}")
self._safe(work, title="Domains save error")
# ---- close event ---------------------------------------------------
def closeEvent(self, event) -> None: # pragma: no cover - GUI
try:
self._save_ui_preferences()
self._login_flow_autopoll_stop()
self.loc_typeahead_timer.stop()
if self.locations_thread:
self.locations_thread.quit()
self.locations_thread.wait(1500)
if self.events_thread:
self.events_thread.stop()
self.events_thread.wait(1500)
finally:
super().closeEvent(event)

View File

@@ -0,0 +1,427 @@
from __future__ import annotations
import time
from PySide6 import QtCore
from dashboard_controller import TraceMode
from main_window.workers import EventThread, LocationsThread
class RuntimeRefreshMixin:
def _start_events_stream(self) -> None:
if self.events_thread:
return
self.events_thread = EventThread(self.ctrl, self)
self.events_thread.eventReceived.connect(self._handle_event)
self.events_thread.error.connect(self._handle_event_error)
self.events_thread.start()
@QtCore.Slot(object)
def _handle_event(self, ev) -> None:
try:
kinds = self.ctrl.classify_event(ev)
except Exception:
kinds = []
# Отдельно ловим routes_nft_progress, чтобы обновить лейбл прогресса.
try:
k = (getattr(ev, "kind", "") or "").strip().lower()
except Exception:
k = ""
if k == "routes_nft_progress":
try:
prog_view = self.ctrl.routes_nft_progress_from_event(ev)
self._update_routes_progress_label(prog_view)
except Exception:
# не роняем UI, просто игнор
pass
# Простая стратегия: триггерить существующие refresh-функции.
if "status" in kinds:
self.refresh_status_tab()
if "login" in kinds:
self.refresh_login_banner()
if "vpn" in kinds:
self.refresh_vpn_tab()
if "routes" in kinds:
self.refresh_routes_tab()
if "dns" in kinds:
self.refresh_dns_tab()
if "transport" in kinds:
self.refresh_singbox_tab()
self._refresh_selected_transport_health_live(silent=True)
if "trace" in kinds:
self.refresh_trace_tab()
@QtCore.Slot(str)
def _handle_event_error(self, msg: str) -> None:
# Логируем в trace, UI не блокируем.
try:
self.ctrl.log_gui(f"[sse-error] {msg}")
except Exception:
pass
# ---------------- REFRESH ----------------
def refresh_everything(self) -> None:
self.refresh_login_banner()
self.refresh_status_tab()
self.refresh_vpn_tab()
self.refresh_singbox_tab()
self.refresh_routes_tab()
self.refresh_dns_tab()
self.refresh_domains_tab()
self.refresh_trace_tab()
def refresh_login_banner(self) -> None:
def work():
view = self.ctrl.get_login_view()
self._set_auth_button(view.logged_in)
if self._vpn_switching_active:
if self._is_vpn_switching_expired():
self._stop_vpn_location_switching()
else:
target = (self._vpn_switching_target or "").strip()
msg = "AdGuard VPN: Switching location..."
if target:
msg = f"AdGuard VPN: Switching location to {target}..."
self.btn_login_banner.setText(msg)
self.btn_login_banner.setStyleSheet(
"text-align: left; border: none; color: #d4a200;"
)
return
self.btn_login_banner.setText(view.text)
# Принудительно: зелёный если залогинен, серый если нет
color = "green" if view.logged_in else "gray"
base_style = "text-align: left; border: none;"
self.btn_login_banner.setStyleSheet(
f"{base_style} color: {color};"
)
self._safe(work, title="Login state error")
def refresh_status_tab(self) -> None:
def work():
view = self.ctrl.get_status_overview()
self.st_timestamp.setText(view.timestamp)
self.st_counts.setText(view.counts)
self.st_iface.setText(view.iface_table_mark)
self._set_status_label_color(
self.st_route, view.policy_route, kind="policy"
)
self._set_status_label_color(
self.st_routes_service, view.routes_service, kind="service"
)
self._set_status_label_color(
self.st_smartdns_service, view.smartdns_service, kind="service"
)
self._set_status_label_color(
self.st_vpn_service, view.vpn_service, kind="service"
)
self._safe(work, title="Status error")
def refresh_vpn_tab(self) -> None:
def work():
view = self.ctrl.vpn_status_view()
prev_desired = (self._vpn_desired_location_last_seen or "").strip().lower()
self._vpn_desired_location = (view.desired_location or "").strip()
current_desired = (self._vpn_desired_location or "").strip().lower()
self._vpn_desired_location_last_seen = self._vpn_desired_location
txt = []
if view.desired_location:
txt.append(f"Desired location: {view.desired_location}")
if view.pretty_text:
txt.append(view.pretty_text.rstrip())
self._set_text(self.txt_vpn, "\n".join(txt).strip() + "\n")
auto_view = self.ctrl.vpn_autoconnect_view()
self.btn_autoconnect_toggle.setText(
"Disable autoconnect" if auto_view.enabled else "Enable autoconnect"
)
self.lbl_autoconnect_state.setText(auto_view.unit_text)
self.lbl_autoconnect_state.setStyleSheet(
f"color: {auto_view.color};"
)
vpn_egress = self._refresh_egress_identity_scope(
"adguardvpn",
trigger_refresh=True,
silent=True,
)
self._render_vpn_egress_label(vpn_egress)
self._maybe_trigger_vpn_egress_refresh_on_autoloop(auto_view.unit_text)
if prev_desired and current_desired and prev_desired != current_desired:
self._trigger_vpn_egress_refresh(
reason=f"desired location changed: {prev_desired} -> {current_desired}"
)
if self._vpn_switching_active:
unit_low = (auto_view.unit_text or "").strip().lower()
elapsed = self._vpn_switching_elapsed_sec()
if any(
x in unit_low
for x in ("disconnected", "reconnecting", "unknown", "error", "inactive", "failed", "dead")
):
self._vpn_switching_seen_non_connected = True
desired_now = (self._vpn_desired_location or "").strip().lower()
target_now = (self._vpn_switching_target or "").strip().lower()
desired_matches = bool(target_now and desired_now and target_now == desired_now)
if self._is_vpn_switching_expired():
self._stop_vpn_location_switching()
elif (
"connected" in unit_low
and "disconnected" not in unit_low
and elapsed >= float(self._vpn_switching_min_visible_sec)
and (self._vpn_switching_seen_non_connected or desired_matches)
):
switched_to = (self._vpn_switching_target or "").strip()
self._stop_vpn_location_switching()
if switched_to:
self._trigger_vpn_egress_refresh(
reason=f"location switch completed: {switched_to}"
)
self.refresh_login_banner()
self._refresh_locations_async()
self._safe(work, title="VPN error")
def refresh_singbox_tab(self) -> None:
def work():
self.refresh_transport_engines(silent=True)
self.refresh_transport_policy_locks(silent=True)
self._apply_singbox_profile_controls()
self._safe(work, title="SingBox error")
def _start_vpn_location_switching(self, target: str) -> None:
self._vpn_switching_active = True
self._vpn_switching_target = str(target or "").strip()
self._vpn_switching_started_at = time.monotonic()
self._vpn_switching_seen_non_connected = False
def _stop_vpn_location_switching(self) -> None:
self._vpn_switching_active = False
self._vpn_switching_target = ""
self._vpn_switching_started_at = 0.0
self._vpn_switching_seen_non_connected = False
def _is_vpn_switching_expired(self) -> bool:
if not self._vpn_switching_active:
return False
started = float(self._vpn_switching_started_at or 0.0)
if started <= 0:
return False
return (time.monotonic() - started) >= float(self._vpn_switching_timeout_sec)
def _vpn_switching_elapsed_sec(self) -> float:
if not self._vpn_switching_active:
return 0.0
started = float(self._vpn_switching_started_at or 0.0)
if started <= 0:
return 0.0
return max(0.0, time.monotonic() - started)
def _refresh_locations_async(self, force_refresh: bool = False) -> None:
if self.locations_thread and self.locations_thread.isRunning():
self._locations_refresh_pending = True
if force_refresh:
self._locations_force_refresh_pending = True
return
run_force_refresh = bool(force_refresh or self._locations_force_refresh_pending)
self._locations_refresh_pending = False
self._locations_force_refresh_pending = False
self.locations_thread = LocationsThread(
self.ctrl,
force_refresh=run_force_refresh,
parent=self,
)
self.locations_thread.loaded.connect(self._on_locations_loaded)
self.locations_thread.error.connect(self._on_locations_error)
self.locations_thread.finished.connect(self._on_locations_finished)
self.locations_thread.start()
@QtCore.Slot(object)
def _on_locations_loaded(self, state) -> None:
try:
self._apply_locations_state(state)
except Exception as e:
self._on_locations_error(str(e))
@QtCore.Slot(str)
def _on_locations_error(self, msg: str) -> None:
msg = (msg or "").strip()
if not msg:
msg = "failed to load locations"
self.lbl_locations_meta.setText(f"Locations: {msg}")
self.lbl_locations_meta.setStyleSheet("color: red;")
try:
self.ctrl.log_gui(f"[vpn-locations] {msg}")
except Exception:
pass
@QtCore.Slot()
def _on_locations_finished(self) -> None:
self.locations_thread = None
if self._locations_refresh_pending:
force_refresh = self._locations_force_refresh_pending
self._locations_refresh_pending = False
self._locations_force_refresh_pending = False
self._refresh_locations_async(force_refresh=force_refresh)
def _apply_locations_state(self, state) -> None:
all_items: list[tuple[str, str, str, str, int]] = []
for loc in getattr(state, "locations", []) or []:
iso = str(getattr(loc, "iso", "") or "").strip().upper()
label = str(getattr(loc, "label", "") or "").strip()
target = str(getattr(loc, "target", "") or "").strip()
if not iso or not label:
continue
if not target:
target = iso
name, ping = self._location_name_ping(label, iso, target)
all_items.append((label, iso, target, name, ping))
self._all_locations = all_items
self._apply_location_search_filter()
self._render_locations_meta(state)
def _render_locations_meta(self, state) -> None:
parts = []
color = "gray"
updated_at = str(getattr(state, "updated_at", "") or "").strip()
stale = bool(getattr(state, "stale", False))
refreshing = bool(getattr(state, "refresh_in_progress", False))
last_error = str(getattr(state, "last_error", "") or "").strip()
next_retry = str(getattr(state, "next_retry_at", "") or "").strip()
if refreshing:
parts.append("refreshing")
color = "orange"
if updated_at:
parts.append(f"updated: {updated_at}")
else:
parts.append("updated: n/a")
if stale:
parts.append("stale cache")
color = "orange"
if last_error:
cut = last_error if len(last_error) <= 120 else last_error[:117] + "..."
parts.append(f"last error: {cut}")
color = "red" if not refreshing else "orange"
if next_retry:
parts.append(f"next retry: {next_retry}")
self.lbl_locations_meta.setText(" | ".join(parts))
self.lbl_locations_meta.setStyleSheet(f"color: {color};")
def refresh_routes_tab(self) -> None:
def work():
timer_enabled = self.ctrl.routes_timer_enabled()
self.chk_timer.blockSignals(True)
self.chk_timer.setChecked(bool(timer_enabled))
self.chk_timer.blockSignals(False)
t = self.ctrl.traffic_mode_view()
self._set_traffic_mode_state(
t.desired_mode,
t.applied_mode,
t.preferred_iface,
bool(t.advanced_active),
bool(t.auto_local_bypass),
bool(t.auto_local_active),
bool(t.ingress_reply_bypass),
bool(t.ingress_reply_active),
int(t.bypass_candidates),
int(t.overrides_applied),
int(t.cgroup_resolved_uids),
t.cgroup_warning,
bool(t.healthy),
bool(t.ingress_rule_present),
bool(t.ingress_nft_active),
bool(t.probe_ok),
t.probe_message,
t.active_iface,
t.iface_reason,
t.message,
)
rs = self.ctrl.routes_resolve_summary_view()
self.lbl_routes_resolve_summary.setText(rs.text)
self.lbl_routes_resolve_summary.setStyleSheet(f"color: {rs.color};")
self.lbl_routes_recheck_summary.setText(rs.recheck_text)
self.lbl_routes_recheck_summary.setStyleSheet(f"color: {rs.recheck_color};")
self._safe(work, title="Routes error")
def refresh_dns_tab(self) -> None:
def work():
self._dns_ui_refresh = True
try:
pool = self.ctrl.dns_upstream_pool_view()
self._set_dns_resolver_summary(getattr(pool, "items", []))
st = self.ctrl.dns_status_view()
self.ent_smartdns_addr.setText(st.smartdns_addr or "")
mode = (getattr(st, "mode", "") or "").strip().lower()
if mode in ("hybrid_wildcard", "hybrid"):
hybrid_enabled = True
mode = "hybrid_wildcard"
else:
hybrid_enabled = False
mode = "direct"
self.chk_dns_via_smartdns.blockSignals(True)
self.chk_dns_via_smartdns.setChecked(hybrid_enabled)
self.chk_dns_via_smartdns.blockSignals(False)
unit_state = (st.unit_state or "unknown").strip().lower()
unit_active = unit_state == "active"
self.chk_dns_unit_relay.blockSignals(True)
self.chk_dns_unit_relay.setChecked(unit_active)
self.chk_dns_unit_relay.blockSignals(False)
self.chk_dns_runtime_nftset.blockSignals(True)
self.chk_dns_runtime_nftset.setChecked(bool(getattr(st, "runtime_nftset", True)))
self.chk_dns_runtime_nftset.blockSignals(False)
self._set_dns_unit_relay_state(unit_active)
self._set_dns_runtime_state(
bool(getattr(st, "runtime_nftset", True)),
str(getattr(st, "wildcard_source", "") or ""),
str(getattr(st, "runtime_config_error", "") or ""),
)
self._set_dns_mode_state(mode)
finally:
self._dns_ui_refresh = False
self._safe(work, title="DNS error")
def refresh_domains_tab(self) -> None:
def work():
# reload currently selected file
self.on_domains_load()
self._safe(work, title="Domains error")
def refresh_trace_tab(self) -> None:
def work():
if self.radio_trace_gui.isChecked():
mode: TraceMode = "gui"
elif self.radio_trace_smartdns.isChecked():
mode = "smartdns"
else:
mode = "full"
dump = self.ctrl.trace_view(mode)
text = "\n".join(dump.lines).rstrip()
if dump.lines:
text += "\n"
self._set_text(self.txt_trace, text, preserve_scroll=True)
self._safe(work, title="Trace error")

View File

@@ -0,0 +1,481 @@
from __future__ import annotations
from PySide6.QtWidgets import QLabel
from main_window.constants import LoginPage
class RuntimeStateMixin:
def _get_selected_domains_file(self) -> str:
item = self.lst_files.currentItem()
return item.text() if item is not None else "bases"
def _load_file_content(self, name: str) -> tuple[str, str, str]:
api_map = {
"bases": "bases",
"meta-special": "meta",
"subs": "subs",
"static-ips": "static",
"last-ips-map-direct": "last-ips-map-direct",
"last-ips-map-wildcard": "last-ips-map-wildcard",
"wildcard-observed-hosts": "wildcard-observed-hosts",
"smartdns.conf": "smartdns",
}
if name in api_map:
f = self.ctrl.domains_file_load(api_map[name])
content = f.content or ""
source = getattr(f, "source", "") or "api"
if name == "smartdns.conf":
path = "/var/lib/selective-vpn/smartdns-wildcards.json -> /etc/selective-vpn/smartdns.conf"
elif name == "last-ips-map-direct":
path = "/var/lib/selective-vpn/last-ips-map-direct.txt (artifact: agvpn4)"
elif name == "last-ips-map-wildcard":
path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (artifact: agvpn_dyn4)"
elif name == "wildcard-observed-hosts":
path = "/var/lib/selective-vpn/last-ips-map-wildcard.txt (derived unique hosts)"
else:
path = f"/etc/selective-vpn/domains/{name}.txt"
return content, source, path
return "", "unknown", name
def _save_file_content(self, name: str, content: str) -> None:
api_map = {
"bases": "bases",
"meta-special": "meta",
"subs": "subs",
"static-ips": "static",
"smartdns.conf": "smartdns",
}
if name in api_map:
self.ctrl.domains_file_save(api_map[name], content)
return
def _show_vpn_page(self, which: LoginPage) -> None:
self.vpn_stack.setCurrentIndex(1 if which == "login" else 0)
def _set_auth_button(self, logged: bool) -> None:
self.btn_auth.setText("Logout" if logged else "Login")
def _set_status_label_color(self, lbl: QLabel, text: str, *, kind: str) -> None:
"""Подкраска Policy route / services."""
lbl.setText(text)
low = (text or "").lower()
color = "black"
if kind == "policy":
if "ok" in low and "missing" not in low and "error" not in low:
color = "green"
elif any(w in low for w in ("missing", "error", "failed")):
color = "red"
else:
color = "orange"
else: # service
if any(w in low for w in ("failed", "error", "unknown", "inactive", "dead")):
color = "red"
elif "active" in low or "running" in low:
color = "green"
else:
color = "orange"
lbl.setStyleSheet(f"color: {color};")
def _set_dns_unit_relay_state(self, enabled: bool) -> None:
txt = "SmartDNS unit relay: ON" if enabled else "SmartDNS unit relay: OFF"
color = "green" if enabled else "red"
self.chk_dns_unit_relay.setText(txt)
self.chk_dns_unit_relay.setStyleSheet(f"color: {color};")
def _set_dns_runtime_state(self, enabled: bool, source: str, cfg_error: str = "") -> None:
txt = "SmartDNS runtime accelerator (nftset -> agvpn_dyn4): ON" if enabled else "SmartDNS runtime accelerator (nftset -> agvpn_dyn4): OFF"
color = "green" if enabled else "orange"
self.chk_dns_runtime_nftset.setText(txt)
self.chk_dns_runtime_nftset.setStyleSheet(f"color: {color};")
src = (source or "").strip().lower()
if src == "both":
src_txt = "Wildcard source: both (resolver + smartdns_runtime)"
src_color = "green"
elif src == "smartdns_runtime":
src_txt = "Wildcard source: smartdns_runtime"
src_color = "orange"
else:
src_txt = "Wildcard source: resolver"
src_color = "gray"
if cfg_error:
src_txt = f"{src_txt} | runtime cfg: {cfg_error}"
src_color = "orange"
self.lbl_dns_wildcard_source.setText(src_txt)
self.lbl_dns_wildcard_source.setStyleSheet(f"color: {src_color};")
def _set_dns_mode_state(self, mode: str) -> None:
low = (mode or "").strip().lower()
if low in ("hybrid_wildcard", "hybrid"):
txt = "Resolver mode: hybrid wildcard (SmartDNS for wildcard domains)"
color = "green"
elif low == "direct":
txt = "Resolver mode: direct upstreams"
color = "red"
else:
txt = "Resolver mode: unknown"
color = "orange"
self.lbl_dns_mode_state.setText(txt)
self.lbl_dns_mode_state.setStyleSheet(f"color: {color};")
def _set_dns_resolver_summary(self, pool_items) -> None:
active = []
total = 0
for item in pool_items or []:
addr = str(getattr(item, "addr", "") or "").strip()
if not addr:
continue
total += 1
if bool(getattr(item, "enabled", False)):
active.append(addr)
applied = len(active)
if applied > 12:
applied = 12
if not active:
text = f"Resolver upstreams: active=0/{total} (empty set)"
else:
preview = ", ".join(active[:4])
if len(active) > 4:
preview += f", +{len(active)-4} more"
text = f"Resolver upstreams: active={len(active)}/{total}, applied={applied}/12 [{preview}]"
self.lbl_dns_resolver_upstreams.setText(text)
self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;")
avg_ms = self._ui_settings.value("dns_benchmark/last_avg_ms", None)
ok = self._ui_settings.value("dns_benchmark/last_ok", None)
fail = self._ui_settings.value("dns_benchmark/last_fail", None)
timeout = self._ui_settings.value("dns_benchmark/last_timeout", None)
if avg_ms is None or ok is None or fail is None:
self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet")
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
return
try:
avg = int(avg_ms)
ok_i = int(ok)
fail_i = int(fail)
timeout_i = int(timeout or 0)
except Exception:
self.lbl_dns_resolver_health.setText("Resolver health: no benchmark yet")
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
return
color = "green" if avg < 200 else ("#b58900" if avg <= 400 else "red")
if timeout_i > 0 and color != "red":
color = "#b58900"
self.lbl_dns_resolver_health.setText(
f"Resolver health: avg={avg}ms ok={ok_i} fail={fail_i} timeout={timeout_i}"
)
self.lbl_dns_resolver_health.setStyleSheet(f"color: {color};")
def _set_traffic_mode_state(
self,
desired_mode: str,
applied_mode: str,
preferred_iface: str,
advanced_active: bool,
auto_local_bypass: bool,
auto_local_active: bool,
ingress_reply_bypass: bool,
ingress_reply_active: bool,
bypass_candidates: int,
overrides_applied: int,
cgroup_resolved_uids: int,
cgroup_warning: str,
healthy: bool,
ingress_rule_present: bool,
ingress_nft_active: bool,
probe_ok: bool,
probe_message: str,
active_iface: str,
iface_reason: str,
message: str,
) -> None:
desired = (desired_mode or "").strip().lower() or "selective"
applied = (applied_mode or "").strip().lower() or "direct"
if healthy:
color = "green"
health_txt = "OK"
else:
color = "red"
health_txt = "MISMATCH"
text = f"Traffic mode: {desired} (applied: {applied}) [{health_txt}]"
diag_parts = []
diag_parts.append(f"preferred={preferred_iface or 'auto'}")
diag_parts.append(
f"advanced={'on' if advanced_active else 'off'}"
)
diag_parts.append(
f"auto_local={'on' if auto_local_bypass else 'off'}"
f"({'active' if auto_local_active else 'saved'})"
)
diag_parts.append(
f"ingress_reply={'on' if ingress_reply_bypass else 'off'}"
f"({'active' if ingress_reply_active else 'saved'})"
)
if auto_local_active and bypass_candidates > 0:
diag_parts.append(f"bypass_routes={bypass_candidates}")
diag_parts.append(f"overrides={overrides_applied}")
if cgroup_resolved_uids > 0:
diag_parts.append(f"cgroup_uids={cgroup_resolved_uids}")
if cgroup_warning:
diag_parts.append(f"cgroup_warning={cgroup_warning}")
if active_iface:
diag_parts.append(f"iface={active_iface}")
if iface_reason:
diag_parts.append(f"source={iface_reason}")
diag_parts.append(
f"ingress_diag=rule:{'ok' if ingress_rule_present else 'off'}"
f"/nft:{'ok' if ingress_nft_active else 'off'}"
)
diag_parts.append(f"probe={'ok' if probe_ok else 'fail'}")
if probe_message:
diag_parts.append(probe_message)
if message:
diag_parts.append(message)
diag = " | ".join(diag_parts) if diag_parts else ""
self.lbl_traffic_mode_state.setText(text)
self.lbl_traffic_mode_state.setStyleSheet(f"color: {color};")
self.lbl_traffic_mode_diag.setText(diag)
self.lbl_traffic_mode_diag.setStyleSheet("color: gray;")
def _update_routes_progress_label(self, view) -> None:
"""
Обновляет прогресс nft по RoutesNftProgressView.
view ожидаем с полями percent, message, active (duck-typing).
"""
if view is None:
# сброс до idle
self._routes_progress_last = 0
self.routes_progress.setValue(0)
self.lbl_routes_progress.setText("NFT: idle")
self.lbl_routes_progress.setStyleSheet("color: gray;")
return
# аккуратно ограничим 0..100
try:
percent = max(0, min(100, int(view.percent)))
except Exception:
percent = 0
# не даём прогрессу дёргаться назад, кроме явного сброса (percent==0)
if percent == 0:
self._routes_progress_last = 0
else:
percent = max(percent, self._routes_progress_last)
self._routes_progress_last = percent
self.routes_progress.setValue(percent)
text = f"{percent}% {view.message}"
if not view.active and percent >= 100:
color = "green"
elif view.active:
color = "orange"
else:
color = "gray"
self.lbl_routes_progress.setText(text)
self.lbl_routes_progress.setStyleSheet(f"color: {color};")
def _load_ui_preferences(self) -> None:
raw = self._ui_settings.value("routes/prewarm_aggressive", False)
if isinstance(raw, str):
val = raw.strip().lower() in ("1", "true", "yes", "on")
else:
val = bool(raw)
self.chk_routes_prewarm_aggressive.blockSignals(True)
self.chk_routes_prewarm_aggressive.setChecked(val)
self.chk_routes_prewarm_aggressive.blockSignals(False)
self._update_prewarm_mode_label()
sort_mode = str(self._ui_settings.value("vpn/locations_sort", "ping") or "ping").strip().lower()
idx = self.cmb_locations_sort.findData(sort_mode)
if idx < 0:
idx = 0
self.cmb_locations_sort.blockSignals(True)
self.cmb_locations_sort.setCurrentIndex(idx)
self.cmb_locations_sort.blockSignals(False)
g_route = str(
self._ui_settings.value("singbox/global_routing", "selective") or "selective"
).strip().lower()
idx = self.cmb_singbox_global_routing.findData(g_route)
if idx < 0:
idx = 0
self.cmb_singbox_global_routing.blockSignals(True)
self.cmb_singbox_global_routing.setCurrentIndex(idx)
self.cmb_singbox_global_routing.blockSignals(False)
g_dns = str(
self._ui_settings.value("singbox/global_dns", "system_resolver") or "system_resolver"
).strip().lower()
idx = self.cmb_singbox_global_dns.findData(g_dns)
if idx < 0:
idx = 0
self.cmb_singbox_global_dns.blockSignals(True)
self.cmb_singbox_global_dns.setCurrentIndex(idx)
self.cmb_singbox_global_dns.blockSignals(False)
g_kill = str(
self._ui_settings.value("singbox/global_killswitch", "on") or "on"
).strip().lower()
idx = self.cmb_singbox_global_killswitch.findData(g_kill)
if idx < 0:
idx = 0
self.cmb_singbox_global_killswitch.blockSignals(True)
self.cmb_singbox_global_killswitch.setCurrentIndex(idx)
self.cmb_singbox_global_killswitch.blockSignals(False)
p_route = str(
self._ui_settings.value("singbox/profile_routing", "global") or "global"
).strip().lower()
idx = self.cmb_singbox_profile_routing.findData(p_route)
if idx < 0:
idx = 0
self.cmb_singbox_profile_routing.blockSignals(True)
self.cmb_singbox_profile_routing.setCurrentIndex(idx)
self.cmb_singbox_profile_routing.blockSignals(False)
p_dns = str(
self._ui_settings.value("singbox/profile_dns", "global") or "global"
).strip().lower()
idx = self.cmb_singbox_profile_dns.findData(p_dns)
if idx < 0:
idx = 0
self.cmb_singbox_profile_dns.blockSignals(True)
self.cmb_singbox_profile_dns.setCurrentIndex(idx)
self.cmb_singbox_profile_dns.blockSignals(False)
p_kill = str(
self._ui_settings.value("singbox/profile_killswitch", "global") or "global"
).strip().lower()
idx = self.cmb_singbox_profile_killswitch.findData(p_kill)
if idx < 0:
idx = 0
self.cmb_singbox_profile_killswitch.blockSignals(True)
self.cmb_singbox_profile_killswitch.setCurrentIndex(idx)
self.cmb_singbox_profile_killswitch.blockSignals(False)
raw = self._ui_settings.value("singbox/profile_use_global_routing", True)
use_global_route = (
raw.strip().lower() in ("1", "true", "yes", "on")
if isinstance(raw, str)
else bool(raw)
)
raw = self._ui_settings.value("singbox/profile_use_global_dns", True)
use_global_dns = (
raw.strip().lower() in ("1", "true", "yes", "on")
if isinstance(raw, str)
else bool(raw)
)
raw = self._ui_settings.value("singbox/profile_use_global_killswitch", True)
use_global_kill = (
raw.strip().lower() in ("1", "true", "yes", "on")
if isinstance(raw, str)
else bool(raw)
)
self.chk_singbox_profile_use_global_routing.blockSignals(True)
self.chk_singbox_profile_use_global_routing.setChecked(use_global_route)
self.chk_singbox_profile_use_global_routing.blockSignals(False)
self.chk_singbox_profile_use_global_dns.blockSignals(True)
self.chk_singbox_profile_use_global_dns.setChecked(use_global_dns)
self.chk_singbox_profile_use_global_dns.blockSignals(False)
self.chk_singbox_profile_use_global_killswitch.blockSignals(True)
self.chk_singbox_profile_use_global_killswitch.setChecked(use_global_kill)
self.chk_singbox_profile_use_global_killswitch.blockSignals(False)
raw = self._ui_settings.value("singbox/ui_show_profile_settings", False)
show_profile = (
raw.strip().lower() in ("1", "true", "yes", "on")
if isinstance(raw, str)
else bool(raw)
)
raw = self._ui_settings.value("singbox/ui_show_global_defaults", False)
show_global = (
raw.strip().lower() in ("1", "true", "yes", "on")
if isinstance(raw, str)
else bool(raw)
)
raw = self._ui_settings.value("singbox/ui_show_activity_log", False)
show_activity = (
raw.strip().lower() in ("1", "true", "yes", "on")
if isinstance(raw, str)
else bool(raw)
)
if show_profile and show_global:
show_global = False
self.btn_singbox_toggle_profile_settings.blockSignals(True)
self.btn_singbox_toggle_profile_settings.setChecked(show_profile)
self.btn_singbox_toggle_profile_settings.blockSignals(False)
self.btn_singbox_toggle_global_defaults.blockSignals(True)
self.btn_singbox_toggle_global_defaults.setChecked(show_global)
self.btn_singbox_toggle_global_defaults.blockSignals(False)
self.btn_singbox_toggle_activity.blockSignals(True)
self.btn_singbox_toggle_activity.setChecked(show_activity)
self.btn_singbox_toggle_activity.blockSignals(False)
self._apply_singbox_profile_controls()
self._apply_singbox_compact_visibility()
def _save_ui_preferences(self) -> None:
self._ui_settings.setValue(
"routes/prewarm_aggressive",
bool(self.chk_routes_prewarm_aggressive.isChecked()),
)
self._ui_settings.setValue(
"vpn/locations_sort",
str(self.cmb_locations_sort.currentData() or "ping"),
)
self._ui_settings.setValue(
"singbox/global_routing",
str(self.cmb_singbox_global_routing.currentData() or "selective"),
)
self._ui_settings.setValue(
"singbox/global_dns",
str(self.cmb_singbox_global_dns.currentData() or "system_resolver"),
)
self._ui_settings.setValue(
"singbox/global_killswitch",
str(self.cmb_singbox_global_killswitch.currentData() or "on"),
)
self._ui_settings.setValue(
"singbox/profile_use_global_routing",
bool(self.chk_singbox_profile_use_global_routing.isChecked()),
)
self._ui_settings.setValue(
"singbox/profile_use_global_dns",
bool(self.chk_singbox_profile_use_global_dns.isChecked()),
)
self._ui_settings.setValue(
"singbox/profile_use_global_killswitch",
bool(self.chk_singbox_profile_use_global_killswitch.isChecked()),
)
self._ui_settings.setValue(
"singbox/profile_routing",
str(self.cmb_singbox_profile_routing.currentData() or "global"),
)
self._ui_settings.setValue(
"singbox/profile_dns",
str(self.cmb_singbox_profile_dns.currentData() or "global"),
)
self._ui_settings.setValue(
"singbox/profile_killswitch",
str(self.cmb_singbox_profile_killswitch.currentData() or "global"),
)
self._ui_settings.setValue(
"singbox/ui_show_profile_settings",
bool(self.btn_singbox_toggle_profile_settings.isChecked()),
)
self._ui_settings.setValue(
"singbox/ui_show_global_defaults",
bool(self.btn_singbox_toggle_global_defaults.isChecked()),
)
self._ui_settings.setValue(
"singbox/ui_show_activity_log",
bool(self.btn_singbox_toggle_activity.isChecked()),
)
self._ui_settings.sync()

View File

@@ -0,0 +1,11 @@
from .cards_mixin import SingBoxCardsMixin
from .editor_mixin import SingBoxEditorMixin
from .links_mixin import SingBoxLinksMixin
from .runtime_mixin import SingBoxRuntimeMixin
__all__ = [
"SingBoxCardsMixin",
"SingBoxEditorMixin",
"SingBoxLinksMixin",
"SingBoxRuntimeMixin",
]

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QFrame, QLabel, QListWidgetItem, QVBoxLayout
from main_window.constants import SINGBOX_STATUS_ROLE
from transport_protocol_summary import transport_protocol_summary
class SingBoxCardsMixin:
def _singbox_client_protocol_summary(self, client) -> str:
protocol_txt = transport_protocol_summary(client)
if protocol_txt == "n/a":
cid = str(getattr(client, "id", "") or "").strip()
if (
cid
and cid == str(self._singbox_editor_profile_client_id or "").strip()
and str(self._singbox_editor_protocol or "").strip()
):
protocol_txt = str(self._singbox_editor_protocol).strip().lower()
return protocol_txt
def _make_singbox_profile_card_widget(
self,
*,
name: str,
protocol_txt: str,
status: str,
latency_txt: str,
cid: str,
) -> QFrame:
frame = QFrame()
frame.setObjectName("singboxProfileCard")
lay = QVBoxLayout(frame)
lay.setContentsMargins(10, 8, 10, 8)
lay.setSpacing(2)
lbl_name = QLabel(name)
lbl_name.setObjectName("cardName")
lbl_name.setAlignment(Qt.AlignHCenter)
lay.addWidget(lbl_name)
lbl_proto = QLabel(protocol_txt)
lbl_proto.setObjectName("cardProto")
lbl_proto.setAlignment(Qt.AlignHCenter)
lay.addWidget(lbl_proto)
lbl_state = QLabel(f"{str(status or '').upper()} · {latency_txt}")
lbl_state.setObjectName("cardState")
lbl_state.setAlignment(Qt.AlignHCenter)
lay.addWidget(lbl_state)
frame.setToolTip(f"{cid}\n{protocol_txt}\nstatus={status}")
return frame
def _style_singbox_profile_card_widget(
self,
card: QFrame,
*,
active: bool,
selected: bool,
) -> None:
if active and selected:
bg = "#c7f1d5"
border = "#208f47"
name_color = "#11552e"
meta_color = "#1f6f43"
elif active:
bg = "#eafaf0"
border = "#2f9e44"
name_color = "#14532d"
meta_color = "#1f6f43"
elif selected:
bg = "#e8f1ff"
border = "#2f80ed"
name_color = "#1b2f50"
meta_color = "#28568a"
else:
bg = "#f7f7f7"
border = "#c9c9c9"
name_color = "#202020"
meta_color = "#666666"
card.setStyleSheet(
f"""
QFrame#singboxProfileCard {{
border: 1px solid {border};
border-radius: 6px;
background: {bg};
}}
QLabel#cardName {{
color: {name_color};
font-weight: 600;
}}
QLabel#cardProto {{
color: {meta_color};
}}
QLabel#cardState {{
color: {meta_color};
}}
"""
)
def _refresh_singbox_profile_card_styles(self) -> None:
current_id = self._selected_transport_engine_id()
for i in range(self.lst_singbox_profile_cards.count()):
item = self.lst_singbox_profile_cards.item(i)
cid = str(item.data(Qt.UserRole) or "").strip()
status = str(item.data(SINGBOX_STATUS_ROLE) or "").strip().lower()
card = self.lst_singbox_profile_cards.itemWidget(item)
if not isinstance(card, QFrame):
continue
self._style_singbox_profile_card_widget(
card,
active=(status == "up"),
selected=bool(current_id and cid == current_id),
)
def _render_singbox_profile_cards(self) -> None:
current_id = self._selected_transport_engine_id()
self.lst_singbox_profile_cards.blockSignals(True)
self.lst_singbox_profile_cards.clear()
selected_item = None
if not self._transport_api_supported:
item = QListWidgetItem("Transport API unavailable")
item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable)
self.lst_singbox_profile_cards.addItem(item)
self.lst_singbox_profile_cards.blockSignals(False)
return
if not self._transport_clients:
item = QListWidgetItem("No SingBox profiles configured")
item.setFlags(item.flags() & ~Qt.ItemIsEnabled & ~Qt.ItemIsSelectable)
self.lst_singbox_profile_cards.addItem(item)
self.lst_singbox_profile_cards.blockSignals(False)
return
for c in self._transport_clients:
cid = str(getattr(c, "id", "") or "").strip()
if not cid:
continue
name = str(getattr(c, "name", "") or "").strip() or cid
status, latency, _last_error, _last_check = self._transport_live_health_for_client(c)
latency_txt = f"{latency}ms" if latency > 0 else "no ping"
protocol_txt = self._singbox_client_protocol_summary(c)
item = QListWidgetItem("")
item.setData(Qt.UserRole, cid)
item.setData(SINGBOX_STATUS_ROLE, status)
item.setSizeHint(QSize(228, 78))
self.lst_singbox_profile_cards.addItem(item)
self.lst_singbox_profile_cards.setItemWidget(
item,
self._make_singbox_profile_card_widget(
name=name,
protocol_txt=protocol_txt,
status=status,
latency_txt=latency_txt,
cid=cid,
),
)
if current_id and cid == current_id:
selected_item = item
if selected_item is not None:
self.lst_singbox_profile_cards.setCurrentItem(selected_item)
elif self.lst_singbox_profile_cards.count() > 0:
self.lst_singbox_profile_cards.setCurrentRow(0)
self.lst_singbox_profile_cards.blockSignals(False)
self._refresh_singbox_profile_card_styles()
def _sync_singbox_profile_card_selection(self, cid: str) -> None:
if self._syncing_singbox_selection:
return
self._syncing_singbox_selection = True
try:
self.lst_singbox_profile_cards.blockSignals(True)
self.lst_singbox_profile_cards.clearSelection()
target = str(cid or "").strip()
if target:
for i in range(self.lst_singbox_profile_cards.count()):
item = self.lst_singbox_profile_cards.item(i)
if str(item.data(Qt.UserRole) or "").strip() == target:
self.lst_singbox_profile_cards.setCurrentItem(item)
break
self.lst_singbox_profile_cards.blockSignals(False)
finally:
self._syncing_singbox_selection = False
self._refresh_singbox_profile_card_styles()
def _select_transport_engine_by_id(self, cid: str) -> bool:
target = str(cid or "").strip()
if not target:
return False
idx = self.cmb_transport_engine.findData(target)
if idx < 0:
return False
if idx != self.cmb_transport_engine.currentIndex():
self.cmb_transport_engine.setCurrentIndex(idx)
else:
self._sync_singbox_profile_card_selection(target)
self._sync_selected_singbox_profile_link(silent=True)
self._load_singbox_editor_for_selected(silent=True)
self._update_transport_engine_view()
return True

View File

@@ -0,0 +1,633 @@
from __future__ import annotations
import json
from typing import Any
from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_PROTOCOL_SEED_SPEC
class SingBoxEditorMixin:
def _selected_singbox_profile_id(self) -> str:
selected = self._selected_transport_client()
if selected is not None:
selected_cid = str(getattr(selected, "id", "") or "").strip()
if (
selected_cid
and self._singbox_editor_profile_id
and selected_cid == str(self._singbox_editor_profile_client_id or "").strip()
):
return str(self._singbox_editor_profile_id).strip()
if selected_cid:
# Desktop SingBox tab keeps one deterministic profile per engine card.
return selected_cid
return self._selected_transport_engine_id()
def _set_singbox_editor_enabled(self, enabled: bool) -> None:
widgets = [
self.ent_singbox_proto_name,
self.chk_singbox_proto_enabled,
self.cmb_singbox_proto_protocol,
self.ent_singbox_vless_server,
self.spn_singbox_vless_port,
self.ent_singbox_vless_uuid,
self.ent_singbox_proto_password,
self.cmb_singbox_vless_flow,
self.cmb_singbox_vless_packet_encoding,
self.cmb_singbox_ss_method,
self.ent_singbox_ss_plugin,
self.spn_singbox_hy2_up_mbps,
self.spn_singbox_hy2_down_mbps,
self.ent_singbox_hy2_obfs,
self.ent_singbox_hy2_obfs_password,
self.cmb_singbox_tuic_congestion,
self.cmb_singbox_tuic_udp_mode,
self.chk_singbox_tuic_zero_rtt,
self.ent_singbox_wg_private_key,
self.ent_singbox_wg_peer_public_key,
self.ent_singbox_wg_psk,
self.ent_singbox_wg_local_address,
self.ent_singbox_wg_reserved,
self.spn_singbox_wg_mtu,
self.btn_singbox_wg_paste_private,
self.btn_singbox_wg_copy_private,
self.btn_singbox_wg_paste_peer,
self.btn_singbox_wg_copy_peer,
self.btn_singbox_wg_paste_psk,
self.btn_singbox_wg_copy_psk,
self.cmb_singbox_vless_transport,
self.ent_singbox_vless_path,
self.ent_singbox_vless_grpc_service,
self.cmb_singbox_vless_security,
self.ent_singbox_vless_sni,
self.ent_singbox_tls_alpn,
self.cmb_singbox_vless_utls_fp,
self.ent_singbox_vless_reality_pk,
self.ent_singbox_vless_reality_sid,
self.chk_singbox_vless_insecure,
self.chk_singbox_vless_sniff,
]
for w in widgets:
w.setEnabled(bool(enabled))
def _clear_singbox_editor(self) -> None:
self._singbox_editor_loading = True
try:
self._singbox_editor_profile_id = ""
self._singbox_editor_profile_client_id = ""
self._singbox_editor_protocol = "vless"
self._singbox_editor_source_raw = {}
self.ent_singbox_proto_name.setText("")
self.chk_singbox_proto_enabled.setChecked(True)
self.cmb_singbox_proto_protocol.setCurrentIndex(0)
self.ent_singbox_vless_server.setText("")
self.spn_singbox_vless_port.setValue(443)
self.ent_singbox_vless_uuid.setText("")
self.ent_singbox_proto_password.setText("")
self.cmb_singbox_vless_flow.setCurrentIndex(0)
self.cmb_singbox_vless_flow.setEditText("")
self.cmb_singbox_vless_packet_encoding.setCurrentIndex(0)
self.cmb_singbox_ss_method.setCurrentIndex(0)
self.ent_singbox_ss_plugin.setText("")
self.spn_singbox_hy2_up_mbps.setValue(0)
self.spn_singbox_hy2_down_mbps.setValue(0)
self.ent_singbox_hy2_obfs.setText("")
self.ent_singbox_hy2_obfs_password.setText("")
self.cmb_singbox_tuic_congestion.setCurrentIndex(0)
self.cmb_singbox_tuic_udp_mode.setCurrentIndex(0)
self.chk_singbox_tuic_zero_rtt.setChecked(False)
self.ent_singbox_wg_private_key.setText("")
self.ent_singbox_wg_peer_public_key.setText("")
self.ent_singbox_wg_psk.setText("")
self.ent_singbox_wg_local_address.setText("")
self.ent_singbox_wg_reserved.setText("")
self.spn_singbox_wg_mtu.setValue(0)
self.cmb_singbox_vless_transport.setCurrentIndex(0)
self.ent_singbox_vless_path.setText("")
self.ent_singbox_vless_grpc_service.setText("")
self.cmb_singbox_vless_security.setCurrentIndex(0)
self.ent_singbox_vless_sni.setText("")
self.ent_singbox_tls_alpn.setText("")
self.cmb_singbox_vless_utls_fp.setCurrentIndex(0)
self.ent_singbox_vless_reality_pk.setText("")
self.ent_singbox_vless_reality_sid.setText("")
self.chk_singbox_vless_insecure.setChecked(False)
self.chk_singbox_vless_sniff.setChecked(True)
finally:
self._singbox_editor_loading = False
self.on_singbox_vless_editor_changed()
def _load_singbox_editor_for_selected(self, *, silent: bool = True) -> None:
client = self._selected_transport_client()
if client is None:
self._clear_singbox_editor()
self._set_singbox_editor_enabled(False)
return
try:
cid = str(getattr(client, "id", "") or "").strip()
profile = self.ctrl.singbox_profile_get_for_client(
client,
profile_id=self._selected_singbox_profile_id(),
)
self._apply_singbox_editor_profile(profile, fallback_name=str(getattr(client, "name", "") or "").strip())
self._singbox_editor_profile_client_id = cid
self._set_singbox_editor_enabled(True)
except Exception as e:
if not silent:
raise
self._append_transport_log(f"[profile] editor load failed: {e}")
self._clear_singbox_editor()
self._set_singbox_editor_enabled(False)
def _find_editor_proxy_outbound(self, outbounds: list[Any]) -> dict[str, Any]:
proxy = {}
for row in outbounds:
if not isinstance(row, dict):
continue
t = str(row.get("type") or "").strip().lower()
tag = str(row.get("tag") or "").strip().lower()
if self._is_supported_editor_protocol(t):
proxy = row
break
if tag == "proxy":
proxy = row
return dict(proxy) if isinstance(proxy, dict) else {}
def _find_editor_sniff_inbound(self, inbounds: list[Any]) -> dict[str, Any]:
inbound = {}
for row in inbounds:
if not isinstance(row, dict):
continue
tag = str(row.get("tag") or "").strip().lower()
t = str(row.get("type") or "").strip().lower()
if tag == "socks-in" or t == "socks":
inbound = row
break
return dict(inbound) if isinstance(inbound, dict) else {}
def _apply_singbox_editor_profile(self, profile, *, fallback_name: str = "") -> None:
raw = getattr(profile, "raw_config", {}) or {}
if not isinstance(raw, dict):
raw = {}
protocol = str(getattr(profile, "protocol", "") or "").strip().lower() or "vless"
outbounds = raw.get("outbounds") or []
if not isinstance(outbounds, list):
outbounds = []
inbounds = raw.get("inbounds") or []
if not isinstance(inbounds, list):
inbounds = []
proxy = self._find_editor_proxy_outbound(outbounds)
inbound = self._find_editor_sniff_inbound(inbounds)
proxy_type = str(proxy.get("type") or "").strip().lower()
if self._is_supported_editor_protocol(proxy_type):
protocol = proxy_type
tls = proxy.get("tls") if isinstance(proxy.get("tls"), dict) else {}
reality = tls.get("reality") if isinstance(tls.get("reality"), dict) else {}
utls = tls.get("utls") if isinstance(tls.get("utls"), dict) else {}
transport = proxy.get("transport") if isinstance(proxy.get("transport"), dict) else {}
security = "none"
if bool(tls.get("enabled", False)):
security = "tls"
if bool(reality.get("enabled", False)):
security = "reality"
transport_type = str(transport.get("type") or "").strip().lower() or "tcp"
path = str(transport.get("path") or "").strip()
grpc_service = str(transport.get("service_name") or "").strip()
alpn_vals = tls.get("alpn") or []
if not isinstance(alpn_vals, list):
alpn_vals = []
alpn_text = ",".join([str(x).strip() for x in alpn_vals if str(x).strip()])
self._singbox_editor_loading = True
try:
self._singbox_editor_profile_id = str(getattr(profile, "id", "") or "").strip()
self._singbox_editor_protocol = protocol
self._singbox_editor_source_raw = json.loads(json.dumps(raw))
self.ent_singbox_proto_name.setText(
str(getattr(profile, "name", "") or "").strip() or fallback_name or self._singbox_editor_profile_id
)
self.chk_singbox_proto_enabled.setChecked(bool(getattr(profile, "enabled", True)))
pidx = self.cmb_singbox_proto_protocol.findData(protocol)
self.cmb_singbox_proto_protocol.setCurrentIndex(pidx if pidx >= 0 else 0)
self.ent_singbox_vless_server.setText(str(proxy.get("server") or "").strip())
try:
self.spn_singbox_vless_port.setValue(int(proxy.get("server_port") or 443))
except Exception:
self.spn_singbox_vless_port.setValue(443)
self.ent_singbox_vless_uuid.setText(str(proxy.get("uuid") or "").strip())
self.ent_singbox_proto_password.setText(str(proxy.get("password") or "").strip())
flow_value = str(proxy.get("flow") or "").strip()
idx = self.cmb_singbox_vless_flow.findData(flow_value)
if idx >= 0:
self.cmb_singbox_vless_flow.setCurrentIndex(idx)
else:
self.cmb_singbox_vless_flow.setEditText(flow_value)
pe = str(proxy.get("packet_encoding") or "").strip().lower()
if pe in ("none", "off", "false"):
pe = ""
idx = self.cmb_singbox_vless_packet_encoding.findData(pe)
self.cmb_singbox_vless_packet_encoding.setCurrentIndex(idx if idx >= 0 else 0)
ss_method = str(proxy.get("method") or "").strip().lower()
idx = self.cmb_singbox_ss_method.findData(ss_method)
if idx >= 0:
self.cmb_singbox_ss_method.setCurrentIndex(idx)
else:
self.cmb_singbox_ss_method.setEditText(ss_method)
self.ent_singbox_ss_plugin.setText(str(proxy.get("plugin") or "").strip())
try:
self.spn_singbox_hy2_up_mbps.setValue(int(proxy.get("up_mbps") or 0))
except Exception:
self.spn_singbox_hy2_up_mbps.setValue(0)
try:
self.spn_singbox_hy2_down_mbps.setValue(int(proxy.get("down_mbps") or 0))
except Exception:
self.spn_singbox_hy2_down_mbps.setValue(0)
obfs = proxy.get("obfs") if isinstance(proxy.get("obfs"), dict) else {}
self.ent_singbox_hy2_obfs.setText(str(obfs.get("type") or "").strip())
self.ent_singbox_hy2_obfs_password.setText(str(obfs.get("password") or "").strip())
cc = str(proxy.get("congestion_control") or "").strip()
idx = self.cmb_singbox_tuic_congestion.findData(cc)
if idx >= 0:
self.cmb_singbox_tuic_congestion.setCurrentIndex(idx)
else:
self.cmb_singbox_tuic_congestion.setCurrentIndex(0)
udp_mode = str(proxy.get("udp_relay_mode") or "").strip()
idx = self.cmb_singbox_tuic_udp_mode.findData(udp_mode)
self.cmb_singbox_tuic_udp_mode.setCurrentIndex(idx if idx >= 0 else 0)
self.chk_singbox_tuic_zero_rtt.setChecked(bool(proxy.get("zero_rtt_handshake", False)))
self.ent_singbox_wg_private_key.setText(str(proxy.get("private_key") or "").strip())
self.ent_singbox_wg_peer_public_key.setText(str(proxy.get("peer_public_key") or "").strip())
self.ent_singbox_wg_psk.setText(str(proxy.get("pre_shared_key") or "").strip())
local_addr = proxy.get("local_address") or []
if not isinstance(local_addr, list):
if str(local_addr or "").strip():
local_addr = [str(local_addr).strip()]
else:
local_addr = []
self.ent_singbox_wg_local_address.setText(
",".join([str(x).strip() for x in local_addr if str(x).strip()])
)
reserved = proxy.get("reserved") or []
if not isinstance(reserved, list):
if str(reserved or "").strip():
reserved = [str(reserved).strip()]
else:
reserved = []
self.ent_singbox_wg_reserved.setText(
",".join([str(x).strip() for x in reserved if str(x).strip()])
)
try:
self.spn_singbox_wg_mtu.setValue(int(proxy.get("mtu") or 0))
except Exception:
self.spn_singbox_wg_mtu.setValue(0)
idx = self.cmb_singbox_vless_transport.findData(transport_type)
self.cmb_singbox_vless_transport.setCurrentIndex(idx if idx >= 0 else 0)
self.ent_singbox_vless_path.setText(path)
self.ent_singbox_vless_grpc_service.setText(grpc_service)
idx = self.cmb_singbox_vless_security.findData(security)
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0)
self.ent_singbox_vless_sni.setText(str(tls.get("server_name") or "").strip())
self.ent_singbox_tls_alpn.setText(alpn_text)
idx = self.cmb_singbox_vless_utls_fp.findData(str(utls.get("fingerprint") or "").strip())
self.cmb_singbox_vless_utls_fp.setCurrentIndex(idx if idx >= 0 else 0)
self.ent_singbox_vless_reality_pk.setText(str(reality.get("public_key") or "").strip())
self.ent_singbox_vless_reality_sid.setText(str(reality.get("short_id") or "").strip())
self.chk_singbox_vless_insecure.setChecked(bool(tls.get("insecure", False)))
self.chk_singbox_vless_sniff.setChecked(bool(inbound.get("sniff", True)))
finally:
self._singbox_editor_loading = False
self.on_singbox_vless_editor_changed()
def _validate_singbox_editor_form(self) -> None:
protocol = self._current_editor_protocol()
addr = self.ent_singbox_vless_server.text().strip()
if not addr:
raise RuntimeError("Address is required")
security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower()
transport = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower()
if protocol == "vless":
if not self.ent_singbox_vless_uuid.text().strip():
raise RuntimeError("UUID is required for VLESS")
if security == "reality" and not self.ent_singbox_vless_reality_pk.text().strip():
raise RuntimeError("Reality public key is required for Reality security mode")
elif protocol == "trojan":
if not self.ent_singbox_proto_password.text().strip():
raise RuntimeError("Password is required for Trojan")
if security == "reality":
raise RuntimeError("Reality security is not supported for Trojan in this editor")
elif protocol == "shadowsocks":
method = str(self.cmb_singbox_ss_method.currentData() or "").strip()
if not method:
method = self.cmb_singbox_ss_method.currentText().strip()
if not method:
raise RuntimeError("SS method is required for Shadowsocks")
if not self.ent_singbox_proto_password.text().strip():
raise RuntimeError("Password is required for Shadowsocks")
elif protocol == "hysteria2":
if not self.ent_singbox_proto_password.text().strip():
raise RuntimeError("Password is required for Hysteria2")
elif protocol == "tuic":
if not self.ent_singbox_vless_uuid.text().strip():
raise RuntimeError("UUID is required for TUIC")
if not self.ent_singbox_proto_password.text().strip():
raise RuntimeError("Password is required for TUIC")
elif protocol == "wireguard":
if not self.ent_singbox_wg_private_key.text().strip():
raise RuntimeError("WireGuard private key is required")
if not self.ent_singbox_wg_peer_public_key.text().strip():
raise RuntimeError("WireGuard peer public key is required")
local_addr = [str(x).strip() for x in self.ent_singbox_wg_local_address.text().split(",") if str(x).strip()]
if not local_addr:
raise RuntimeError("WireGuard local address is required (CIDR list)")
self._parse_wg_reserved_values(
[str(x).strip() for x in self.ent_singbox_wg_reserved.text().split(",") if str(x).strip()],
strict=True,
)
if protocol in ("vless", "trojan"):
if transport == "grpc" and not self.ent_singbox_vless_grpc_service.text().strip():
raise RuntimeError("gRPC service is required for gRPC transport")
if transport in ("ws", "http", "httpupgrade") and not self.ent_singbox_vless_path.text().strip():
raise RuntimeError("Transport path is required for selected transport")
def _build_singbox_editor_raw_config(self) -> dict[str, Any]:
base = self._singbox_editor_source_raw
if not isinstance(base, dict):
base = {}
raw: dict[str, Any] = json.loads(json.dumps(base))
protocol = self._current_editor_protocol()
outbounds = raw.get("outbounds") or []
if not isinstance(outbounds, list):
outbounds = []
proxy_idx = -1
for i, row in enumerate(outbounds):
if not isinstance(row, dict):
continue
t = str(row.get("type") or "").strip().lower()
tag = str(row.get("tag") or "").strip().lower()
if self._is_supported_editor_protocol(t) or tag == "proxy":
proxy_idx = i
break
proxy: dict[str, Any]
if proxy_idx >= 0:
proxy = dict(outbounds[proxy_idx]) if isinstance(outbounds[proxy_idx], dict) else {}
else:
proxy = {}
proxy["type"] = protocol
proxy["tag"] = str(proxy.get("tag") or "proxy")
proxy["server"] = self.ent_singbox_vless_server.text().strip()
proxy["server_port"] = int(self.spn_singbox_vless_port.value())
# clear protocol-specific keys before repopulating
for key in (
"uuid",
"password",
"method",
"plugin",
"flow",
"packet_encoding",
"up_mbps",
"down_mbps",
"obfs",
"congestion_control",
"udp_relay_mode",
"zero_rtt_handshake",
"private_key",
"peer_public_key",
"pre_shared_key",
"local_address",
"reserved",
"mtu",
):
proxy.pop(key, None)
if protocol == "vless":
proxy["uuid"] = self.ent_singbox_vless_uuid.text().strip()
flow = str(self.cmb_singbox_vless_flow.currentData() or "").strip()
if not flow:
flow = self.cmb_singbox_vless_flow.currentText().strip()
if flow:
proxy["flow"] = flow
packet_encoding = str(self.cmb_singbox_vless_packet_encoding.currentData() or "").strip().lower()
if packet_encoding and packet_encoding != "none":
proxy["packet_encoding"] = packet_encoding
elif protocol == "trojan":
proxy["password"] = self.ent_singbox_proto_password.text().strip()
elif protocol == "shadowsocks":
method = str(self.cmb_singbox_ss_method.currentData() or "").strip()
if not method:
method = self.cmb_singbox_ss_method.currentText().strip()
proxy["method"] = method
proxy["password"] = self.ent_singbox_proto_password.text().strip()
plugin = self.ent_singbox_ss_plugin.text().strip()
if plugin:
proxy["plugin"] = plugin
elif protocol == "hysteria2":
proxy["password"] = self.ent_singbox_proto_password.text().strip()
up = int(self.spn_singbox_hy2_up_mbps.value())
down = int(self.spn_singbox_hy2_down_mbps.value())
if up > 0:
proxy["up_mbps"] = up
if down > 0:
proxy["down_mbps"] = down
obfs_type = self.ent_singbox_hy2_obfs.text().strip()
if obfs_type:
obfs: dict[str, Any] = {"type": obfs_type}
obfs_password = self.ent_singbox_hy2_obfs_password.text().strip()
if obfs_password:
obfs["password"] = obfs_password
proxy["obfs"] = obfs
elif protocol == "tuic":
proxy["uuid"] = self.ent_singbox_vless_uuid.text().strip()
proxy["password"] = self.ent_singbox_proto_password.text().strip()
cc = str(self.cmb_singbox_tuic_congestion.currentData() or "").strip()
if not cc:
cc = self.cmb_singbox_tuic_congestion.currentText().strip()
if cc:
proxy["congestion_control"] = cc
udp_mode = str(self.cmb_singbox_tuic_udp_mode.currentData() or "").strip()
if udp_mode:
proxy["udp_relay_mode"] = udp_mode
if self.chk_singbox_tuic_zero_rtt.isChecked():
proxy["zero_rtt_handshake"] = True
elif protocol == "wireguard":
proxy["private_key"] = self.ent_singbox_wg_private_key.text().strip()
proxy["peer_public_key"] = self.ent_singbox_wg_peer_public_key.text().strip()
psk = self.ent_singbox_wg_psk.text().strip()
if psk:
proxy["pre_shared_key"] = psk
local_addr = [str(x).strip() for x in self.ent_singbox_wg_local_address.text().split(",") if str(x).strip()]
if local_addr:
proxy["local_address"] = local_addr
reserved_vals = self._parse_wg_reserved_values(
[str(x).strip() for x in self.ent_singbox_wg_reserved.text().split(",") if str(x).strip()],
strict=True,
)
if reserved_vals:
proxy["reserved"] = reserved_vals
mtu = int(self.spn_singbox_wg_mtu.value())
if mtu > 0:
proxy["mtu"] = mtu
transport_type = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower()
if protocol in ("vless", "trojan"):
self._apply_proxy_transport(
proxy,
transport=transport_type,
path=self.ent_singbox_vless_path.text().strip(),
grpc_service=self.ent_singbox_vless_grpc_service.text().strip(),
)
else:
proxy.pop("transport", None)
security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower()
if protocol == "vless":
pass
elif protocol == "trojan":
if security == "reality":
security = "tls"
elif protocol in ("hysteria2", "tuic"):
security = "tls"
else:
security = "none"
alpn = []
for p in self.ent_singbox_tls_alpn.text().split(","):
v = str(p or "").strip()
if v:
alpn.append(v)
self._apply_proxy_tls(
proxy,
security=security,
sni=self.ent_singbox_vless_sni.text().strip(),
utls_fp=str(self.cmb_singbox_vless_utls_fp.currentData() or "").strip(),
tls_insecure=bool(self.chk_singbox_vless_insecure.isChecked()),
reality_public_key=self.ent_singbox_vless_reality_pk.text().strip(),
reality_short_id=self.ent_singbox_vless_reality_sid.text().strip(),
alpn=alpn,
)
if proxy_idx >= 0:
outbounds[proxy_idx] = proxy
else:
outbounds.insert(0, proxy)
has_direct = any(
isinstance(row, dict)
and str(row.get("type") or "").strip().lower() == "direct"
and str(row.get("tag") or "").strip().lower() == "direct"
for row in outbounds
)
if not has_direct:
outbounds.append({"type": "direct", "tag": "direct"})
raw["outbounds"] = outbounds
inbounds = raw.get("inbounds") or []
if not isinstance(inbounds, list):
inbounds = []
inbound_idx = -1
for i, row in enumerate(inbounds):
if not isinstance(row, dict):
continue
tag = str(row.get("tag") or "").strip().lower()
t = str(row.get("type") or "").strip().lower()
if tag == "socks-in" or t == "socks":
inbound_idx = i
break
inbound = (
dict(inbounds[inbound_idx]) if inbound_idx >= 0 and isinstance(inbounds[inbound_idx], dict) else {}
)
inbound["type"] = str(inbound.get("type") or "socks")
inbound["tag"] = str(inbound.get("tag") or "socks-in")
inbound["listen"] = str(inbound.get("listen") or "127.0.0.1")
inbound["listen_port"] = int(inbound.get("listen_port") or 10808)
sniff = bool(self.chk_singbox_vless_sniff.isChecked())
inbound["sniff"] = sniff
inbound["sniff_override_destination"] = sniff
if inbound_idx >= 0:
inbounds[inbound_idx] = inbound
else:
inbounds.insert(0, inbound)
raw["inbounds"] = inbounds
route = raw.get("route") if isinstance(raw.get("route"), dict) else {}
route["final"] = str(route.get("final") or "direct")
rules = route.get("rules") or []
if not isinstance(rules, list):
rules = []
has_proxy_rule = False
for row in rules:
if not isinstance(row, dict):
continue
outbound = str(row.get("outbound") or "").strip().lower()
inbound_list = row.get("inbound") or []
if not isinstance(inbound_list, list):
inbound_list = []
inbound_norm = [str(x).strip().lower() for x in inbound_list if str(x).strip()]
if outbound == "proxy" and "socks-in" in inbound_norm:
has_proxy_rule = True
break
if not has_proxy_rule:
rules.insert(0, {"inbound": ["socks-in"], "outbound": "proxy"})
route["rules"] = rules
raw["route"] = route
return raw
def _save_singbox_editor_draft(self, client, *, profile_id: str = ""):
protocol = self._current_editor_protocol()
self._validate_singbox_editor_form()
raw_cfg = self._build_singbox_editor_raw_config()
name = self.ent_singbox_proto_name.text().strip()
enabled = bool(self.chk_singbox_proto_enabled.isChecked())
res = self.ctrl.singbox_profile_save_raw_for_client(
client,
profile_id=profile_id,
name=name,
enabled=enabled,
protocol=protocol,
raw_config=raw_cfg,
)
profile = self.ctrl.singbox_profile_get_for_client(client, profile_id=profile_id)
self._singbox_editor_profile_id = str(getattr(profile, "id", "") or "").strip()
self._singbox_editor_profile_client_id = str(getattr(client, "id", "") or "").strip()
self._singbox_editor_protocol = str(getattr(profile, "protocol", "") or protocol).strip().lower() or protocol
self._singbox_editor_source_raw = json.loads(json.dumps(getattr(profile, "raw_config", {}) or {}))
return res
def _sync_selected_singbox_profile_link(self, *, silent: bool = True) -> None:
client = self._selected_transport_client()
if client is None:
return
try:
preferred_pid = str(getattr(client, "id", "") or "").strip()
res = self.ctrl.singbox_profile_ensure_linked(
client,
preferred_profile_id=preferred_pid,
)
except Exception as e:
if not silent:
raise
self._append_transport_log(f"[profile] auto-link skipped: {e}")
return
line = (res.pretty_text or "").strip()
if not line:
return
# Keep noisy "already linked" messages out of normal flow.
if "already linked" in line.lower() and silent:
return
self._append_transport_log(f"[profile] {line}")
self.ctrl.log_gui(f"[singbox-profile] {line}")

View File

@@ -0,0 +1,271 @@
from __future__ import annotations
import json
from PySide6.QtWidgets import QApplication, QInputDialog, QMenu
from main_window.constants import SINGBOX_EDITOR_PROTOCOL_OPTIONS
class SingBoxLinksActionsMixin:
def _apply_singbox_editor_values(self, values: dict[str, Any]) -> None:
incoming = dict(values or {})
target_protocol = str(incoming.get("protocol") or self._current_editor_protocol() or "vless").strip().lower() or "vless"
payload = self._seed_editor_values_for_protocol(
target_protocol,
profile_name=str(incoming.get("profile_name") or "").strip(),
)
payload.update(incoming)
self._singbox_editor_loading = True
try:
name = str(payload.get("profile_name") or "").strip()
self.ent_singbox_proto_name.setText(name)
self.chk_singbox_proto_enabled.setChecked(bool(payload.get("enabled", True)))
protocol = str(payload.get("protocol") or "").strip().lower()
if protocol:
pidx = self.cmb_singbox_proto_protocol.findData(protocol)
self.cmb_singbox_proto_protocol.setCurrentIndex(pidx if pidx >= 0 else self.cmb_singbox_proto_protocol.currentIndex())
self.ent_singbox_vless_server.setText(str(payload.get("server") or "").strip())
try:
self.spn_singbox_vless_port.setValue(int(payload.get("port") or 443))
except Exception:
self.spn_singbox_vless_port.setValue(443)
self.ent_singbox_vless_uuid.setText(str(payload.get("uuid") or "").strip())
self.ent_singbox_proto_password.setText(str(payload.get("password") or "").strip())
flow_v = str(payload.get("flow") or "").strip()
flow_idx = self.cmb_singbox_vless_flow.findData(flow_v)
if flow_idx >= 0:
self.cmb_singbox_vless_flow.setCurrentIndex(flow_idx)
else:
self.cmb_singbox_vless_flow.setEditText(flow_v)
packet_v = str(payload.get("packet_encoding") or "").strip().lower()
if packet_v in ("none", "off", "false"):
packet_v = ""
packet_idx = self.cmb_singbox_vless_packet_encoding.findData(packet_v)
self.cmb_singbox_vless_packet_encoding.setCurrentIndex(packet_idx if packet_idx >= 0 else 0)
transport_v = str(payload.get("transport") or "tcp").strip().lower()
transport_idx = self.cmb_singbox_vless_transport.findData(transport_v)
self.cmb_singbox_vless_transport.setCurrentIndex(transport_idx if transport_idx >= 0 else 0)
self.ent_singbox_vless_path.setText(str(payload.get("path") or "").strip())
self.ent_singbox_vless_grpc_service.setText(str(payload.get("grpc_service") or "").strip())
sec_v = str(payload.get("security") or "none").strip().lower()
sec_idx = self.cmb_singbox_vless_security.findData(sec_v)
self.cmb_singbox_vless_security.setCurrentIndex(sec_idx if sec_idx >= 0 else 0)
self.ent_singbox_vless_sni.setText(str(payload.get("sni") or "").strip())
fp_v = str(payload.get("utls_fp") or "").strip().lower()
fp_idx = self.cmb_singbox_vless_utls_fp.findData(fp_v)
self.cmb_singbox_vless_utls_fp.setCurrentIndex(fp_idx if fp_idx >= 0 else 0)
self.ent_singbox_vless_reality_pk.setText(str(payload.get("reality_public_key") or "").strip())
self.ent_singbox_vless_reality_sid.setText(str(payload.get("reality_short_id") or "").strip())
self.chk_singbox_vless_insecure.setChecked(bool(payload.get("tls_insecure", False)))
self.chk_singbox_vless_sniff.setChecked(bool(payload.get("sniff", True)))
ss_method = str(payload.get("ss_method") or "").strip().lower()
if ss_method:
idx = self.cmb_singbox_ss_method.findData(ss_method)
if idx >= 0:
self.cmb_singbox_ss_method.setCurrentIndex(idx)
else:
self.cmb_singbox_ss_method.setEditText(ss_method)
else:
self.cmb_singbox_ss_method.setCurrentIndex(0)
self.ent_singbox_ss_plugin.setText(str(payload.get("ss_plugin") or "").strip())
try:
self.spn_singbox_hy2_up_mbps.setValue(int(payload.get("hy2_up_mbps") or 0))
except Exception:
self.spn_singbox_hy2_up_mbps.setValue(0)
try:
self.spn_singbox_hy2_down_mbps.setValue(int(payload.get("hy2_down_mbps") or 0))
except Exception:
self.spn_singbox_hy2_down_mbps.setValue(0)
self.ent_singbox_hy2_obfs.setText(str(payload.get("hy2_obfs") or "").strip())
self.ent_singbox_hy2_obfs_password.setText(str(payload.get("hy2_obfs_password") or "").strip())
tuic_cc = str(payload.get("tuic_congestion") or "").strip()
idx = self.cmb_singbox_tuic_congestion.findData(tuic_cc)
self.cmb_singbox_tuic_congestion.setCurrentIndex(idx if idx >= 0 else 0)
tuic_udp = str(payload.get("tuic_udp_mode") or "").strip()
idx = self.cmb_singbox_tuic_udp_mode.findData(tuic_udp)
self.cmb_singbox_tuic_udp_mode.setCurrentIndex(idx if idx >= 0 else 0)
self.chk_singbox_tuic_zero_rtt.setChecked(bool(payload.get("tuic_zero_rtt", False)))
self.ent_singbox_wg_private_key.setText(str(payload.get("wg_private_key") or "").strip())
self.ent_singbox_wg_peer_public_key.setText(str(payload.get("wg_peer_public_key") or "").strip())
self.ent_singbox_wg_psk.setText(str(payload.get("wg_psk") or "").strip())
wg_local = payload.get("wg_local_address") or []
if isinstance(wg_local, list):
self.ent_singbox_wg_local_address.setText(
",".join([str(x).strip() for x in wg_local if str(x).strip()])
)
else:
self.ent_singbox_wg_local_address.setText(str(wg_local or "").strip())
wg_reserved = payload.get("wg_reserved") or []
if isinstance(wg_reserved, list):
self.ent_singbox_wg_reserved.setText(
",".join([str(x).strip() for x in wg_reserved if str(x).strip()])
)
else:
self.ent_singbox_wg_reserved.setText(str(wg_reserved or "").strip())
try:
self.spn_singbox_wg_mtu.setValue(int(payload.get("wg_mtu") or 0))
except Exception:
self.spn_singbox_wg_mtu.setValue(0)
finally:
self._singbox_editor_loading = False
self.on_singbox_vless_editor_changed()
def _create_singbox_connection(
self,
*,
profile_name: str,
protocol: str = "vless",
raw_config: dict[str, Any] | None = None,
editor_values: dict[str, Any] | None = None,
auto_save: bool = False,
) -> str:
name = str(profile_name or "").strip() or "SingBox connection"
client_id = self._next_free_transport_client_id(name)
proto = self._normalized_seed_protocol(protocol)
config = self._default_new_singbox_client_config(client_id, protocol=proto)
created = self.ctrl.transport_client_create_action(
client_id=client_id,
kind="singbox",
name=name,
enabled=True,
config=config,
)
line = (created.pretty_text or "").strip() or f"create {client_id}"
self._append_transport_log(f"[engine] {line}")
self.ctrl.log_gui(f"[transport-engine] {line}")
if not created.ok:
raise RuntimeError(line)
self.refresh_transport_engines(silent=True)
if not self._select_transport_engine_by_id(client_id):
raise RuntimeError(f"created client '{client_id}' was not found after refresh")
self._sync_selected_singbox_profile_link(silent=False)
client, _eid, pid = self._selected_singbox_profile_context()
seed_raw = raw_config if isinstance(raw_config, dict) else self._seed_raw_config_for_protocol(proto)
saved_seed = self.ctrl.singbox_profile_save_raw_for_client(
client,
profile_id=pid,
name=name,
enabled=True,
protocol=proto,
raw_config=seed_raw,
)
seed_line = (saved_seed.pretty_text or "").strip() or f"save profile {pid}"
self._append_transport_log(f"[profile] {seed_line}")
self.ctrl.log_gui(f"[singbox-profile] {seed_line}")
self._load_singbox_editor_for_selected(silent=True)
if editor_values:
payload = dict(editor_values)
seeded = self._seed_editor_values_for_protocol(proto, profile_name=name)
seeded.update(payload)
payload = seeded
if not str(payload.get("profile_name") or "").strip():
payload["profile_name"] = name
self._apply_singbox_editor_values(payload)
if auto_save:
saved = self._save_singbox_editor_draft(client, profile_id=pid)
save_line = (saved.pretty_text or "").strip() or f"save profile {pid}"
self._append_transport_log(f"[profile] {save_line}")
self.ctrl.log_gui(f"[singbox-profile] {save_line}")
return client_id
def on_singbox_create_connection_click(self) -> None:
menu = QMenu(self)
act_clip = menu.addAction("Create from clipboard")
act_link = menu.addAction("Create from link...")
act_manual = menu.addAction("Create manual")
pos = self.btn_singbox_profile_create.mapToGlobal(
self.btn_singbox_profile_create.rect().bottomLeft()
)
chosen = menu.exec(pos)
if chosen is None:
return
if chosen == act_clip:
self._safe(self.on_singbox_create_connection_from_clipboard, title="Create connection error")
return
if chosen == act_link:
self._safe(self.on_singbox_create_connection_from_link, title="Create connection error")
return
if chosen == act_manual:
self._safe(self.on_singbox_create_connection_manual, title="Create connection error")
def on_singbox_create_connection_from_clipboard(self) -> None:
raw = str(QApplication.clipboard().text() or "").strip()
if not raw:
raise RuntimeError("Clipboard is empty")
payload = self._parse_connection_link_payload(raw)
profile_name = str(payload.get("profile_name") or "").strip() or "SingBox Clipboard"
cid = self._create_singbox_connection(
profile_name=profile_name,
protocol=str(payload.get("protocol") or "vless"),
raw_config=payload.get("raw_config") if isinstance(payload.get("raw_config"), dict) else None,
editor_values=payload.get("editor_values") if isinstance(payload.get("editor_values"), dict) else None,
auto_save=True,
)
self.on_singbox_profile_edit_dialog(cid)
def on_singbox_create_connection_from_link(self) -> None:
raw, ok = QInputDialog.getText(
self,
"Create connection from link",
"Paste connection link (vless:// trojan:// ss:// hysteria2:// hy2:// tuic:// wireguard://):",
)
if not ok:
return
payload = self._parse_connection_link_payload(raw)
profile_name = str(payload.get("profile_name") or "").strip() or "SingBox Link"
cid = self._create_singbox_connection(
profile_name=profile_name,
protocol=str(payload.get("protocol") or "vless"),
raw_config=payload.get("raw_config") if isinstance(payload.get("raw_config"), dict) else None,
editor_values=payload.get("editor_values") if isinstance(payload.get("editor_values"), dict) else None,
auto_save=True,
)
self.on_singbox_profile_edit_dialog(cid)
def on_singbox_create_connection_manual(self) -> None:
name, ok = QInputDialog.getText(
self,
"Create manual connection",
"Connection name:",
)
if not ok:
return
profile_name = str(name or "").strip() or "SingBox Manual"
proto_title, ok = QInputDialog.getItem(
self,
"Create manual connection",
"Protocol:",
[label for label, _pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS],
0,
False,
)
if not ok:
return
proto_map = {label.lower(): pid for label, pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS}
proto = self._normalized_seed_protocol(proto_map.get(str(proto_title or "").strip().lower(), "vless"))
cid = self._create_singbox_connection(
profile_name=profile_name,
protocol=proto,
editor_values=self._seed_editor_values_for_protocol(proto, profile_name=profile_name),
auto_save=False,
)
self.on_singbox_profile_edit_dialog(cid)

View File

@@ -0,0 +1,337 @@
from __future__ import annotations
import base64
import binascii
import json
import re
from urllib.parse import unquote
from typing import Any
from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_PROTOCOL_SEED_SPEC
class SingBoxLinksHelpersMixin:
def _slugify_connection_id(self, text: str) -> str:
raw = str(text or "").strip().lower()
raw = re.sub(r"[^a-z0-9]+", "-", raw)
raw = re.sub(r"-{2,}", "-", raw).strip("-")
if not raw:
raw = "connection"
if not raw.startswith("sg-"):
raw = f"sg-{raw}"
return raw
def _next_free_transport_client_id(self, base_hint: str) -> str:
base = self._slugify_connection_id(base_hint)
existing = {str(getattr(c, "id", "") or "").strip() for c in (self._transport_clients or [])}
if base not in existing:
return base
i = 2
while True:
cid = f"{base}-{i}"
if cid not in existing:
return cid
i += 1
def _template_singbox_client(self):
selected = self._selected_transport_client()
if selected is not None and str(getattr(selected, "kind", "") or "").strip().lower() == "singbox":
return selected
for c in self._transport_clients or []:
if str(getattr(c, "kind", "") or "").strip().lower() == "singbox":
return c
return None
def _default_new_singbox_client_config(self, client_id: str, *, protocol: str = "vless") -> dict[str, Any]:
cfg: dict[str, Any] = {}
tpl = self._template_singbox_client()
if tpl is not None:
src_cfg = getattr(tpl, "config", {}) or {}
if isinstance(src_cfg, dict):
for key in (
"runner",
"runtime_mode",
"require_binary",
"exec_start",
"singbox_bin",
"packaging_profile",
"packaging_system_fallback",
"bin_root",
"hardening_enabled",
"hardening_profile",
"restart",
"restart_sec",
"watchdog_sec",
"start_limit_interval_sec",
"start_limit_burst",
"timeout_start_sec",
"timeout_stop_sec",
"bootstrap_bypass_strict",
"netns_enabled",
"netns_name",
"netns_auto_cleanup",
"netns_setup_strict",
"singbox_dns_migrate_legacy",
"singbox_dns_migrate_strict",
):
if key in src_cfg:
cfg[key] = json.loads(json.dumps(src_cfg.get(key)))
cid = str(client_id or "").strip()
if not cid:
return cfg
for key in ("profile", "profile_id", "singbox_profile_id"):
cfg.pop(key, None)
config_path = f"/etc/selective-vpn/transports/{cid}/singbox.json"
cfg["config_path"] = config_path
cfg["singbox_config_path"] = config_path
runner = str(cfg.get("runner") or "").strip().lower()
if not runner:
cfg["runner"] = "systemd"
runner = "systemd"
if runner == "systemd":
cfg["unit"] = "singbox@.service"
if "runtime_mode" not in cfg:
cfg["runtime_mode"] = "exec"
if "require_binary" not in cfg:
cfg["require_binary"] = True
cfg["profile_id"] = cid
cfg["protocol"] = self._normalized_seed_protocol(protocol)
return cfg
def _normalized_seed_protocol(self, protocol: str) -> str:
proto = str(protocol or "vless").strip().lower() or "vless"
if proto not in SINGBOX_EDITOR_PROTOCOL_IDS:
proto = "vless"
return proto
def _protocol_seed_spec(self, protocol: str) -> dict[str, Any]:
proto = self._normalized_seed_protocol(protocol)
spec = SINGBOX_PROTOCOL_SEED_SPEC.get(proto) or SINGBOX_PROTOCOL_SEED_SPEC.get("vless") or {}
if not isinstance(spec, dict):
spec = {}
return dict(spec)
def _seed_editor_values_for_protocol(self, protocol: str, *, profile_name: str = "") -> dict[str, Any]:
proto = self._normalized_seed_protocol(protocol)
spec = self._protocol_seed_spec(proto)
security = str(spec.get("security") or "none").strip().lower() or "none"
port = int(spec.get("port") or (51820 if proto == "wireguard" else 443))
return {
"profile_name": str(profile_name or "").strip(),
"enabled": True,
"protocol": proto,
"server": "",
"port": port,
"uuid": "",
"password": "",
"flow": "",
"packet_encoding": "",
"transport": "tcp",
"path": "",
"grpc_service": "",
"security": security,
"sni": "",
"utls_fp": "",
"reality_public_key": "",
"reality_short_id": "",
"tls_insecure": False,
"sniff": True,
"ss_method": "aes-128-gcm",
"ss_plugin": "",
"hy2_up_mbps": 0,
"hy2_down_mbps": 0,
"hy2_obfs": "",
"hy2_obfs_password": "",
"tuic_congestion": "",
"tuic_udp_mode": "",
"tuic_zero_rtt": False,
"wg_private_key": "",
"wg_peer_public_key": "",
"wg_psk": "",
"wg_local_address": "",
"wg_reserved": "",
"wg_mtu": 0,
}
def _seed_raw_config_for_protocol(self, protocol: str) -> dict[str, Any]:
proto = self._normalized_seed_protocol(protocol)
spec = self._protocol_seed_spec(proto)
port = int(spec.get("port") or (51820 if proto == "wireguard" else 443))
proxy: dict[str, Any] = {
"type": proto,
"tag": "proxy",
"server": "",
"server_port": port,
}
proxy_defaults = spec.get("proxy_defaults") or {}
if isinstance(proxy_defaults, dict):
for key, value in proxy_defaults.items():
proxy[key] = json.loads(json.dumps(value))
tls_security = str(spec.get("tls_security") or "").strip().lower()
if tls_security in ("tls", "reality"):
self._apply_proxy_tls(proxy, security=tls_security)
return self._build_singbox_raw_config_from_proxy(proxy, sniff=True)
def _parse_wg_reserved_values(self, raw_values: list[str], *, strict: bool) -> list[int]:
vals = [str(x).strip() for x in list(raw_values or []) if str(x).strip()]
if len(vals) > 3:
if strict:
raise RuntimeError("WG reserved accepts up to 3 values (0..255)")
vals = vals[:3]
out: list[int] = []
for token in vals:
try:
num = int(token)
except Exception:
if strict:
raise RuntimeError(f"WG reserved value '{token}' is not an integer")
continue
if num < 0 or num > 255:
if strict:
raise RuntimeError(f"WG reserved value '{token}' must be in range 0..255")
continue
out.append(num)
return out
def _query_value(self, query: dict[str, list[str]], *keys: str) -> str:
for k in keys:
vals = query.get(str(k or "").strip())
if not vals:
continue
v = str(vals[0] or "").strip()
if v:
return unquote(v)
return ""
def _query_bool(self, query: dict[str, list[str]], *keys: str) -> bool:
v = self._query_value(query, *keys).strip().lower()
return v in ("1", "true", "yes", "on")
def _query_csv(self, query: dict[str, list[str]], *keys: str) -> list[str]:
raw = self._query_value(query, *keys)
if not raw:
return []
out: list[str] = []
for p in raw.split(","):
val = str(p or "").strip()
if val:
out.append(val)
return out
def _normalize_link_transport(self, value: str) -> str:
v = str(value or "").strip().lower() or "tcp"
if v == "raw":
v = "tcp"
if v in ("h2", "http2"):
v = "http"
if v not in ("tcp", "ws", "grpc", "http", "httpupgrade", "quic"):
v = "tcp"
return v
def _b64_urlsafe_decode(self, value: str) -> str:
raw = str(value or "").strip()
if not raw:
return ""
pad = "=" * ((4 - (len(raw) % 4)) % 4)
try:
data = base64.urlsafe_b64decode((raw + pad).encode("utf-8"))
return data.decode("utf-8", errors="replace")
except (binascii.Error, ValueError):
return ""
def _apply_proxy_transport(
self,
proxy: dict[str, Any],
*,
transport: str,
path: str = "",
grpc_service: str = "",
) -> None:
t = self._normalize_link_transport(transport)
if t in ("", "tcp"):
proxy.pop("transport", None)
return
tx: dict[str, Any] = {"type": t}
if t in ("ws", "http", "httpupgrade"):
tx["path"] = str(path or "/").strip() or "/"
if t == "grpc":
tx["service_name"] = str(grpc_service or "").strip()
proxy["transport"] = tx
def _apply_proxy_tls(
self,
proxy: dict[str, Any],
*,
security: str,
sni: str = "",
utls_fp: str = "",
tls_insecure: bool = False,
reality_public_key: str = "",
reality_short_id: str = "",
alpn: list[str] | None = None,
) -> None:
sec = str(security or "").strip().lower()
if sec not in ("none", "tls", "reality"):
sec = "none"
if sec == "none":
proxy.pop("tls", None)
return
tls: dict[str, Any] = {
"enabled": True,
"insecure": bool(tls_insecure),
}
if str(sni or "").strip():
tls["server_name"] = str(sni).strip()
if str(utls_fp or "").strip():
tls["utls"] = {"enabled": True, "fingerprint": str(utls_fp).strip().lower()}
alpn_vals = [str(x).strip() for x in list(alpn or []) if str(x).strip()]
if alpn_vals:
tls["alpn"] = alpn_vals
if sec == "reality":
reality: dict[str, Any] = {
"enabled": True,
"public_key": str(reality_public_key or "").strip(),
}
sid = str(reality_short_id or "").strip()
if sid:
reality["short_id"] = sid
tls["reality"] = reality
proxy["tls"] = tls
def _build_singbox_raw_config_from_proxy(
self,
proxy: dict[str, Any],
*,
sniff: bool = True,
) -> dict[str, Any]:
return {
"inbounds": [
{
"type": "socks",
"tag": "socks-in",
"listen": "127.0.0.1",
"listen_port": 10808,
"sniff": bool(sniff),
"sniff_override_destination": bool(sniff),
}
],
"outbounds": [
proxy,
{"type": "direct", "tag": "direct"},
],
"route": {
"final": "direct",
"rules": [
{"inbound": ["socks-in"], "outbound": "proxy"},
],
},
}

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from main_window.singbox.links_actions_mixin import SingBoxLinksActionsMixin
from main_window.singbox.links_helpers_mixin import SingBoxLinksHelpersMixin
from main_window.singbox.links_parsers_mixin import SingBoxLinksParsersMixin
class SingBoxLinksMixin(
SingBoxLinksActionsMixin,
SingBoxLinksParsersMixin,
SingBoxLinksHelpersMixin,
):
"""Facade mixin for SingBox link import/create workflow."""
__all__ = ["SingBoxLinksMixin"]

View File

@@ -0,0 +1,391 @@
from __future__ import annotations
import re
from urllib.parse import parse_qs, unquote, urlsplit
from typing import Any
class SingBoxLinksParsersMixin:
def _parse_vless_link_payload(self, link: str) -> dict[str, Any]:
u = urlsplit(link)
query = parse_qs(u.query or "", keep_blank_values=True)
uuid = unquote(str(u.username or "").strip())
host = str(u.hostname or "").strip()
if not uuid:
raise RuntimeError("VLESS link has no UUID")
if not host:
raise RuntimeError("VLESS link has no host")
try:
port = int(u.port or 443)
except Exception:
port = 443
transport = self._normalize_link_transport(self._query_value(query, "type", "transport"))
security = self._query_value(query, "security").strip().lower() or "none"
if security == "xtls":
security = "tls"
if security not in ("none", "tls", "reality"):
security = "none"
path = self._query_value(query, "path", "spx")
if not path and str(u.path or "").strip() not in ("", "/"):
path = unquote(str(u.path or "").strip())
grpc_service = self._query_value(query, "serviceName", "service_name")
if transport == "grpc" and not grpc_service:
grpc_service = self._query_value(query, "path")
flow = self._query_value(query, "flow")
packet_encoding = self._query_value(query, "packetEncoding", "packet_encoding").strip().lower()
if packet_encoding in ("none", "off", "false"):
packet_encoding = ""
sni = self._query_value(query, "sni", "host")
utls_fp = self._query_value(query, "fp", "fingerprint")
reality_pk = self._query_value(query, "pbk", "public_key")
reality_sid = self._query_value(query, "sid", "short_id")
tls_insecure = self._query_bool(query, "allowInsecure", "insecure")
profile_name = unquote(str(u.fragment or "").strip()) or host
proxy: dict[str, Any] = {
"type": "vless",
"tag": "proxy",
"server": host,
"server_port": port,
"uuid": uuid,
}
if packet_encoding:
proxy["packet_encoding"] = packet_encoding
if flow:
proxy["flow"] = flow
self._apply_proxy_transport(proxy, transport=transport, path=path, grpc_service=grpc_service)
self._apply_proxy_tls(
proxy,
security=security,
sni=sni,
utls_fp=utls_fp,
tls_insecure=tls_insecure,
reality_public_key=reality_pk,
reality_short_id=reality_sid,
)
editor_values = {
"profile_name": profile_name,
"enabled": True,
"server": host,
"port": port,
"uuid": uuid,
"flow": flow,
"packet_encoding": packet_encoding,
"transport": transport,
"path": path,
"grpc_service": grpc_service,
"security": security,
"sni": sni,
"utls_fp": utls_fp,
"reality_public_key": reality_pk,
"reality_short_id": reality_sid,
"tls_insecure": tls_insecure,
"sniff": True,
}
return {
"protocol": "vless",
"profile_name": profile_name,
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
"editor_values": editor_values,
}
def _parse_trojan_link_payload(self, link: str) -> dict[str, Any]:
u = urlsplit(link)
query = parse_qs(u.query or "", keep_blank_values=True)
password = unquote(str(u.username or "").strip()) or self._query_value(query, "password")
host = str(u.hostname or "").strip()
if not password:
raise RuntimeError("Trojan link has no password")
if not host:
raise RuntimeError("Trojan link has no host")
try:
port = int(u.port or 443)
except Exception:
port = 443
transport = self._normalize_link_transport(self._query_value(query, "type", "transport"))
path = self._query_value(query, "path")
grpc_service = self._query_value(query, "serviceName", "service_name")
security = self._query_value(query, "security").strip().lower() or "tls"
if security not in ("none", "tls"):
security = "tls"
sni = self._query_value(query, "sni", "host")
utls_fp = self._query_value(query, "fp", "fingerprint")
tls_insecure = self._query_bool(query, "allowInsecure", "insecure")
alpn = self._query_csv(query, "alpn")
profile_name = unquote(str(u.fragment or "").strip()) or host
proxy: dict[str, Any] = {
"type": "trojan",
"tag": "proxy",
"server": host,
"server_port": port,
"password": password,
}
self._apply_proxy_transport(proxy, transport=transport, path=path, grpc_service=grpc_service)
self._apply_proxy_tls(
proxy,
security=security,
sni=sni,
utls_fp=utls_fp,
tls_insecure=tls_insecure,
alpn=alpn,
)
return {
"protocol": "trojan",
"profile_name": profile_name,
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
}
def _parse_ss_link_payload(self, link: str) -> dict[str, Any]:
raw = str(link or "").strip()
u = urlsplit(raw)
query = parse_qs(u.query or "", keep_blank_values=True)
profile_name = unquote(str(u.fragment or "").strip()) or "Shadowsocks"
body = raw[len("ss://"):]
body = body.split("#", 1)[0]
body = body.split("?", 1)[0]
method = ""
password = ""
host_port = ""
if "@" in body:
left, host_port = body.rsplit("@", 1)
creds = left
if ":" not in creds:
creds = self._b64_urlsafe_decode(creds)
if ":" not in creds:
raise RuntimeError("Shadowsocks link has invalid credentials")
method, password = creds.split(":", 1)
else:
decoded = self._b64_urlsafe_decode(body)
if "@" not in decoded:
raise RuntimeError("Shadowsocks link has invalid payload")
creds, host_port = decoded.rsplit("@", 1)
if ":" not in creds:
raise RuntimeError("Shadowsocks link has invalid credentials")
method, password = creds.split(":", 1)
hp = urlsplit("//" + host_port)
host = str(hp.hostname or "").strip()
if not host:
raise RuntimeError("Shadowsocks link has no host")
try:
port = int(hp.port or 8388)
except Exception:
port = 8388
proxy: dict[str, Any] = {
"type": "shadowsocks",
"tag": "proxy",
"server": host,
"server_port": port,
"method": str(method or "").strip(),
"password": str(password or "").strip(),
}
plugin = self._query_value(query, "plugin")
if plugin:
proxy["plugin"] = plugin
return {
"protocol": "shadowsocks",
"profile_name": profile_name,
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
}
def _parse_hysteria2_link_payload(self, link: str) -> dict[str, Any]:
u = urlsplit(link)
query = parse_qs(u.query or "", keep_blank_values=True)
password = unquote(str(u.username or "").strip()) or self._query_value(query, "password")
host = str(u.hostname or "").strip()
if not password:
raise RuntimeError("Hysteria2 link has no password")
if not host:
raise RuntimeError("Hysteria2 link has no host")
try:
port = int(u.port or 443)
except Exception:
port = 443
profile_name = unquote(str(u.fragment or "").strip()) or host
proxy: dict[str, Any] = {
"type": "hysteria2",
"tag": "proxy",
"server": host,
"server_port": port,
"password": password,
}
up_mbps = self._query_value(query, "up_mbps", "upmbps", "up")
down_mbps = self._query_value(query, "down_mbps", "downmbps", "down")
try:
if up_mbps:
proxy["up_mbps"] = int(float(up_mbps))
except Exception:
pass
try:
if down_mbps:
proxy["down_mbps"] = int(float(down_mbps))
except Exception:
pass
obfs_type = self._query_value(query, "obfs")
if obfs_type:
obfs: dict[str, Any] = {"type": obfs_type}
obfs_pw = self._query_value(query, "obfs-password", "obfs_password")
if obfs_pw:
obfs["password"] = obfs_pw
proxy["obfs"] = obfs
self._apply_proxy_tls(
proxy,
security="tls",
sni=self._query_value(query, "sni"),
tls_insecure=self._query_bool(query, "allowInsecure", "insecure"),
alpn=self._query_csv(query, "alpn"),
)
return {
"protocol": "hysteria2",
"profile_name": profile_name,
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
}
def _parse_tuic_link_payload(self, link: str) -> dict[str, Any]:
u = urlsplit(link)
query = parse_qs(u.query or "", keep_blank_values=True)
uuid = unquote(str(u.username or "").strip())
password = unquote(str(u.password or "").strip())
host = str(u.hostname or "").strip()
if not uuid:
raise RuntimeError("TUIC link has no UUID")
if not password:
raise RuntimeError("TUIC link has no password")
if not host:
raise RuntimeError("TUIC link has no host")
try:
port = int(u.port or 443)
except Exception:
port = 443
profile_name = unquote(str(u.fragment or "").strip()) or host
proxy: dict[str, Any] = {
"type": "tuic",
"tag": "proxy",
"server": host,
"server_port": port,
"uuid": uuid,
"password": password,
}
cc = self._query_value(query, "congestion_control", "congestion")
if cc:
proxy["congestion_control"] = cc
udp_mode = self._query_value(query, "udp_relay_mode")
if udp_mode:
proxy["udp_relay_mode"] = udp_mode
if self._query_bool(query, "zero_rtt_handshake", "zero_rtt"):
proxy["zero_rtt_handshake"] = True
self._apply_proxy_tls(
proxy,
security="tls",
sni=self._query_value(query, "sni", "host"),
utls_fp=self._query_value(query, "fp", "fingerprint"),
tls_insecure=self._query_bool(query, "allowInsecure", "insecure"),
alpn=self._query_csv(query, "alpn"),
)
return {
"protocol": "tuic",
"profile_name": profile_name,
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
}
def _parse_wireguard_link_payload(self, link: str) -> dict[str, Any]:
u = urlsplit(link)
query = parse_qs(u.query or "", keep_blank_values=True)
private_key = unquote(str(u.username or "").strip()) or self._query_value(query, "private_key", "privateKey")
host = str(u.hostname or "").strip()
if not host:
raise RuntimeError("WireGuard link has no host")
if not private_key:
raise RuntimeError("WireGuard link has no private key")
try:
port = int(u.port or 443)
except Exception:
port = 443
peer_public_key = self._query_value(query, "peer_public_key", "public_key", "peerPublicKey")
if not peer_public_key:
raise RuntimeError("WireGuard link has no peer public key")
local_address = self._query_csv(query, "local_address", "address", "localAddress")
if not local_address:
raise RuntimeError("WireGuard link has no local address")
profile_name = unquote(str(u.fragment or "").strip()) or host
proxy: dict[str, Any] = {
"type": "wireguard",
"tag": "proxy",
"server": host,
"server_port": port,
"private_key": private_key,
"peer_public_key": peer_public_key,
"local_address": local_address,
}
psk = self._query_value(query, "pre_shared_key", "psk", "preSharedKey")
if psk:
proxy["pre_shared_key"] = psk
reserved_vals = self._parse_wg_reserved_values(self._query_csv(query, "reserved"), strict=True)
if reserved_vals:
proxy["reserved"] = reserved_vals
mtu_val = self._query_value(query, "mtu")
try:
mtu = int(mtu_val) if mtu_val else 0
except Exception:
mtu = 0
if mtu > 0:
proxy["mtu"] = mtu
return {
"protocol": "wireguard",
"profile_name": profile_name,
"raw_config": self._build_singbox_raw_config_from_proxy(proxy, sniff=True),
}
def _extract_first_connection_link(self, text: str) -> str:
raw = str(text or "").strip()
if not raw:
return ""
m = re.search(r"(?i)(vless|trojan|ss|hysteria2|hy2|tuic|wireguard|wg)://\S+", raw)
if m:
return str(m.group(0) or "").strip()
if "://" in raw:
return raw.splitlines()[0].strip()
return ""
def _parse_connection_link_payload(self, text: str) -> dict[str, Any]:
raw = self._extract_first_connection_link(text)
if not raw:
raise RuntimeError(
"No supported link found. Supported schemes: "
"vless:// trojan:// ss:// hysteria2:// hy2:// tuic:// wireguard:// wg://"
)
u = urlsplit(raw)
scheme = str(u.scheme or "").strip().lower()
if scheme == "vless":
return self._parse_vless_link_payload(raw)
if scheme == "trojan":
return self._parse_trojan_link_payload(raw)
if scheme == "ss":
return self._parse_ss_link_payload(raw)
if scheme in ("hysteria2", "hy2"):
return self._parse_hysteria2_link_payload(raw)
if scheme == "tuic":
return self._parse_tuic_link_payload(raw)
if scheme in ("wireguard", "wg"):
return self._parse_wireguard_link_payload(raw)
raise RuntimeError(f"Unsupported link scheme: {scheme}")

View File

@@ -0,0 +1,136 @@
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMenu, QMessageBox
class SingBoxRuntimeCardsMixin:
def on_singbox_profile_card_context_menu(self, pos) -> None:
item = self.lst_singbox_profile_cards.itemAt(pos)
if item is None:
return
cid = str(item.data(Qt.UserRole) or "").strip()
if not cid:
return
menu = QMenu(self)
act_run = menu.addAction("Run")
act_edit = menu.addAction("Edit")
act_delete = menu.addAction("Delete")
chosen = menu.exec(self.lst_singbox_profile_cards.viewport().mapToGlobal(pos))
if chosen is None:
return
if not self._select_transport_engine_by_id(cid):
QMessageBox.warning(self, "SingBox profile", f"Profile '{cid}' is no longer available.")
return
if chosen == act_run:
self.on_transport_engine_action("start")
return
if chosen == act_edit:
self.on_singbox_profile_edit_dialog(cid)
return
if chosen == act_delete:
self.on_transport_engine_delete(cid)
return
def on_singbox_profile_card_selected(self) -> None:
if self._syncing_singbox_selection:
return
items = self.lst_singbox_profile_cards.selectedItems()
if not items:
return
cid = str(items[0].data(Qt.UserRole) or "").strip()
if not cid:
return
idx = self.cmb_transport_engine.findData(cid)
if idx < 0:
return
if idx != self.cmb_transport_engine.currentIndex():
self._syncing_singbox_selection = True
try:
self.cmb_transport_engine.setCurrentIndex(idx)
finally:
self._syncing_singbox_selection = False
return
self._refresh_singbox_profile_card_styles()
self._sync_selected_singbox_profile_link(silent=True)
self._load_singbox_editor_for_selected(silent=True)
self._update_transport_engine_view()
def _singbox_value_label(self, key: str, value: str) -> str:
v = str(value or "").strip().lower()
if key == "routing":
if v == "full":
return "Full tunnel"
return "Selective"
if key == "dns":
if v == "singbox_dns":
return "SingBox DNS"
return "System resolver"
if key == "killswitch":
if v == "off":
return "Disabled"
return "Enabled"
return v or ""
def _effective_singbox_policy(self) -> tuple[str, str, str]:
route = str(self.cmb_singbox_global_routing.currentData() or "selective").strip().lower()
dns = str(self.cmb_singbox_global_dns.currentData() or "system_resolver").strip().lower()
killswitch = str(self.cmb_singbox_global_killswitch.currentData() or "on").strip().lower()
if not self.chk_singbox_profile_use_global_routing.isChecked():
route = str(self.cmb_singbox_profile_routing.currentData() or route).strip().lower()
if route == "global":
route = str(self.cmb_singbox_global_routing.currentData() or "selective").strip().lower()
if not self.chk_singbox_profile_use_global_dns.isChecked():
dns = str(self.cmb_singbox_profile_dns.currentData() or dns).strip().lower()
if dns == "global":
dns = str(self.cmb_singbox_global_dns.currentData() or "system_resolver").strip().lower()
if not self.chk_singbox_profile_use_global_killswitch.isChecked():
killswitch = str(self.cmb_singbox_profile_killswitch.currentData() or killswitch).strip().lower()
if killswitch == "global":
killswitch = str(self.cmb_singbox_global_killswitch.currentData() or "on").strip().lower()
return route, dns, killswitch
def _refresh_singbox_profile_effective(self) -> None:
route, dns, killswitch = self._effective_singbox_policy()
route_txt = self._singbox_value_label("routing", route)
dns_txt = self._singbox_value_label("dns", dns)
kill_txt = self._singbox_value_label("killswitch", killswitch)
self.lbl_singbox_profile_effective.setText(
f"Effective: routing={route_txt} | dns={dns_txt} | kill-switch={kill_txt}"
)
self.lbl_singbox_profile_effective.setStyleSheet("color: gray;")
def _apply_singbox_profile_controls(self) -> None:
self.cmb_singbox_profile_routing.setEnabled(
not self.chk_singbox_profile_use_global_routing.isChecked()
)
self.cmb_singbox_profile_dns.setEnabled(
not self.chk_singbox_profile_use_global_dns.isChecked()
)
self.cmb_singbox_profile_killswitch.setEnabled(
not self.chk_singbox_profile_use_global_killswitch.isChecked()
)
self._refresh_singbox_profile_effective()
def _apply_singbox_compact_visibility(self) -> None:
show_profile = bool(self.btn_singbox_toggle_profile_settings.isChecked())
self.grp_singbox_profile_settings.setVisible(show_profile)
self.btn_singbox_toggle_profile_settings.setText(
"Hide profile settings" if show_profile else "Profile settings"
)
show_global = bool(self.btn_singbox_toggle_global_defaults.isChecked())
self.grp_singbox_global_defaults.setVisible(show_global)
self.btn_singbox_toggle_global_defaults.setText(
"Hide global defaults" if show_global else "Global defaults"
)
show_log = bool(self.btn_singbox_toggle_activity.isChecked())
self.grp_singbox_activity.setVisible(show_log)
self.btn_singbox_toggle_activity.setText(
"Hide activity log" if show_log else "Activity log"
)

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from main_window.singbox.runtime_cards_mixin import SingBoxRuntimeCardsMixin
from main_window.singbox.runtime_profiles_mixin import SingBoxRuntimeProfilesMixin
from main_window.singbox.runtime_transport_mixin import SingBoxRuntimeTransportMixin
class SingBoxRuntimeMixin(
SingBoxRuntimeProfilesMixin,
SingBoxRuntimeTransportMixin,
SingBoxRuntimeCardsMixin,
):
"""Facade mixin for SingBox runtime/profile actions."""
__all__ = ["SingBoxRuntimeMixin"]

View File

@@ -0,0 +1,428 @@
from __future__ import annotations
from typing import Literal
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QApplication,
QDialog,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QVBoxLayout,
)
class SingBoxRuntimeProfilesMixin:
def on_singbox_profile_edit_dialog(self, cid: str = "") -> None:
def work():
target = str(cid or "").strip() or self._selected_transport_engine_id()
if not target:
raise RuntimeError("Select a transport engine first")
if not self._select_transport_engine_by_id(target):
raise RuntimeError(f"Transport engine '{target}' not found")
self._sync_selected_singbox_profile_link(silent=True)
self._load_singbox_editor_for_selected(silent=False)
client = self._selected_transport_client()
pid = self._selected_singbox_profile_id()
if client is None or not pid:
raise RuntimeError("Select a SingBox profile first")
profile_name = self.ent_singbox_proto_name.text().strip() or str(getattr(client, "name", "") or pid).strip()
host_layout = self.grp_singbox_profile_settings.layout()
if host_layout is None:
raise RuntimeError("internal layout is unavailable")
editor = self.grp_singbox_proto_editor
insert_at = host_layout.indexOf(editor)
if insert_at >= 0:
host_layout.removeWidget(editor)
moved = False
dlg = QDialog(self)
dlg.setModal(True)
dlg.setWindowTitle(f"Edit SingBox profile: {profile_name}")
dlg.resize(860, 680)
dlg_layout = QVBoxLayout(dlg)
try:
hint = QLabel("Edit protocol fields and save draft. Use profile card menu for Run/Delete.")
hint.setStyleSheet("color: gray;")
dlg_layout.addWidget(hint)
editor.setTitle(f"{self._singbox_editor_default_title} · {profile_name}")
editor.setParent(dlg)
editor.setVisible(True)
moved = True
dlg_layout.addWidget(editor, stretch=1)
actions = QHBoxLayout()
btn_save = QPushButton("Save draft")
btn_close = QPushButton("Close")
actions.addWidget(btn_save)
actions.addStretch(1)
actions.addWidget(btn_close)
dlg_layout.addLayout(actions)
def save_draft_clicked() -> None:
try:
selected_client, _eid, selected_pid = self._selected_singbox_profile_context()
saved = self._save_singbox_editor_draft(selected_client, profile_id=selected_pid)
line = (saved.pretty_text or "").strip() or f"save profile {selected_pid}"
self._append_transport_log(f"[profile] {line}")
self.ctrl.log_gui(f"[singbox-profile] {line}")
self.lbl_transport_engine_meta.setText(f"Engine: profile {selected_pid} draft saved")
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
self._render_singbox_profile_cards()
self._sync_singbox_profile_card_selection(self._selected_transport_engine_id())
QMessageBox.information(dlg, "SingBox profile", line)
except Exception as e:
QMessageBox.critical(dlg, "SingBox profile save error", str(e))
btn_save.clicked.connect(save_draft_clicked)
btn_close.clicked.connect(dlg.accept)
dlg.exec()
finally:
if moved:
dlg_layout.removeWidget(editor)
editor.setParent(self.grp_singbox_profile_settings)
editor.setTitle(self._singbox_editor_default_title)
if insert_at >= 0:
host_layout.insertWidget(insert_at, editor)
else:
host_layout.addWidget(editor)
editor.setVisible(False)
self._safe(work, title="SingBox profile edit error")
def on_transport_engine_action(
self,
action: Literal["provision", "start", "stop", "restart"],
) -> None:
def work():
cid = self._selected_transport_engine_id()
if not cid:
raise RuntimeError("Select a transport engine first")
self.lbl_transport_engine_meta.setText(f"Engine: {action} {cid}...")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
QApplication.processEvents()
if action == "start":
selected_client = self._selected_transport_client()
if selected_client is not None and str(getattr(selected_client, "kind", "") or "").strip().lower() == "singbox":
_client, _eid, pid = self._selected_singbox_profile_context()
self.lbl_transport_engine_meta.setText(f"Engine: preparing profile {pid} for start...")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
QApplication.processEvents()
pre = self.ctrl.singbox_profile_apply_action(
pid,
client_id=cid,
restart=False,
skip_runtime=True,
check_binary=True,
client=selected_client,
)
pre_line = (pre.pretty_text or "").strip() or f"apply profile {pid}"
self._append_transport_log(f"[profile] {pre_line}")
self.ctrl.log_gui(f"[singbox-profile] {pre_line}")
if not pre.ok:
raise RuntimeError(f"profile preflight failed: {pre_line}")
ok, msg = self._apply_transport_switch_policy(cid)
self._append_transport_log(f"[switch] {msg}")
self.ctrl.log_gui(f"[transport-switch] {msg}")
if not ok:
if "canceled by user" in msg.lower():
self.refresh_transport_engines(silent=True)
return
raise RuntimeError(msg)
res = self.ctrl.transport_client_action(cid, action if action != "start" else "start")
line = (res.pretty_text or "").strip() or f"{action} {cid}"
self._append_transport_log(f"[engine] {line}")
self.ctrl.log_gui(f"[transport-engine] {line}")
if not res.ok:
raise RuntimeError(line)
self.refresh_transport_engines(silent=True)
self.refresh_status_tab()
self._safe(work, title="Transport engine error")
def on_transport_engine_delete(self, cid: str = "") -> None:
def work():
target = str(cid or "").strip() or self._selected_transport_engine_id()
if not target:
raise RuntimeError("Select a transport engine first")
if not self._select_transport_engine_by_id(target):
raise RuntimeError(f"Transport engine '{target}' not found")
ans = QMessageBox.question(
self,
"Delete transport profile",
(
f"Delete profile '{target}'?\n\n"
"The client configuration and related runtime artifacts will be removed."
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if ans != QMessageBox.Yes:
return
self.lbl_transport_engine_meta.setText(f"Engine: deleting {target}...")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
QApplication.processEvents()
res = self.ctrl.transport_client_delete_action(target, force=False, cleanup=True)
if not res.ok and "force=true" in (res.pretty_text or "").lower():
force_ans = QMessageBox.question(
self,
"Profile is referenced",
(
"This profile is referenced by current transport policy.\n"
"Force delete anyway?"
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if force_ans == QMessageBox.Yes:
res = self.ctrl.transport_client_delete_action(target, force=True, cleanup=True)
else:
self._append_transport_log(f"[engine] delete {target}: canceled by user")
self.ctrl.log_gui(f"[transport-engine] delete {target}: canceled by user")
return
line = (res.pretty_text or "").strip() or f"delete {target}"
self._append_transport_log(f"[engine] {line}")
self.ctrl.log_gui(f"[transport-engine] {line}")
if not res.ok:
raise RuntimeError(line)
self.refresh_transport_engines(silent=True)
self.refresh_status_tab()
self._safe(work, title="Transport engine delete error")
def on_transport_policy_rollback(self) -> None:
def work():
self.lbl_transport_engine_meta.setText("Engine: rollback policy...")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
QApplication.processEvents()
res = self.ctrl.transport_policy_rollback_action()
line = (res.pretty_text or "").strip() or "policy rollback"
self._append_transport_log(f"[switch] {line}")
self.ctrl.log_gui(f"[transport-switch] {line}")
if not res.ok:
raise RuntimeError(line)
self.refresh_transport_engines(silent=True)
self.refresh_status_tab()
self._safe(work, title="Transport rollback error")
def on_toggle_singbox_profile_settings(self, checked: bool = False) -> None:
if checked and self.btn_singbox_toggle_global_defaults.isChecked():
self.btn_singbox_toggle_global_defaults.setChecked(False)
self._apply_singbox_compact_visibility()
self._save_ui_preferences()
def on_toggle_singbox_global_defaults(self, checked: bool = False) -> None:
if checked and self.btn_singbox_toggle_profile_settings.isChecked():
self.btn_singbox_toggle_profile_settings.setChecked(False)
self._apply_singbox_compact_visibility()
self._save_ui_preferences()
def on_toggle_singbox_activity(self, _checked: bool = False) -> None:
self._apply_singbox_compact_visibility()
self._save_ui_preferences()
def on_singbox_profile_scope_changed(self, _state: int = 0) -> None:
self._apply_singbox_profile_controls()
self._save_ui_preferences()
self._update_transport_engine_view()
def on_singbox_global_defaults_changed(self, _index: int = 0) -> None:
self._refresh_singbox_profile_effective()
self._save_ui_preferences()
self._update_transport_engine_view()
def on_singbox_global_save(self) -> None:
def work():
self._save_ui_preferences()
route, dns, killswitch = self._effective_singbox_policy()
msg = (
"Global defaults saved: "
f"routing={self._singbox_value_label('routing', route)}, "
f"dns={self._singbox_value_label('dns', dns)}, "
f"kill-switch={self._singbox_value_label('killswitch', killswitch)}"
)
self._append_transport_log(f"[profile] {msg}")
self.ctrl.log_gui(f"[singbox-settings] {msg}")
self._safe(work, title="SingBox settings error")
def on_singbox_profile_save(self) -> None:
def work():
client, eid, pid = self._selected_singbox_profile_context()
self._save_ui_preferences()
self.lbl_transport_engine_meta.setText(f"Engine: saving draft for {pid}...")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
QApplication.processEvents()
saved = self._save_singbox_editor_draft(client, profile_id=pid)
save_line = (saved.pretty_text or "").strip() or f"save profile {pid}"
self._append_transport_log(f"[profile] {save_line}")
self.ctrl.log_gui(f"[singbox-profile] {save_line}")
route, dns, killswitch = self._effective_singbox_policy()
msg = (
f"profile settings saved for {eid}: "
f"routing={self._singbox_value_label('routing', route)}, "
f"dns={self._singbox_value_label('dns', dns)}, "
f"kill-switch={self._singbox_value_label('killswitch', killswitch)}"
)
self._append_transport_log(f"[profile] {msg}")
self.ctrl.log_gui(f"[singbox-profile] {msg}")
self.lbl_transport_engine_meta.setText(f"Engine: profile {pid} draft saved")
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
self.refresh_transport_engines(silent=True)
self._safe(work, title="SingBox profile save error")
def _selected_singbox_profile_context(self):
client = self._selected_transport_client()
eid = self._selected_transport_engine_id()
pid = self._selected_singbox_profile_id()
if not eid or client is None:
raise RuntimeError("Select a transport engine first")
if not pid:
raise RuntimeError("Select a SingBox profile first")
return client, eid, pid
def _run_singbox_profile_action(
self,
*,
verb: str,
runner,
refresh_status: bool = False,
sync_draft: bool = False,
) -> None:
client, eid, pid = self._selected_singbox_profile_context()
if sync_draft:
self.lbl_transport_engine_meta.setText(f"Engine: syncing draft for {pid}...")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
QApplication.processEvents()
saved = self._save_singbox_editor_draft(client, profile_id=pid)
save_line = (saved.pretty_text or "").strip() or f"save profile {pid}"
self._append_transport_log(f"[profile] {save_line}")
self.ctrl.log_gui(f"[singbox-profile] {save_line}")
self.lbl_transport_engine_meta.setText(f"Engine: {verb} profile {pid}...")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
QApplication.processEvents()
res = runner(client, eid, pid)
line = (res.pretty_text or "").strip() or f"{verb} profile {pid}"
self._append_transport_log(f"[profile] {line}")
self.ctrl.log_gui(f"[singbox-profile] {line}")
if res.ok:
self.lbl_transport_engine_meta.setText(f"Engine: profile {pid} {verb} done")
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
else:
self.lbl_transport_engine_meta.setText(f"Engine: profile {pid} {verb} failed")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
self.refresh_transport_engines(silent=True)
if refresh_status:
self.refresh_status_tab()
def on_singbox_profile_preview(self) -> None:
self._safe(
lambda: self._run_singbox_profile_action(
verb="previewing",
runner=lambda client, _eid, pid: self.ctrl.singbox_profile_render_preview_action(
pid,
check_binary=None,
persist=False,
client=client,
),
refresh_status=False,
sync_draft=True,
),
title="SingBox profile preview error",
)
def on_singbox_profile_validate(self) -> None:
self._safe(
lambda: self._run_singbox_profile_action(
verb="validating",
runner=lambda client, _eid, pid: self.ctrl.singbox_profile_validate_action(
pid,
client=client,
),
refresh_status=False,
sync_draft=True,
),
title="SingBox profile validate error",
)
def on_singbox_profile_apply(self) -> None:
self._safe(
lambda: self._run_singbox_profile_action(
verb="applying",
runner=lambda client, eid, pid: self.ctrl.singbox_profile_apply_action(
pid,
client_id=eid,
restart=True,
skip_runtime=False,
client=client,
),
refresh_status=True,
sync_draft=True,
),
title="SingBox profile apply error",
)
def on_singbox_profile_rollback(self) -> None:
self._safe(
lambda: self._run_singbox_profile_action(
verb="rolling back",
runner=lambda client, eid, pid: self.ctrl.singbox_profile_rollback_action(
pid,
client_id=eid,
restart=True,
skip_runtime=False,
client=client,
),
refresh_status=True,
),
title="SingBox profile rollback error",
)
def on_singbox_profile_history(self) -> None:
def work():
client, _eid, pid = self._selected_singbox_profile_context()
self.lbl_transport_engine_meta.setText(f"Engine: loading history for {pid}...")
self.lbl_transport_engine_meta.setStyleSheet("color: orange;")
QApplication.processEvents()
lines = self.ctrl.singbox_profile_history_lines(pid, limit=20, client=client)
if not lines:
line = f"history profile {pid}: no entries"
self._append_transport_log(f"[history] {line}")
self.ctrl.log_gui(f"[singbox-profile] {line}")
self.lbl_transport_engine_meta.setText(f"Engine: history {pid} is empty")
self.lbl_transport_engine_meta.setStyleSheet("color: gray;")
return
header = f"history profile {pid}: {len(lines)} entries"
self._append_transport_log(f"[history] {header}")
self.ctrl.log_gui(f"[singbox-profile] {header}")
for ln in lines:
self._append_transport_log(f"[history] {ln}")
self.lbl_transport_engine_meta.setText(f"Engine: history loaded for {pid}")
self.lbl_transport_engine_meta.setStyleSheet("color: green;")
self._safe(work, title="SingBox profile history error")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from main_window.singbox import (
SingBoxCardsMixin,
SingBoxEditorMixin,
SingBoxLinksMixin,
SingBoxRuntimeMixin,
)
class SingBoxMainWindowMixin(
SingBoxRuntimeMixin,
SingBoxLinksMixin,
SingBoxCardsMixin,
SingBoxEditorMixin,
):
"""Facade mixin for backward-compatible MainWindow inheritance."""
__all__ = ["SingBoxMainWindowMixin"]

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import re
from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QMessageBox, QPlainTextEdit
from main_window.constants import _NEXT_CHECK_RE
class UIHelpersMixin:
def _safe(self, fn, *, title: str = "Error"):
try:
return fn()
except Exception as e: # pragma: no cover - GUI
try:
self.ctrl.log_gui(f"[ui-error] {title}: {e}")
except Exception:
pass
QMessageBox.critical(self, title, str(e))
return None
def _set_text(self, widget: QPlainTextEdit, text: str, *, preserve_scroll: bool = False) -> None:
"""Set text, optionally сохраняя положение скролла (для trace)."""
if not preserve_scroll:
widget.setPlainText(text)
return
sb = widget.verticalScrollBar()
old_max = sb.maximum()
old_val = sb.value()
at_end = old_val >= old_max - 2
widget.setPlainText(text)
new_max = sb.maximum()
if at_end:
sb.setValue(new_max)
else:
# подвинем на ту же относительную позицию, учитывая прирост размера
sb.setValue(max(0, min(new_max, old_val+(new_max-old_max))))
def _append_text(self, widget: QPlainTextEdit, text: str) -> None:
cursor = widget.textCursor()
cursor.movePosition(QTextCursor.End)
cursor.insertText(text)
widget.setTextCursor(cursor)
widget.ensureCursorVisible()
def _clean_ui_lines(self, lines) -> str:
buf = "\n".join([str(x) for x in (lines or [])]).replace("\r", "\n")
out_lines = []
for ln in buf.splitlines():
t = ln.strip()
if not t:
continue
t2 = _NEXT_CHECK_RE.sub("", t).strip()
if not t2:
continue
out_lines.append(t2)
return "\n".join(out_lines).rstrip()

View File

@@ -0,0 +1,448 @@
from __future__ import annotations
import re
import time
from PySide6 import QtCore
from PySide6.QtCore import QTimer, Qt
from PySide6.QtWidgets import QMessageBox
from api_client import ApiError
from main_window.constants import LOCATION_TARGET_ROLE
from netns_debug import singbox_clients_netns_state, singbox_netns_toggle_button
class UILocationRuntimeMixin:
def eventFilter(self, obj, event): # pragma: no cover - GUI
cmb = getattr(self, "cmb_locations", None)
try:
view = cmb.view() if cmb is not None else None
except RuntimeError:
return super().eventFilter(obj, event)
if obj in (cmb, view):
if event.type() == QtCore.QEvent.KeyPress:
if self._handle_location_keypress(event):
return True
return super().eventFilter(obj, event)
def _handle_location_keypress(self, event) -> bool:
key = int(event.key())
if key == int(Qt.Key_Backspace):
if self._loc_typeahead_buf:
self._loc_typeahead_buf = self._loc_typeahead_buf[:-1]
self._apply_location_search_filter()
self.loc_typeahead_timer.start()
self.cmb_locations.showPopup()
return True
if key == int(Qt.Key_Escape):
self._reset_location_typeahead()
return True
text = event.text() or ""
if len(text) != 1 or not text.isprintable() or text.isspace():
return False
self._loc_typeahead_buf += text.lower()
count = self._apply_location_search_filter()
if count == 0 and len(self._loc_typeahead_buf) > 1:
self._loc_typeahead_buf = text.lower()
self._apply_location_search_filter()
self.loc_typeahead_timer.start()
self.cmb_locations.showPopup()
return True
def _apply_location_search_filter(self) -> int:
source = list(self._all_locations or [])
query = (self._loc_typeahead_buf or "").strip().lower()
if not source:
self._set_locations_combo_items([])
return 0
items = source
if query:
items = [
row
for row in source
if self._location_matches(query, row[0], row[1], row[2], row[3])
]
items = self._sort_location_items(items)
self._set_locations_combo_items(items)
return len(items)
def _location_matches(
self,
query: str,
label: str,
iso: str,
target: str,
name: str,
) -> bool:
q = (query or "").strip().lower()
if not q:
return True
iso_l = (iso or "").strip().lower()
label_l = (label or "").strip().lower()
target_l = (target or "").strip().lower()
name_l = (name or "").strip().lower()
if iso_l.startswith(q):
return True
if target_l.startswith(q) or label_l.startswith(q) or name_l.startswith(q):
return True
tokens = [t for t in re.split(r"[^\w]+", f"{target_l} {name_l} {label_l}") if t]
if any(tok.startswith(q) for tok in tokens):
return True
return q in target_l or q in name_l or q in label_l
def _sort_location_items(
self,
items: list[tuple[str, str, str, str, int]],
) -> list[tuple[str, str, str, str, int]]:
mode = str(self.cmb_locations_sort.currentData() or "ping").strip().lower()
if mode == "ping_desc":
return sorted(items, key=lambda x: (-x[4], x[3].lower(), x[0].lower()))
if mode == "name":
return sorted(items, key=lambda x: (x[3].lower(), x[4], x[0].lower()))
if mode == "name_desc":
return sorted(items, key=lambda x: (x[3].lower(), x[4], x[0].lower()), reverse=True)
return sorted(items, key=lambda x: (x[4], x[3].lower(), x[0].lower()))
def _set_locations_combo_items(self, items: list[tuple[str, str, str, str, int]]) -> None:
prev_target = str(self.cmb_locations.currentData(LOCATION_TARGET_ROLE) or "").strip()
prev_iso = str(self.cmb_locations.currentData() or "").strip().upper()
desired = (self._vpn_desired_location or "").strip()
desired_l = desired.lower()
self.cmb_locations.blockSignals(True)
self.cmb_locations.clear()
pick = -1
for i, (label, iso, target, _name, _ping) in enumerate(items):
self.cmb_locations.addItem(label, iso)
self.cmb_locations.setItemData(i, target, LOCATION_TARGET_ROLE)
iso_l = (iso or "").strip().lower()
target_l = (target or "").strip().lower()
if desired_l and desired_l in (iso_l, target_l):
pick = i
if pick < 0 and prev_target and prev_target == target:
pick = i
if pick < 0 and prev_iso and prev_iso == iso:
pick = i
if self.cmb_locations.count() > 0:
if pick < 0:
pick = 0
self.cmb_locations.setCurrentIndex(pick)
model_index = self.cmb_locations.model().index(pick, 0)
self.cmb_locations.view().setCurrentIndex(model_index)
self.cmb_locations.blockSignals(False)
def _reset_location_typeahead(self) -> None:
self._loc_typeahead_buf = ""
self._apply_location_search_filter()
def _location_name_ping(self, label: str, iso: str, target: str) -> tuple[str, int]:
text = (label or "").strip()
ping = 1_000_000
m = re.search(r"\((\d+)\s*ms\)\s*$", text, flags=re.IGNORECASE)
if m:
try:
ping = int(m.group(1))
except Exception:
ping = 1_000_000
text = text[:m.start()].strip()
iso_pref = (iso or "").strip().upper()
pref = iso_pref + " "
if iso_pref and text.upper().startswith(pref):
text = text[len(pref):].strip()
name = text or (target or iso_pref or "").strip()
return name, ping
def on_locations_sort_changed(self, _index: int = 0) -> None:
self._apply_location_search_filter()
self._save_ui_preferences()
def on_locations_refresh_click(self) -> None:
self._safe(self._trigger_locations_refresh, title="Locations refresh error")
def _trigger_locations_refresh(self) -> None:
self.lbl_locations_meta.setText("Locations: refreshing...")
self.lbl_locations_meta.setStyleSheet("color: orange;")
self._refresh_locations_async(force_refresh=True)
def _append_transport_log(self, line: str) -> None:
msg = (line or "").strip()
if not msg:
return
self._append_text(self.txt_transport, msg + "\n")
def _singbox_clients_netns_state(self) -> tuple[bool, bool]:
return singbox_clients_netns_state(list(self._transport_clients or []))
def _refresh_transport_netns_toggle_button(self) -> None:
all_enabled, any_enabled = self._singbox_clients_netns_state()
text, color = singbox_netns_toggle_button(all_enabled, any_enabled)
self.btn_transport_netns_toggle.setText(text)
self.btn_transport_netns_toggle.setStyleSheet(f"color: {color};")
def _selected_transport_engine_id(self) -> str:
return str(self.cmb_transport_engine.currentData() or "").strip()
def _selected_transport_client(self):
cid = self._selected_transport_engine_id()
if not cid:
return None
for client in self._transport_clients or []:
if str(getattr(client, "id", "") or "").strip() == cid:
return client
return None
def _transport_live_health_for_client(self, client) -> tuple[str, int, str, str]:
status = str(getattr(client, "status", "") or "").strip().lower() or "unknown"
latency = int(getattr(getattr(client, "health", None), "latency_ms", 0) or 0)
last_error = str(getattr(getattr(client, "health", None), "last_error", "") or "").strip()
last_check = str(getattr(getattr(client, "health", None), "last_check", "") or "").strip()
cid = str(getattr(client, "id", "") or "").strip()
if not cid:
return status, latency, last_error, last_check
snap = self._transport_health_live.get(cid)
if not isinstance(snap, dict):
return status, latency, last_error, last_check
snap_status = str(snap.get("status") or "").strip().lower()
if snap_status:
status = snap_status
try:
snap_latency = int(snap.get("latency_ms") or 0)
if snap_latency >= 0:
latency = snap_latency
except Exception:
pass
snap_err = str(snap.get("last_error") or "").strip()
if snap_err:
last_error = snap_err
snap_check = str(snap.get("last_check") or "").strip()
if snap_check:
last_check = snap_check
return status, latency, last_error, last_check
def _country_flag(self, country_code: str) -> str:
cc = str(country_code or "").strip().upper()
if len(cc) != 2 or not cc.isalpha():
return ""
try:
return "".join(chr(127397 + ord(ch)) for ch in cc)
except Exception:
return ""
def _refresh_egress_identity_scope(
self,
scope: str,
*,
force: bool = False,
trigger_refresh: bool = True,
min_interval_sec: float = 1.0,
silent: bool = True,
):
scope_key = str(scope or "").strip().lower()
if not scope_key:
return None
now = time.monotonic()
last = float(self._egress_identity_last_probe_ts.get(scope_key, 0.0) or 0.0)
if not force and (now - last) < max(0.2, float(min_interval_sec)):
return self._egress_identity_cache.get(scope_key)
self._egress_identity_last_probe_ts[scope_key] = now
try:
item = self.ctrl.egress_identity(scope_key, refresh=trigger_refresh)
self._egress_identity_cache[scope_key] = item
return item
except ApiError as e:
code = int(getattr(e, "status_code", 0) or 0)
if not silent and code != 404:
QMessageBox.warning(self, "Egress identity error", str(e))
return self._egress_identity_cache.get(scope_key)
except Exception as e:
if not silent:
QMessageBox.warning(self, "Egress identity error", str(e))
return self._egress_identity_cache.get(scope_key)
def _format_egress_identity_short(self, item) -> str:
if item is None:
return ""
ip = str(getattr(item, "ip", "") or "").strip()
if not ip:
return ""
code = str(getattr(item, "country_code", "") or "").strip().upper()
flag = self._country_flag(code)
if flag:
return f"{flag} {ip}"
return ip
def _render_vpn_egress_label(self, item) -> None:
if item is None:
self.lbl_vpn_egress.setText("Egress: n/a")
self.lbl_vpn_egress.setStyleSheet("color: gray;")
return
ip = str(getattr(item, "ip", "") or "").strip()
code = str(getattr(item, "country_code", "") or "").strip().upper()
name = str(getattr(item, "country_name", "") or "").strip()
stale = bool(getattr(item, "stale", False))
refreshing = bool(getattr(item, "refresh_in_progress", False))
last_error = str(getattr(item, "last_error", "") or "").strip()
if not ip:
if refreshing:
self.lbl_vpn_egress.setText("Egress: refreshing...")
self.lbl_vpn_egress.setStyleSheet("color: orange;")
return
if last_error:
cut = last_error if len(last_error) <= 120 else last_error[:117] + "..."
self.lbl_vpn_egress.setText(f"Egress: n/a ({cut})")
self.lbl_vpn_egress.setStyleSheet("color: red;")
return
self.lbl_vpn_egress.setText("Egress: n/a")
self.lbl_vpn_egress.setStyleSheet("color: gray;")
return
flag = self._country_flag(code)
prefix = f"{flag} {ip}" if flag else ip
tail = ""
if name:
tail = f" ({name})"
elif code:
tail = f" ({code})"
if stale:
tail += " · stale"
self.lbl_vpn_egress.setText(f"Egress: {prefix}{tail}")
self.lbl_vpn_egress.setStyleSheet("color: orange;" if stale else "color: #1f6b2f;")
def _poll_vpn_egress_after_switch(self, token: int, attempts_left: int) -> None:
if token != self._vpn_egress_refresh_token:
return
item = self._refresh_egress_identity_scope(
"adguardvpn",
force=True,
trigger_refresh=False,
min_interval_sec=0.0,
silent=True,
)
self._render_vpn_egress_label(item)
if token != self._vpn_egress_refresh_token:
return
refresh_in_progress = bool(getattr(item, "refresh_in_progress", False)) if item is not None else True
has_ip = bool(str(getattr(item, "ip", "") or "").strip()) if item is not None else False
has_country = bool(
str(getattr(item, "country_code", "") or "").strip()
or str(getattr(item, "country_name", "") or "").strip()
) if item is not None else False
if attempts_left <= 0:
return
if has_ip and has_country and not refresh_in_progress and not self._vpn_switching_active:
return
delay_ms = 450 if attempts_left > 3 else 900
QTimer.singleShot(
delay_ms,
lambda tok=token, left=attempts_left - 1: self._poll_vpn_egress_after_switch(tok, left),
)
def _trigger_vpn_egress_refresh(self, *, reason: str = "") -> None:
scope = "adguardvpn"
self._vpn_egress_refresh_token += 1
token = self._vpn_egress_refresh_token
self._egress_identity_last_probe_ts[scope] = 0.0
self._vpn_autoloop_refresh_pending = False
self._vpn_autoloop_last_force_refresh_ts = time.monotonic()
self.lbl_vpn_egress.setText("Egress: refreshing...")
self.lbl_vpn_egress.setStyleSheet("color: orange;")
try:
self.ctrl.egress_identity_refresh(scopes=[scope], force=True)
except Exception:
pass
if reason:
try:
self.ctrl.log_gui(f"[egress] force refresh: {reason}")
except Exception:
pass
self._poll_vpn_egress_after_switch(token, attempts_left=14)
def _normalize_vpn_autoloop_state(self, unit_text: str) -> str:
low = str(unit_text or "").strip().lower()
if ":" in low:
low = low.split(":", 1)[1].strip()
if "reconnect" in low:
return "reconnecting"
if "disconnected" in low or "inactive" in low:
return "down"
if "failed" in low or "error" in low or "dead" in low:
return "down"
if "connected" in low:
return "connected"
if "active" in low or "running" in low or "enabled" in low or "up" in low:
return "connected"
return "unknown"
def _maybe_trigger_vpn_egress_refresh_on_autoloop(self, unit_text: str) -> None:
state = self._normalize_vpn_autoloop_state(unit_text)
prev = str(self._vpn_autoloop_last_state or "").strip().lower()
now = time.monotonic()
if state in ("down", "reconnecting", "unknown"):
self._vpn_autoloop_refresh_pending = True
if (
state == "connected"
and self._vpn_autoloop_refresh_pending
and not self._vpn_switching_active
and (now - float(self._vpn_autoloop_last_force_refresh_ts or 0.0)) >= 1.0
):
self._trigger_vpn_egress_refresh(reason=f"autoloop {prev or 'unknown'} -> connected")
self._vpn_autoloop_last_state = state
def _refresh_selected_transport_health_live(
self,
*,
force: bool = False,
min_interval_sec: float = 0.8,
silent: bool = True,
) -> bool:
if not self._transport_api_supported:
return False
cid = self._selected_transport_engine_id()
if not cid:
return False
now = time.monotonic()
if not force and (now - self._transport_health_last_probe_ts) < max(0.2, float(min_interval_sec)):
return False
self._transport_health_last_probe_ts = now
try:
snap = self.ctrl.transport_client_health(cid)
except ApiError as e:
if not silent and int(getattr(e, "status_code", 0) or 0) != 404:
QMessageBox.warning(self, "Transport health error", str(e))
return False
except Exception as e:
if not silent:
QMessageBox.warning(self, "Transport health error", str(e))
return False
self._transport_health_live[cid] = {
"status": str(getattr(snap, "status", "") or "").strip().lower(),
"latency_ms": int(getattr(snap, "latency_ms", 0) or 0),
"last_error": str(getattr(snap, "last_error", "") or "").strip(),
"last_check": str(getattr(snap, "last_check", "") or "").strip(),
}
self._render_singbox_profile_cards()
self._sync_singbox_profile_card_selection(cid)
self._update_transport_engine_view()

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from main_window.ui_helpers_mixin import UIHelpersMixin
from main_window.ui_location_runtime_mixin import UILocationRuntimeMixin
from main_window.ui_tabs_mixin import UITabsMixin
class MainWindowUIShellMixin(
UILocationRuntimeMixin,
UIHelpersMixin,
UITabsMixin,
):
"""Facade mixin for backward-compatible MainWindow inheritance."""
__all__ = ["MainWindowUIShellMixin"]

View File

@@ -0,0 +1,244 @@
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPlainTextEdit,
QListView,
QPushButton,
QStackedWidget,
QStyle,
QTabWidget,
QToolButton,
QVBoxLayout,
QWidget,
QComboBox,
)
class UITabsMainMixin:
def _build_ui(self) -> None:
root = QWidget()
root_layout = QVBoxLayout(root)
root.setLayout(root_layout)
self.setCentralWidget(root)
# top bar ---------------------------------------------------------
top = QHBoxLayout()
root_layout.addLayout(top)
# клик по этому баннеру показывает whoami
self.btn_login_banner = QPushButton("AdGuard VPN: —")
self.btn_login_banner.setFlat(True)
self.btn_login_banner.setStyleSheet(
"text-align: left; border: none; color: gray;"
)
self.btn_login_banner.clicked.connect(self.on_login_banner_clicked)
top.addWidget(self.btn_login_banner, stretch=1)
self.btn_auth = QPushButton("Login")
self.btn_auth.clicked.connect(self.on_auth_button)
top.addWidget(self.btn_auth)
self.btn_refresh_all = QPushButton("Refresh all")
self.btn_refresh_all.clicked.connect(self.refresh_everything)
top.addWidget(self.btn_refresh_all)
# tabs -------------------------------------------------------------
self.tabs = QTabWidget()
root_layout.addWidget(self.tabs, stretch=1)
self._build_tab_status()
self._build_tab_vpn()
self._build_tab_singbox()
self._build_tab_multiif()
self._build_tab_routes()
self._build_tab_dns()
self._build_tab_domains()
self._build_tab_trace()
# ---------------- STATUS TAB ----------------
def _build_tab_status(self) -> None:
tab = QWidget()
layout = QVBoxLayout(tab)
grid = QFormLayout()
layout.addLayout(grid)
self.st_timestamp = QLabel("")
self.st_counts = QLabel("")
self.st_iface = QLabel("")
self.st_route = QLabel("")
self.st_routes_service = QLabel("")
self.st_smartdns_service = QLabel("")
self.st_vpn_service = QLabel("")
grid.addRow("Timestamp:", self.st_timestamp)
grid.addRow("Counts:", self.st_counts)
grid.addRow("Iface / table / mark:", self.st_iface)
grid.addRow("Policy route:", self.st_route)
grid.addRow("Routes service:", self.st_routes_service)
grid.addRow("SmartDNS:", self.st_smartdns_service)
grid.addRow("VPN service:", self.st_vpn_service)
btns = QHBoxLayout()
layout.addLayout(btns)
btn_refresh = QPushButton("Refresh")
btn_refresh.clicked.connect(self.refresh_status_tab)
btns.addWidget(btn_refresh)
btns.addStretch(1)
self.tabs.addTab(tab, "Status")
# ---------------- VPN TAB ----------------
def _build_tab_vpn(self) -> None:
tab = QWidget()
self.tab_vpn = tab # нужно, чтобы переключаться сюда из шапки
layout = QVBoxLayout(tab)
# stack: main vs login-flow page
self.vpn_stack = QStackedWidget()
layout.addWidget(self.vpn_stack, stretch=1)
# ---- main page
page_main = QWidget()
main_layout = QVBoxLayout(page_main)
# Autoconnect group
auto_group = QGroupBox("Autoconnect (AdGuardVPN autoloop)")
auto_layout = QHBoxLayout(auto_group)
self.btn_autoconnect_toggle = QPushButton("Enable autoconnect")
self.btn_autoconnect_toggle.clicked.connect(self.on_toggle_autoconnect)
auto_layout.addWidget(self.btn_autoconnect_toggle)
auto_layout.addStretch(1)
# справа текст "unit: active/inactive" с цветом
self.lbl_autoconnect_state = QLabel("unit: —")
self.lbl_autoconnect_state.setStyleSheet("color: gray;")
auto_layout.addWidget(self.lbl_autoconnect_state)
main_layout.addWidget(auto_group)
# Locations group
loc_group = QGroupBox("Location")
loc_layout = QVBoxLayout(loc_group)
loc_row = QHBoxLayout()
loc_layout.addLayout(loc_row)
self.cmb_locations = QComboBox()
# компактный popup со скроллом, а не на весь экран
self.cmb_locations.setMaxVisibleItems(12)
self.cmb_locations.setStyleSheet("combobox-popup: 0;")
self.cmb_locations.setFocusPolicy(Qt.StrongFocus)
view = QListView()
view.setUniformItemSizes(True)
self.cmb_locations.setView(view)
self.cmb_locations.activated.connect(self.on_location_activated)
self.cmb_locations.installEventFilter(self)
view.installEventFilter(self)
loc_row.addWidget(self.cmb_locations, stretch=1)
self.cmb_locations_sort = QComboBox()
self.cmb_locations_sort.addItem("Sort: Ping", "ping")
self.cmb_locations_sort.addItem("Sort: Ping (slow first)", "ping_desc")
self.cmb_locations_sort.addItem("Sort: Name", "name")
self.cmb_locations_sort.addItem("Sort: Name (Z-A)", "name_desc")
self.cmb_locations_sort.currentIndexChanged.connect(
self.on_locations_sort_changed
)
loc_row.addWidget(self.cmb_locations_sort)
self.btn_locations_refresh = QToolButton()
self.btn_locations_refresh.setAutoRaise(True)
self.btn_locations_refresh.setIcon(
self.style().standardIcon(QStyle.SP_BrowserReload)
)
self.btn_locations_refresh.setToolTip("Refresh locations now")
self.btn_locations_refresh.setCursor(Qt.PointingHandCursor)
self.btn_locations_refresh.setFocusPolicy(Qt.NoFocus)
self.btn_locations_refresh.clicked.connect(self.on_locations_refresh_click)
loc_row.addWidget(self.btn_locations_refresh)
self.lbl_locations_meta = QLabel("Locations: loading...")
self.lbl_locations_meta.setStyleSheet("color: gray;")
loc_layout.addWidget(self.lbl_locations_meta)
self.lbl_vpn_egress = QLabel("Egress: n/a")
self.lbl_vpn_egress.setStyleSheet("color: gray;")
loc_layout.addWidget(self.lbl_vpn_egress)
main_layout.addWidget(loc_group)
# Status output
self.txt_vpn = QPlainTextEdit()
self.txt_vpn.setReadOnly(True)
main_layout.addWidget(self.txt_vpn, stretch=1)
self.vpn_stack.addWidget(page_main)
# ---- login page
page_login = QWidget()
lf_layout = QVBoxLayout(page_login)
top = QHBoxLayout()
lf_layout.addLayout(top)
self.lbl_login_flow_status = QLabel("Status: —")
top.addWidget(self.lbl_login_flow_status)
self.lbl_login_flow_email = QLabel("")
self.lbl_login_flow_email.setStyleSheet("color: gray;")
top.addWidget(self.lbl_login_flow_email)
top.addStretch(1)
# URL + buttons row
row2 = QHBoxLayout()
lf_layout.addLayout(row2)
row2.addWidget(QLabel("URL:"))
self.edit_login_url = QLineEdit()
row2.addWidget(self.edit_login_url, stretch=1)
self.btn_login_open = QPushButton("Open")
self.btn_login_open.clicked.connect(self.on_login_open)
row2.addWidget(self.btn_login_open)
self.btn_login_copy = QPushButton("Copy")
self.btn_login_copy.clicked.connect(self.on_login_copy)
row2.addWidget(self.btn_login_copy)
self.btn_login_check = QPushButton("Check")
self.btn_login_check.clicked.connect(self.on_login_check)
row2.addWidget(self.btn_login_check)
self.btn_login_close = QPushButton("Cancel")
self.btn_login_close.clicked.connect(self.on_login_cancel)
row2.addWidget(self.btn_login_close)
self.btn_login_stop = QPushButton("Stop session")
self.btn_login_stop.clicked.connect(self.on_login_stop)
row2.addWidget(self.btn_login_stop)
# log text
self.txt_login_flow = QPlainTextEdit()
self.txt_login_flow.setReadOnly(True)
lf_layout.addWidget(self.txt_login_flow, stretch=1)
# bottom buttons
bottom = QHBoxLayout()
lf_layout.addLayout(bottom)
# Start login визуально убираем, но объект оставим на всякий
self.btn_login_start = QPushButton("Start login")
self.btn_login_start.clicked.connect(self.on_start_login)
self.btn_login_start.setVisible(False)
bottom.addWidget(self.btn_login_start)
btn_back = QPushButton("Back to VPN")
btn_back.clicked.connect(lambda: self._show_vpn_page("main"))
bottom.addWidget(btn_back)
bottom.addStretch(1)
self.vpn_stack.addWidget(page_login)
self.tabs.addTab(tab, "AdGuardVPN")

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from main_window.ui_tabs_main_mixin import UITabsMainMixin
from main_window.ui_tabs_other_mixin import UITabsOtherMixin
from main_window.ui_tabs_singbox_mixin import UITabsSingBoxMixin
class UITabsMixin(
UITabsOtherMixin,
UITabsSingBoxMixin,
UITabsMainMixin,
):
"""Facade mixin for MainWindow tab builders."""
__all__ = ["UITabsMixin"]

View File

@@ -0,0 +1,305 @@
from __future__ import annotations
from PySide6.QtWidgets import (
QCheckBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPlainTextEdit,
QPushButton,
QProgressBar,
QRadioButton,
QVBoxLayout,
QWidget,
)
class UITabsOtherMixin:
def _build_tab_routes(self) -> None:
tab = QWidget()
layout = QVBoxLayout(tab)
# --- Service actions ---
act_group = QGroupBox("Selective routes service")
act_layout = QHBoxLayout(act_group)
self.btn_routes_start = QPushButton("Start")
self.btn_routes_start.clicked.connect(
lambda: self.on_routes_action("start")
)
self.btn_routes_restart = QPushButton("Restart")
self.btn_routes_restart.clicked.connect(
lambda: self.on_routes_action("restart")
)
self.btn_routes_stop = QPushButton("Stop")
self.btn_routes_stop.clicked.connect(
lambda: self.on_routes_action("stop")
)
act_layout.addWidget(self.btn_routes_start)
act_layout.addWidget(self.btn_routes_restart)
act_layout.addWidget(self.btn_routes_stop)
act_layout.addStretch(1)
layout.addWidget(act_group)
# --- Timer / policy route ---
timer_group = QGroupBox("Timer")
timer_layout = QHBoxLayout(timer_group)
self.chk_timer = QCheckBox("Enable timer")
self.chk_timer.stateChanged.connect(self.on_toggle_timer)
timer_layout.addWidget(self.chk_timer)
self.btn_fix_policy = QPushButton("Fix policy route")
self.btn_fix_policy.clicked.connect(self.on_fix_policy_route)
timer_layout.addWidget(self.btn_fix_policy)
timer_layout.addStretch(1)
layout.addWidget(timer_group)
# --- Traffic mode relay ---
traffic_group = QGroupBox("Traffic mode relay")
traffic_layout = QVBoxLayout(traffic_group)
relay_row = QHBoxLayout()
self.btn_traffic_settings = QPushButton("Open traffic settings")
self.btn_traffic_settings.clicked.connect(self.on_open_traffic_settings)
relay_row.addWidget(self.btn_traffic_settings)
self.btn_traffic_test = QPushButton("Test mode")
self.btn_traffic_test.clicked.connect(self.on_test_traffic_mode)
relay_row.addWidget(self.btn_traffic_test)
self.btn_routes_prewarm = QPushButton("Prewarm wildcard now")
self.btn_routes_prewarm.setToolTip("""EN: Sends DNS queries for wildcard domains to prefill agvpn_dyn4 before traffic arrives.
RU: Делает DNS-запросы wildcard-доменов, чтобы заранее наполнить agvpn_dyn4.""")
self.btn_routes_prewarm.clicked.connect(self.on_smartdns_prewarm)
relay_row.addWidget(self.btn_routes_prewarm)
self.btn_routes_precheck_debug = QPushButton("Debug precheck now")
self.btn_routes_precheck_debug.setToolTip("""EN: Debug helper. Arms one-shot resolver precheck and requests routes restart now.
RU: Отладочный helper. Включает one-shot precheck резолвера и запрашивает restart routes.""")
self.btn_routes_precheck_debug.clicked.connect(self.on_routes_precheck_debug)
relay_row.addWidget(self.btn_routes_precheck_debug)
relay_row.addStretch(1)
traffic_layout.addLayout(relay_row)
self.chk_routes_prewarm_aggressive = QCheckBox("Aggressive prewarm (use subs)")
self.chk_routes_prewarm_aggressive.setToolTip("""EN: Aggressive mode also queries subs list. This can increase DNS load.
RU: Агрессивный режим дополнительно дергает subs список. Может увеличить нагрузку на DNS.""")
self.chk_routes_prewarm_aggressive.stateChanged.connect(self._on_prewarm_aggressive_changed)
traffic_layout.addWidget(self.chk_routes_prewarm_aggressive)
self.lbl_routes_prewarm_mode = QLabel("Prewarm mode: wildcard-only")
self.lbl_routes_prewarm_mode.setStyleSheet("color: gray;")
traffic_layout.addWidget(self.lbl_routes_prewarm_mode)
self._update_prewarm_mode_label()
self.lbl_traffic_mode_state = QLabel("Traffic mode: —")
self.lbl_traffic_mode_state.setStyleSheet("color: gray;")
traffic_layout.addWidget(self.lbl_traffic_mode_state)
self.lbl_traffic_mode_diag = QLabel("")
self.lbl_traffic_mode_diag.setStyleSheet("color: gray;")
traffic_layout.addWidget(self.lbl_traffic_mode_diag)
self.lbl_routes_resolve_summary = QLabel("Resolve summary: —")
self.lbl_routes_resolve_summary.setToolTip("""EN: Parsed from latest 'resolve summary' trace line.
RU: Берется из последней строки 'resolve summary' в trace.""")
self.lbl_routes_resolve_summary.setStyleSheet("color: gray;")
traffic_layout.addWidget(self.lbl_routes_resolve_summary)
self.lbl_routes_recheck_summary = QLabel("Timeout recheck: —")
self.lbl_routes_recheck_summary.setToolTip("""EN: Hidden timeout-recheck counters included in resolve summary.
RU: Счетчики скрытого timeout-recheck из итогового resolve summary.""")
self.lbl_routes_recheck_summary.setStyleSheet("color: gray;")
traffic_layout.addWidget(self.lbl_routes_recheck_summary)
layout.addWidget(traffic_group)
# --- NFT progress (agvpn4) ---
progress_row = QHBoxLayout()
self.routes_progress = QProgressBar()
self.routes_progress.setRange(0, 100)
self.routes_progress.setValue(0)
self.routes_progress.setFormat("") # текст выводим отдельным лейблом
self.routes_progress.setTextVisible(False)
self.routes_progress.setEnabled(False) # idle по умолчанию
self.lbl_routes_progress = QLabel("NFT: idle")
self.lbl_routes_progress.setStyleSheet("color: gray;")
progress_row.addWidget(self.routes_progress)
progress_row.addWidget(self.lbl_routes_progress)
layout.addLayout(progress_row)
# --- Log output ---
self.txt_routes = QPlainTextEdit()
self.txt_routes.setReadOnly(True)
layout.addWidget(self.txt_routes, stretch=1)
self.tabs.addTab(tab, "Routes")
# ---------------- DNS TAB ----------------
def _build_tab_dns(self) -> None:
tab = QWidget()
main_layout = QVBoxLayout(tab)
tip = QLabel("Tip: hover fields for help. Подсказка: наведи на элементы для описания.")
tip.setWordWrap(True)
tip.setStyleSheet("color: gray;")
main_layout.addWidget(tip)
resolver_group = QGroupBox("Resolver DNS")
resolver_group.setToolTip("""EN: Compact resolver DNS status. Open benchmark to test/apply upstreams.
RU: Компактный статус DNS резолвера. Открой benchmark для проверки/применения апстримов.""")
resolver_layout = QVBoxLayout(resolver_group)
row = QHBoxLayout()
self.btn_dns_benchmark = QPushButton("Open DNS benchmark")
self.btn_dns_benchmark.clicked.connect(self.on_open_dns_benchmark)
row.addWidget(self.btn_dns_benchmark)
row.addStretch(1)
resolver_layout.addLayout(row)
self.lbl_dns_resolver_upstreams = QLabel("Resolver upstreams: default[—, —] meta[—, —]")
self.lbl_dns_resolver_upstreams.setStyleSheet("color: gray;")
resolver_layout.addWidget(self.lbl_dns_resolver_upstreams)
self.lbl_dns_resolver_health = QLabel("Resolver health: —")
self.lbl_dns_resolver_health.setStyleSheet("color: gray;")
resolver_layout.addWidget(self.lbl_dns_resolver_health)
main_layout.addWidget(resolver_group)
smart_group = QGroupBox("SmartDNS")
smart_group.setToolTip("""EN: SmartDNS is used for wildcard domains in hybrid mode.
RU: SmartDNS используется для wildcard-доменов в hybrid режиме.""")
smart_layout = QVBoxLayout(smart_group)
smart_form = QFormLayout()
self.ent_smartdns_addr = QLineEdit()
self.ent_smartdns_addr.setToolTip("""EN: SmartDNS address in host#port format (example: 127.0.0.1#6053).
RU: Адрес SmartDNS в формате host#port (пример: 127.0.0.1#6053).""")
self.ent_smartdns_addr.setPlaceholderText("127.0.0.1#6053")
self.ent_smartdns_addr.textEdited.connect(self._schedule_dns_autosave)
smart_form.addRow("SmartDNS address", self.ent_smartdns_addr)
smart_layout.addLayout(smart_form)
self.chk_dns_via_smartdns = QCheckBox("Use SmartDNS for wildcard domains")
self.chk_dns_via_smartdns.setToolTip("""EN: Hybrid wildcard mode: wildcard domains resolve via SmartDNS, other lists resolve via direct upstreams.
RU: Hybrid wildcard режим: wildcard-домены резолвятся через SmartDNS, остальные списки через direct апстримы.""")
self.chk_dns_via_smartdns.stateChanged.connect(self.on_dns_mode_toggle)
smart_layout.addWidget(self.chk_dns_via_smartdns)
self.lbl_dns_mode_state = QLabel("Resolver mode: unknown")
self.lbl_dns_mode_state.setToolTip("""EN: Current resolver mode reported by API.
RU: Текущий режим резолвера по данным API.""")
smart_layout.addWidget(self.lbl_dns_mode_state)
self.chk_dns_unit_relay = QCheckBox("SmartDNS unit relay: OFF")
self.chk_dns_unit_relay.setToolTip("""EN: Starts/stops smartdns-local.service. Service state is independent from resolver mode.
RU: Запускает/останавливает smartdns-local.service. Состояние сервиса не равно режиму резолвера.""")
self.chk_dns_unit_relay.stateChanged.connect(self.on_smartdns_unit_toggle)
smart_layout.addWidget(self.chk_dns_unit_relay)
self.chk_dns_runtime_nftset = QCheckBox("SmartDNS runtime accelerator (nftset -> agvpn_dyn4): ON")
self.chk_dns_runtime_nftset.setToolTip("""EN: Optional accelerator: SmartDNS can add resolved IPs to agvpn_dyn4 in runtime (via nftset).
EN: Wildcard still works without it (resolver job + prewarm).
RU: Опциональный ускоритель: SmartDNS может добавлять IP в agvpn_dyn4 в runtime (через nftset).
RU: Wildcard работает и без него (resolver job + prewarm).""")
self.chk_dns_runtime_nftset.stateChanged.connect(self.on_smartdns_runtime_toggle)
smart_layout.addWidget(self.chk_dns_runtime_nftset)
self.lbl_dns_wildcard_source = QLabel("Wildcard source: resolver")
self.lbl_dns_wildcard_source.setToolTip("""EN: Where wildcard IPs come from: resolver job, SmartDNS runtime nftset, or both.
RU: Источник wildcard IP: резолвер, runtime nftset SmartDNS, или оба.""")
self.lbl_dns_wildcard_source.setStyleSheet("color: gray;")
smart_layout.addWidget(self.lbl_dns_wildcard_source)
main_layout.addWidget(smart_group)
main_layout.addStretch(1)
self.tabs.addTab(tab, "DNS")
# ---------------- DOMAINS TAB ----------------
def _build_tab_domains(self) -> None:
tab = QWidget()
main_layout = QHBoxLayout(tab)
left = QVBoxLayout()
main_layout.addLayout(left)
left.addWidget(QLabel("Files:"))
self.lst_files = QListWidget()
for name in (
"bases",
"meta-special",
"subs",
"static-ips",
"last-ips-map-direct",
"last-ips-map-wildcard",
"wildcard-observed-hosts",
"smartdns.conf",
):
QListWidgetItem(name, self.lst_files)
self.lst_files.setCurrentRow(0)
self.lst_files.itemSelectionChanged.connect(self.on_domains_load)
left.addWidget(self.lst_files)
self.btn_domains_save = QPushButton("Save file")
self.btn_domains_save.clicked.connect(self.on_domains_save)
left.addWidget(self.btn_domains_save)
left.addStretch(1)
right_layout = QVBoxLayout()
main_layout.addLayout(right_layout, stretch=1)
self.lbl_domains_info = QLabel("")
self.lbl_domains_info.setStyleSheet("color: gray;")
right_layout.addWidget(self.lbl_domains_info)
self.txt_domains = QPlainTextEdit()
right_layout.addWidget(self.txt_domains, stretch=1)
self.tabs.addTab(tab, "Domains")
# ---------------- TRACE TAB ----------------
def _build_tab_trace(self) -> None:
tab = QWidget()
layout = QVBoxLayout(tab)
top = QHBoxLayout()
layout.addLayout(top)
self.radio_trace_full = QRadioButton("Full")
self.radio_trace_full.setChecked(True)
self.radio_trace_full.toggled.connect(self.refresh_trace_tab)
top.addWidget(self.radio_trace_full)
self.radio_trace_gui = QRadioButton("Events")
self.radio_trace_gui.toggled.connect(self.refresh_trace_tab)
top.addWidget(self.radio_trace_gui)
self.radio_trace_smartdns = QRadioButton("SmartDNS")
self.radio_trace_smartdns.toggled.connect(self.refresh_trace_tab)
top.addWidget(self.radio_trace_smartdns)
btn_refresh = QPushButton("Refresh")
btn_refresh.clicked.connect(self.refresh_trace_tab)
top.addWidget(btn_refresh)
top.addStretch(1)
self.txt_trace = QPlainTextEdit()
self.txt_trace.setReadOnly(True)
layout.addWidget(self.txt_trace, stretch=1)
self.tabs.addTab(tab, "Trace")

View File

@@ -0,0 +1,420 @@
from __future__ import annotations
from PySide6.QtCore import QSize
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QRadioButton,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
)
from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_EDITOR_PROTOCOL_OPTIONS
class UITabsSingBoxEditorMixin:
def _build_singbox_vless_editor(self, parent_layout: QVBoxLayout) -> None:
grp = QGroupBox("Protocol editor (client)")
self.grp_singbox_proto_editor = grp
lay = QVBoxLayout(grp)
self.lbl_singbox_proto_editor_info = QLabel(
"Client-side fields only. Server billing/traffic/expiry fields are excluded."
)
self.lbl_singbox_proto_editor_info.setStyleSheet("color: gray;")
lay.addWidget(self.lbl_singbox_proto_editor_info)
form = QFormLayout()
self.frm_singbox_proto_form = form
self.ent_singbox_proto_name = QLineEdit()
self.ent_singbox_proto_name.setPlaceholderText("Profile name")
form.addRow("Profile name:", self.ent_singbox_proto_name)
self.chk_singbox_proto_enabled = QCheckBox("Enabled")
self.chk_singbox_proto_enabled.setChecked(True)
form.addRow("Enabled:", self.chk_singbox_proto_enabled)
self.cmb_singbox_proto_protocol = QComboBox()
for label, pid in SINGBOX_EDITOR_PROTOCOL_OPTIONS:
self.cmb_singbox_proto_protocol.addItem(label, pid)
self.cmb_singbox_proto_protocol.currentIndexChanged.connect(
self.on_singbox_vless_editor_changed
)
form.addRow("Protocol:", self.cmb_singbox_proto_protocol)
self.ent_singbox_vless_server = QLineEdit()
self.ent_singbox_vless_server.setPlaceholderText("example.com")
form.addRow("Address:", self.ent_singbox_vless_server)
self.spn_singbox_vless_port = QSpinBox()
self.spn_singbox_vless_port.setRange(1, 65535)
self.spn_singbox_vless_port.setValue(443)
form.addRow("Port:", self.spn_singbox_vless_port)
self.ent_singbox_vless_uuid = QLineEdit()
self.ent_singbox_vless_uuid.setPlaceholderText("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx")
form.addRow("UUID:", self.ent_singbox_vless_uuid)
self.ent_singbox_proto_password = QLineEdit()
self.ent_singbox_proto_password.setPlaceholderText("password")
form.addRow("Password:", self.ent_singbox_proto_password)
self.cmb_singbox_vless_flow = QComboBox()
self.cmb_singbox_vless_flow.addItem("None", "")
# sing-box v1.12/v1.13 VLESS flow preset; field remains editable for custom/raw values.
self.cmb_singbox_vless_flow.addItem("xtls-rprx-vision", "xtls-rprx-vision")
self.cmb_singbox_vless_flow.setEditable(True)
self.cmb_singbox_vless_flow.setInsertPolicy(QComboBox.NoInsert)
form.addRow("Flow:", self.cmb_singbox_vless_flow)
self.cmb_singbox_vless_packet_encoding = QComboBox()
self.cmb_singbox_vless_packet_encoding.addItem("auto", "")
self.cmb_singbox_vless_packet_encoding.addItem("xudp", "xudp")
form.addRow("Packet encoding:", self.cmb_singbox_vless_packet_encoding)
self.cmb_singbox_ss_method = QComboBox()
self.cmb_singbox_ss_method.setEditable(True)
self.cmb_singbox_ss_method.setInsertPolicy(QComboBox.NoInsert)
for method in (
"aes-128-gcm",
"aes-256-gcm",
"chacha20-ietf-poly1305",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"none",
):
self.cmb_singbox_ss_method.addItem(method, method)
form.addRow("SS method:", self.cmb_singbox_ss_method)
self.ent_singbox_ss_plugin = QLineEdit()
self.ent_singbox_ss_plugin.setPlaceholderText("obfs-local;obfs=http;obfs-host=example.com")
form.addRow("SS plugin:", self.ent_singbox_ss_plugin)
self.spn_singbox_hy2_up_mbps = QSpinBox()
self.spn_singbox_hy2_up_mbps.setRange(0, 100000)
form.addRow("HY2 up mbps:", self.spn_singbox_hy2_up_mbps)
self.spn_singbox_hy2_down_mbps = QSpinBox()
self.spn_singbox_hy2_down_mbps.setRange(0, 100000)
form.addRow("HY2 down mbps:", self.spn_singbox_hy2_down_mbps)
self.ent_singbox_hy2_obfs = QLineEdit()
self.ent_singbox_hy2_obfs.setPlaceholderText("salamander")
form.addRow("HY2 obfs type:", self.ent_singbox_hy2_obfs)
self.ent_singbox_hy2_obfs_password = QLineEdit()
self.ent_singbox_hy2_obfs_password.setPlaceholderText("obfs password")
form.addRow("HY2 obfs password:", self.ent_singbox_hy2_obfs_password)
self.cmb_singbox_tuic_congestion = QComboBox()
self.cmb_singbox_tuic_congestion.setEditable(True)
self.cmb_singbox_tuic_congestion.setInsertPolicy(QComboBox.NoInsert)
self.cmb_singbox_tuic_congestion.addItem("Default", "")
self.cmb_singbox_tuic_congestion.addItem("bbr", "bbr")
self.cmb_singbox_tuic_congestion.addItem("cubic", "cubic")
self.cmb_singbox_tuic_congestion.addItem("new_reno", "new_reno")
form.addRow("TUIC congestion:", self.cmb_singbox_tuic_congestion)
self.cmb_singbox_tuic_udp_mode = QComboBox()
self.cmb_singbox_tuic_udp_mode.addItem("Default", "")
self.cmb_singbox_tuic_udp_mode.addItem("native", "native")
self.cmb_singbox_tuic_udp_mode.addItem("quic", "quic")
form.addRow("TUIC UDP relay:", self.cmb_singbox_tuic_udp_mode)
self.chk_singbox_tuic_zero_rtt = QCheckBox("Enable zero RTT handshake")
form.addRow("TUIC zero RTT:", self.chk_singbox_tuic_zero_rtt)
self.ent_singbox_wg_private_key = QLineEdit()
self.ent_singbox_wg_private_key.setPlaceholderText("wireguard private key")
self.ent_singbox_wg_private_key.setEchoMode(QLineEdit.PasswordEchoOnEdit)
form.addRow("WG private key:", self.ent_singbox_wg_private_key)
self.ent_singbox_wg_peer_public_key = QLineEdit()
self.ent_singbox_wg_peer_public_key.setPlaceholderText("peer public key")
self.ent_singbox_wg_peer_public_key.setEchoMode(QLineEdit.PasswordEchoOnEdit)
form.addRow("WG peer public key:", self.ent_singbox_wg_peer_public_key)
self.ent_singbox_wg_psk = QLineEdit()
self.ent_singbox_wg_psk.setPlaceholderText("pre-shared key (optional)")
self.ent_singbox_wg_psk.setEchoMode(QLineEdit.PasswordEchoOnEdit)
form.addRow("WG pre-shared key:", self.ent_singbox_wg_psk)
self.ent_singbox_wg_local_address = QLineEdit()
self.ent_singbox_wg_local_address.setPlaceholderText("10.0.0.2/32,fd00::2/128")
form.addRow("WG local address:", self.ent_singbox_wg_local_address)
self.ent_singbox_wg_reserved = QLineEdit()
self.ent_singbox_wg_reserved.setPlaceholderText("0,0,0 (optional)")
form.addRow("WG reserved:", self.ent_singbox_wg_reserved)
self.spn_singbox_wg_mtu = QSpinBox()
self.spn_singbox_wg_mtu.setRange(0, 9200)
form.addRow("WG MTU:", self.spn_singbox_wg_mtu)
self.cmb_singbox_vless_transport = QComboBox()
self.cmb_singbox_vless_transport.addItem("TCP (RAW)", "tcp")
self.cmb_singbox_vless_transport.addItem("WebSocket", "ws")
self.cmb_singbox_vless_transport.addItem("gRPC", "grpc")
self.cmb_singbox_vless_transport.addItem("HTTP", "http")
self.cmb_singbox_vless_transport.addItem("HTTP Upgrade", "httpupgrade")
self.cmb_singbox_vless_transport.addItem("QUIC", "quic")
self.cmb_singbox_vless_transport.currentIndexChanged.connect(
self.on_singbox_vless_editor_changed
)
form.addRow("Transport:", self.cmb_singbox_vless_transport)
self.ent_singbox_vless_path = QLineEdit()
self.ent_singbox_vless_path.setPlaceholderText("/")
form.addRow("Transport path:", self.ent_singbox_vless_path)
self.ent_singbox_vless_grpc_service = QLineEdit()
self.ent_singbox_vless_grpc_service.setPlaceholderText("service-name")
form.addRow("gRPC service:", self.ent_singbox_vless_grpc_service)
self.cmb_singbox_vless_security = QComboBox()
self.cmb_singbox_vless_security.addItem("None", "none")
self.cmb_singbox_vless_security.addItem("TLS", "tls")
self.cmb_singbox_vless_security.addItem("Reality", "reality")
self.cmb_singbox_vless_security.currentIndexChanged.connect(
self.on_singbox_vless_editor_changed
)
form.addRow("Security:", self.cmb_singbox_vless_security)
self.ent_singbox_vless_sni = QLineEdit()
self.ent_singbox_vless_sni.setPlaceholderText("www.example.com")
form.addRow("SNI:", self.ent_singbox_vless_sni)
self.ent_singbox_tls_alpn = QLineEdit()
self.ent_singbox_tls_alpn.setPlaceholderText("h2,http/1.1")
form.addRow("TLS ALPN:", self.ent_singbox_tls_alpn)
self.cmb_singbox_vless_utls_fp = QComboBox()
self.cmb_singbox_vless_utls_fp.addItem("Default", "")
self.cmb_singbox_vless_utls_fp.addItem("chrome", "chrome")
self.cmb_singbox_vless_utls_fp.addItem("firefox", "firefox")
self.cmb_singbox_vless_utls_fp.addItem("safari", "safari")
self.cmb_singbox_vless_utls_fp.addItem("edge", "edge")
form.addRow("uTLS fingerprint:", self.cmb_singbox_vless_utls_fp)
self.ent_singbox_vless_reality_pk = QLineEdit()
self.ent_singbox_vless_reality_pk.setPlaceholderText("Reality public key")
form.addRow("Reality public key:", self.ent_singbox_vless_reality_pk)
self.ent_singbox_vless_reality_sid = QLineEdit()
self.ent_singbox_vless_reality_sid.setPlaceholderText("short_id")
form.addRow("Reality short id:", self.ent_singbox_vless_reality_sid)
self.chk_singbox_vless_insecure = QCheckBox("Allow insecure TLS")
form.addRow("TLS insecure:", self.chk_singbox_vless_insecure)
self.chk_singbox_vless_sniff = QCheckBox("Enable sniffing for local inbound")
self.chk_singbox_vless_sniff.setChecked(True)
form.addRow("Sniffing:", self.chk_singbox_vless_sniff)
lay.addLayout(form)
wg_helpers = QHBoxLayout()
self.btn_singbox_wg_paste_private = QToolButton()
self.btn_singbox_wg_paste_private.setText("Paste private")
self.btn_singbox_wg_paste_private.clicked.connect(
lambda: self._paste_line_edit_from_clipboard(self.ent_singbox_wg_private_key)
)
wg_helpers.addWidget(self.btn_singbox_wg_paste_private)
self.btn_singbox_wg_copy_private = QToolButton()
self.btn_singbox_wg_copy_private.setText("Copy private")
self.btn_singbox_wg_copy_private.clicked.connect(
lambda: self._copy_line_edit_to_clipboard(self.ent_singbox_wg_private_key)
)
wg_helpers.addWidget(self.btn_singbox_wg_copy_private)
self.btn_singbox_wg_paste_peer = QToolButton()
self.btn_singbox_wg_paste_peer.setText("Paste peer")
self.btn_singbox_wg_paste_peer.clicked.connect(
lambda: self._paste_line_edit_from_clipboard(self.ent_singbox_wg_peer_public_key)
)
wg_helpers.addWidget(self.btn_singbox_wg_paste_peer)
self.btn_singbox_wg_copy_peer = QToolButton()
self.btn_singbox_wg_copy_peer.setText("Copy peer")
self.btn_singbox_wg_copy_peer.clicked.connect(
lambda: self._copy_line_edit_to_clipboard(self.ent_singbox_wg_peer_public_key)
)
wg_helpers.addWidget(self.btn_singbox_wg_copy_peer)
self.btn_singbox_wg_paste_psk = QToolButton()
self.btn_singbox_wg_paste_psk.setText("Paste PSK")
self.btn_singbox_wg_paste_psk.clicked.connect(
lambda: self._paste_line_edit_from_clipboard(self.ent_singbox_wg_psk)
)
wg_helpers.addWidget(self.btn_singbox_wg_paste_psk)
self.btn_singbox_wg_copy_psk = QToolButton()
self.btn_singbox_wg_copy_psk.setText("Copy PSK")
self.btn_singbox_wg_copy_psk.clicked.connect(
lambda: self._copy_line_edit_to_clipboard(self.ent_singbox_wg_psk)
)
wg_helpers.addWidget(self.btn_singbox_wg_copy_psk)
wg_helpers.addStretch(1)
self.wdg_singbox_wg_key_helpers = QWidget()
self.wdg_singbox_wg_key_helpers.setLayout(wg_helpers)
lay.addWidget(self.wdg_singbox_wg_key_helpers)
self.lbl_singbox_proto_guardrails = QLabel("Guardrails: address/port/uuid required")
self.lbl_singbox_proto_guardrails.setStyleSheet("color: gray;")
lay.addWidget(self.lbl_singbox_proto_guardrails)
parent_layout.addWidget(grp)
self.on_singbox_vless_editor_changed()
def _set_proto_form_row_visible(self, field: QWidget, visible: bool) -> None:
field.setVisible(visible)
label = None
form = getattr(self, "frm_singbox_proto_form", None)
if form is not None:
try:
label = form.labelForField(field)
except Exception:
label = None
if label is not None:
label.setVisible(visible)
def _copy_line_edit_to_clipboard(self, field: QLineEdit) -> None:
txt = str(field.text() or "").strip()
if txt:
QApplication.clipboard().setText(txt)
def _paste_line_edit_from_clipboard(self, field: QLineEdit) -> None:
txt = str(QApplication.clipboard().text() or "").strip()
field.setText(txt)
def _current_editor_protocol(self) -> str:
return str(self.cmb_singbox_proto_protocol.currentData() or "vless").strip().lower() or "vless"
def _is_supported_editor_protocol(self, protocol: str) -> bool:
return str(protocol or "").strip().lower() in SINGBOX_EDITOR_PROTOCOL_IDS
def on_singbox_vless_editor_changed(self, _index: int = 0) -> None:
protocol = self._current_editor_protocol()
self._singbox_editor_protocol = protocol
transport = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower()
security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower()
if protocol == "vless":
self.cmb_singbox_vless_security.setEnabled(True)
elif protocol == "trojan":
if security == "reality":
idx = self.cmb_singbox_vless_security.findData("tls")
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 1)
security = "tls"
self.cmb_singbox_vless_security.setEnabled(True)
elif protocol in ("hysteria2", "tuic"):
idx = self.cmb_singbox_vless_security.findData("tls")
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 1)
security = "tls"
self.cmb_singbox_vless_security.setEnabled(False)
elif protocol == "wireguard":
idx = self.cmb_singbox_vless_security.findData("none")
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0)
security = "none"
self.cmb_singbox_vless_security.setEnabled(False)
else:
idx = self.cmb_singbox_vless_security.findData("none")
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0)
security = "none"
self.cmb_singbox_vless_security.setEnabled(False)
path_needed = transport in ("ws", "http", "httpupgrade")
grpc_needed = transport == "grpc"
transport_supported = protocol in ("vless", "trojan")
self.cmb_singbox_vless_transport.setEnabled(transport_supported)
self.ent_singbox_vless_path.setEnabled(transport_supported and path_needed)
self.ent_singbox_vless_grpc_service.setEnabled(transport_supported and grpc_needed)
tls_like = security in ("tls", "reality")
reality = security == "reality"
self.ent_singbox_vless_sni.setEnabled(tls_like)
self.ent_singbox_tls_alpn.setEnabled(tls_like)
self.cmb_singbox_vless_utls_fp.setEnabled(tls_like)
self.chk_singbox_vless_insecure.setEnabled(tls_like)
self.ent_singbox_vless_reality_pk.setEnabled(reality)
self.ent_singbox_vless_reality_sid.setEnabled(reality)
show_vless_auth = protocol == "vless"
show_password = protocol in ("trojan", "shadowsocks", "hysteria2", "tuic")
show_ss = protocol == "shadowsocks"
show_hy2 = protocol == "hysteria2"
show_tuic = protocol == "tuic"
show_wg = protocol == "wireguard"
self._set_proto_form_row_visible(self.ent_singbox_vless_uuid, show_vless_auth or show_tuic)
self._set_proto_form_row_visible(self.ent_singbox_proto_password, show_password)
self._set_proto_form_row_visible(self.cmb_singbox_vless_flow, show_vless_auth)
self._set_proto_form_row_visible(self.cmb_singbox_vless_packet_encoding, show_vless_auth)
self._set_proto_form_row_visible(self.cmb_singbox_ss_method, show_ss)
self._set_proto_form_row_visible(self.ent_singbox_ss_plugin, show_ss)
self._set_proto_form_row_visible(self.spn_singbox_hy2_up_mbps, show_hy2)
self._set_proto_form_row_visible(self.spn_singbox_hy2_down_mbps, show_hy2)
self._set_proto_form_row_visible(self.ent_singbox_hy2_obfs, show_hy2)
self._set_proto_form_row_visible(self.ent_singbox_hy2_obfs_password, show_hy2)
self._set_proto_form_row_visible(self.cmb_singbox_tuic_congestion, show_tuic)
self._set_proto_form_row_visible(self.cmb_singbox_tuic_udp_mode, show_tuic)
self._set_proto_form_row_visible(self.chk_singbox_tuic_zero_rtt, show_tuic)
self._set_proto_form_row_visible(self.ent_singbox_wg_private_key, show_wg)
self._set_proto_form_row_visible(self.ent_singbox_wg_peer_public_key, show_wg)
self._set_proto_form_row_visible(self.ent_singbox_wg_psk, show_wg)
self._set_proto_form_row_visible(self.ent_singbox_wg_local_address, show_wg)
self._set_proto_form_row_visible(self.ent_singbox_wg_reserved, show_wg)
self._set_proto_form_row_visible(self.spn_singbox_wg_mtu, show_wg)
self.wdg_singbox_wg_key_helpers.setVisible(show_wg)
self._set_proto_form_row_visible(self.cmb_singbox_vless_transport, transport_supported)
self._set_proto_form_row_visible(self.ent_singbox_vless_path, transport_supported)
self._set_proto_form_row_visible(self.ent_singbox_vless_grpc_service, transport_supported)
self._set_proto_form_row_visible(self.cmb_singbox_vless_security, protocol not in ("shadowsocks", "wireguard"))
self._set_proto_form_row_visible(self.ent_singbox_vless_sni, tls_like)
self._set_proto_form_row_visible(self.ent_singbox_tls_alpn, tls_like)
self._set_proto_form_row_visible(self.cmb_singbox_vless_utls_fp, tls_like)
self._set_proto_form_row_visible(self.chk_singbox_vless_insecure, tls_like)
self._set_proto_form_row_visible(self.ent_singbox_vless_reality_pk, reality)
self._set_proto_form_row_visible(self.ent_singbox_vless_reality_sid, reality)
tips = ["Guardrails:"]
if protocol == "vless":
tips.append("address/port/uuid required")
elif protocol == "trojan":
tips.append("address/port/password required")
elif protocol == "shadowsocks":
tips.append("address/port/SS method/password required")
elif protocol == "hysteria2":
tips.append("address/port/password required")
elif protocol == "tuic":
tips.append("address/port/uuid/password required")
elif protocol == "wireguard":
tips.append("address/port/private_key/peer_public_key/local_address required")
if reality:
tips.append("reality.public_key is required")
if transport_supported and grpc_needed:
tips.append("gRPC service is required")
if transport_supported and path_needed:
tips.append("transport path is required")
self.lbl_singbox_proto_guardrails.setText(" | ".join(tips))

View File

@@ -0,0 +1,734 @@
from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QCheckBox,
QComboBox,
QFormLayout,
QGroupBox,
QHeaderView,
QHBoxLayout,
QLabel,
QLineEdit,
QListView,
QListWidget,
QPlainTextEdit,
QProgressBar,
QPushButton,
QScrollArea,
QSpinBox,
QTableWidget,
QStyle,
QToolButton,
QVBoxLayout,
QWidget,
QFrame,
)
class UITabsSingBoxLayoutMixin:
def _build_tab_singbox(self) -> None:
tab = QWidget()
layout = QVBoxLayout(tab)
metrics_row = QHBoxLayout()
layout.addLayout(metrics_row)
(
_card_conn,
self.lbl_singbox_metric_conn_value,
self.lbl_singbox_metric_conn_sub,
) = self._create_singbox_metric_card("Connection")
metrics_row.addWidget(_card_conn, stretch=1)
(
_card_profile,
self.lbl_singbox_metric_profile_value,
self.lbl_singbox_metric_profile_sub,
) = self._create_singbox_metric_card("Profile")
metrics_row.addWidget(_card_profile, stretch=1)
(
_card_proto,
self.lbl_singbox_metric_proto_value,
self.lbl_singbox_metric_proto_sub,
) = self._create_singbox_metric_card("Protocol / Transport / Security")
metrics_row.addWidget(_card_proto, stretch=1)
(
_card_policy,
self.lbl_singbox_metric_policy_value,
self.lbl_singbox_metric_policy_sub,
) = self._create_singbox_metric_card("Routing / DNS / Killswitch")
metrics_row.addWidget(_card_policy, stretch=1)
profiles_group = QGroupBox("Connection profiles")
profiles_layout = QVBoxLayout(profiles_group)
profiles_actions = QHBoxLayout()
self.btn_singbox_profile_create = QPushButton("Create connection")
self.btn_singbox_profile_create.clicked.connect(self.on_singbox_create_connection_click)
profiles_actions.addWidget(self.btn_singbox_profile_create)
profiles_actions.addStretch(1)
profiles_layout.addLayout(profiles_actions)
self.lst_singbox_profile_cards = QListWidget()
self.lst_singbox_profile_cards.setViewMode(QListView.IconMode)
self.lst_singbox_profile_cards.setResizeMode(QListView.Adjust)
self.lst_singbox_profile_cards.setMovement(QListView.Static)
self.lst_singbox_profile_cards.setWrapping(True)
self.lst_singbox_profile_cards.setSpacing(8)
self.lst_singbox_profile_cards.setGridSize(QSize(240, 88))
self.lst_singbox_profile_cards.setMinimumHeight(110)
self.lst_singbox_profile_cards.setContextMenuPolicy(Qt.CustomContextMenu)
self.lst_singbox_profile_cards.customContextMenuRequested.connect(
self.on_singbox_profile_card_context_menu
)
self.lst_singbox_profile_cards.itemSelectionChanged.connect(
self.on_singbox_profile_card_selected
)
profiles_layout.addWidget(self.lst_singbox_profile_cards)
layout.addWidget(profiles_group)
card_group = QGroupBox("Connection card (runtime)")
card_layout = QVBoxLayout(card_group)
card_row = QHBoxLayout()
card_layout.addLayout(card_row)
self.lbl_transport_selected_engine = QLabel("Selected profile: —")
self.lbl_transport_selected_engine.setStyleSheet("color: gray;")
card_row.addWidget(self.lbl_transport_selected_engine, stretch=1)
self.cmb_transport_engine = QComboBox()
self.cmb_transport_engine.setMaxVisibleItems(10)
self.cmb_transport_engine.currentIndexChanged.connect(
self.on_transport_engine_selected
)
# Hidden selector: internal state source (tiles are the visible selection control).
self.cmb_transport_engine.setVisible(False)
self.btn_transport_engine_refresh = QToolButton()
self.btn_transport_engine_refresh.setAutoRaise(True)
self.btn_transport_engine_refresh.setIcon(
self.style().standardIcon(QStyle.SP_BrowserReload)
)
self.btn_transport_engine_refresh.setToolTip("Refresh engines")
self.btn_transport_engine_refresh.clicked.connect(
self.on_transport_engine_refresh
)
card_row.addWidget(self.btn_transport_engine_refresh)
self.btn_transport_engine_provision = QPushButton("Prepare")
self.btn_transport_engine_provision.setToolTip(
"Optional: pre-provision runtime/config artifacts for selected profile"
)
self.btn_transport_engine_provision.clicked.connect(
lambda: self.on_transport_engine_action("provision")
)
card_row.addWidget(self.btn_transport_engine_provision)
self.btn_transport_engine_toggle = QPushButton("Disconnected")
self.btn_transport_engine_toggle.setCheckable(True)
self.btn_transport_engine_toggle.setToolTip(
"Toggle connection for selected profile"
)
self.btn_transport_engine_toggle.clicked.connect(
self.on_transport_engine_toggle
)
card_row.addWidget(self.btn_transport_engine_toggle)
self.btn_transport_engine_restart = QPushButton("Restart")
self.btn_transport_engine_restart.clicked.connect(
lambda: self.on_transport_engine_action("restart")
)
card_row.addWidget(self.btn_transport_engine_restart)
self.btn_transport_engine_rollback = QPushButton("Rollback policy")
self.btn_transport_engine_rollback.clicked.connect(
self.on_transport_policy_rollback
)
card_row.addWidget(self.btn_transport_engine_rollback)
self.btn_transport_netns_toggle = QPushButton("Debug netns: OFF")
self.btn_transport_netns_toggle.setToolTip(
"Toggle netns for all SingBox engines (debug/testing)"
)
self.btn_transport_netns_toggle.clicked.connect(
self.on_transport_netns_toggle
)
card_row.addWidget(self.btn_transport_netns_toggle)
self.lbl_transport_engine_meta = QLabel("Engine: loading...")
self.lbl_transport_engine_meta.setStyleSheet("color: gray;")
card_layout.addWidget(self.lbl_transport_engine_meta)
layout.addWidget(card_group)
settings_toggle_row = QHBoxLayout()
self.btn_singbox_toggle_profile_settings = QPushButton("Profile settings")
self.btn_singbox_toggle_profile_settings.setCheckable(True)
self.btn_singbox_toggle_profile_settings.clicked.connect(
self.on_toggle_singbox_profile_settings
)
settings_toggle_row.addWidget(self.btn_singbox_toggle_profile_settings)
self.btn_singbox_toggle_global_defaults = QPushButton("Global defaults")
self.btn_singbox_toggle_global_defaults.setCheckable(True)
self.btn_singbox_toggle_global_defaults.clicked.connect(
self.on_toggle_singbox_global_defaults
)
settings_toggle_row.addWidget(self.btn_singbox_toggle_global_defaults)
self.btn_singbox_toggle_activity = QPushButton("Activity log")
self.btn_singbox_toggle_activity.setCheckable(True)
self.btn_singbox_toggle_activity.clicked.connect(
self.on_toggle_singbox_activity
)
settings_toggle_row.addWidget(self.btn_singbox_toggle_activity)
settings_toggle_row.addStretch(1)
layout.addLayout(settings_toggle_row)
profile_group = QGroupBox("Profile settings (SingBox)")
self.grp_singbox_profile_settings = profile_group
profile_layout = QVBoxLayout(profile_group)
self.lbl_singbox_profile_name = QLabel("Profile: —")
self.lbl_singbox_profile_name.setStyleSheet("color: gray;")
profile_layout.addWidget(self.lbl_singbox_profile_name)
profile_scope_row = QHBoxLayout()
self.chk_singbox_profile_use_global_routing = QCheckBox("Use global routing defaults")
self.chk_singbox_profile_use_global_routing.stateChanged.connect(
self.on_singbox_profile_scope_changed
)
profile_scope_row.addWidget(self.chk_singbox_profile_use_global_routing)
self.chk_singbox_profile_use_global_dns = QCheckBox("Use global DNS defaults")
self.chk_singbox_profile_use_global_dns.stateChanged.connect(
self.on_singbox_profile_scope_changed
)
profile_scope_row.addWidget(self.chk_singbox_profile_use_global_dns)
self.chk_singbox_profile_use_global_killswitch = QCheckBox("Use global kill-switch defaults")
self.chk_singbox_profile_use_global_killswitch.stateChanged.connect(
self.on_singbox_profile_scope_changed
)
profile_scope_row.addWidget(self.chk_singbox_profile_use_global_killswitch)
profile_scope_row.addStretch(1)
profile_layout.addLayout(profile_scope_row)
profile_form = QFormLayout()
self.cmb_singbox_profile_routing = QComboBox()
self.cmb_singbox_profile_routing.addItem("Global default", "global")
self.cmb_singbox_profile_routing.addItem("Selective", "selective")
self.cmb_singbox_profile_routing.addItem("Full tunnel", "full")
self.cmb_singbox_profile_routing.currentIndexChanged.connect(
self.on_singbox_profile_scope_changed
)
profile_form.addRow("Routing mode:", self.cmb_singbox_profile_routing)
self.cmb_singbox_profile_dns = QComboBox()
self.cmb_singbox_profile_dns.addItem("Global default", "global")
self.cmb_singbox_profile_dns.addItem("System resolver", "system_resolver")
self.cmb_singbox_profile_dns.addItem("SingBox DNS", "singbox_dns")
self.cmb_singbox_profile_dns.currentIndexChanged.connect(
self.on_singbox_profile_scope_changed
)
profile_form.addRow("DNS mode:", self.cmb_singbox_profile_dns)
self.cmb_singbox_profile_killswitch = QComboBox()
self.cmb_singbox_profile_killswitch.addItem("Global default", "global")
self.cmb_singbox_profile_killswitch.addItem("Enabled", "on")
self.cmb_singbox_profile_killswitch.addItem("Disabled", "off")
self.cmb_singbox_profile_killswitch.currentIndexChanged.connect(
self.on_singbox_profile_scope_changed
)
profile_form.addRow("Kill-switch:", self.cmb_singbox_profile_killswitch)
profile_layout.addLayout(profile_form)
profile_actions = QHBoxLayout()
self.btn_singbox_profile_preview = QPushButton("Preview render")
self.btn_singbox_profile_preview.clicked.connect(self.on_singbox_profile_preview)
profile_actions.addWidget(self.btn_singbox_profile_preview)
self.btn_singbox_profile_validate = QPushButton("Validate profile")
self.btn_singbox_profile_validate.clicked.connect(self.on_singbox_profile_validate)
profile_actions.addWidget(self.btn_singbox_profile_validate)
self.btn_singbox_profile_apply = QPushButton("Apply profile")
self.btn_singbox_profile_apply.clicked.connect(self.on_singbox_profile_apply)
profile_actions.addWidget(self.btn_singbox_profile_apply)
self.btn_singbox_profile_rollback = QPushButton("Rollback profile")
self.btn_singbox_profile_rollback.clicked.connect(self.on_singbox_profile_rollback)
profile_actions.addWidget(self.btn_singbox_profile_rollback)
self.btn_singbox_profile_history = QPushButton("History")
self.btn_singbox_profile_history.clicked.connect(self.on_singbox_profile_history)
profile_actions.addWidget(self.btn_singbox_profile_history)
self.btn_singbox_profile_save = QPushButton("Save draft")
self.btn_singbox_profile_save.clicked.connect(self.on_singbox_profile_save)
profile_actions.addWidget(self.btn_singbox_profile_save)
profile_actions.addStretch(1)
profile_layout.addLayout(profile_actions)
self.lbl_singbox_profile_effective = QLabel("Effective: routing=— | dns=— | kill-switch=—")
self.lbl_singbox_profile_effective.setStyleSheet("color: gray;")
profile_layout.addWidget(self.lbl_singbox_profile_effective)
self._build_singbox_vless_editor(profile_layout)
self._singbox_editor_default_title = self.grp_singbox_proto_editor.title()
self.grp_singbox_proto_editor.setVisible(False)
self.lbl_singbox_editor_hint = QLabel("Right-click a profile card and select Edit to open protocol settings.")
self.lbl_singbox_editor_hint.setStyleSheet("color: gray;")
profile_layout.addWidget(self.lbl_singbox_editor_hint)
layout.addWidget(profile_group)
profile_group.setVisible(False)
global_group = QGroupBox("Global defaults")
self.grp_singbox_global_defaults = global_group
global_layout = QVBoxLayout(global_group)
global_form = QFormLayout()
self.cmb_singbox_global_routing = QComboBox()
self.cmb_singbox_global_routing.addItem("Selective", "selective")
self.cmb_singbox_global_routing.addItem("Full tunnel", "full")
self.cmb_singbox_global_routing.currentIndexChanged.connect(
self.on_singbox_global_defaults_changed
)
global_form.addRow("Default routing mode:", self.cmb_singbox_global_routing)
self.cmb_singbox_global_dns = QComboBox()
self.cmb_singbox_global_dns.addItem("System resolver", "system_resolver")
self.cmb_singbox_global_dns.addItem("SingBox DNS", "singbox_dns")
self.cmb_singbox_global_dns.currentIndexChanged.connect(
self.on_singbox_global_defaults_changed
)
global_form.addRow("Default DNS mode:", self.cmb_singbox_global_dns)
self.cmb_singbox_global_killswitch = QComboBox()
self.cmb_singbox_global_killswitch.addItem("Enabled", "on")
self.cmb_singbox_global_killswitch.addItem("Disabled", "off")
self.cmb_singbox_global_killswitch.currentIndexChanged.connect(
self.on_singbox_global_defaults_changed
)
global_form.addRow("Default kill-switch:", self.cmb_singbox_global_killswitch)
global_layout.addLayout(global_form)
global_actions = QHBoxLayout()
self.btn_singbox_global_save = QPushButton("Save global defaults")
self.btn_singbox_global_save.clicked.connect(self.on_singbox_global_save)
global_actions.addWidget(self.btn_singbox_global_save)
global_actions.addStretch(1)
global_layout.addLayout(global_actions)
self.lbl_singbox_global_hint = QLabel(
"Global defaults are used by profiles with 'Use global ...' enabled."
)
self.lbl_singbox_global_hint.setStyleSheet("color: gray;")
global_layout.addWidget(self.lbl_singbox_global_hint)
layout.addWidget(global_group)
global_group.setVisible(False)
# During UI construction routes/dns widgets are not fully created yet,
# so apply local SingBox control state without touching global save path.
self._apply_singbox_profile_controls()
# Multi-interface routing tools are placed on a dedicated tab.
self.grp_singbox_activity = QGroupBox("Activity log")
activity_layout = QVBoxLayout(self.grp_singbox_activity)
self.txt_transport = QPlainTextEdit()
self.txt_transport.setReadOnly(True)
activity_layout.addWidget(self.txt_transport)
layout.addWidget(self.grp_singbox_activity, stretch=1)
self.grp_singbox_activity.setVisible(False)
self._apply_singbox_compact_visibility()
self.tabs.addTab(tab, "SingBox")
def _build_tab_multiif(self) -> None:
tab = QWidget()
layout = QVBoxLayout(tab)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll_content = QWidget()
scroll_layout = QVBoxLayout(scroll_content)
scroll_layout.setContentsMargins(0, 0, 0, 0)
scroll_layout.setSpacing(8)
self.grp_singbox_owner_locks = self._build_singbox_owner_locks_group()
scroll_layout.addWidget(self.grp_singbox_owner_locks)
scroll_layout.addStretch(1)
scroll.setWidget(scroll_content)
layout.addWidget(scroll, stretch=1)
self.tabs.addTab(tab, "MultiIF")
def _build_singbox_owner_locks_group(self) -> QGroupBox:
group = QGroupBox("Routing policy & ownership locks")
owner_locks_layout = QVBoxLayout(group)
owner_actions = QHBoxLayout()
self.btn_singbox_owner_locks_refresh = QPushButton("Refresh locks")
self.btn_singbox_owner_locks_refresh.clicked.connect(
self.on_singbox_owner_locks_refresh
)
owner_actions.addWidget(self.btn_singbox_owner_locks_refresh)
self.btn_singbox_owner_locks_clear = QPushButton("Clear locks...")
self.btn_singbox_owner_locks_clear.clicked.connect(
self.on_singbox_owner_locks_clear
)
owner_actions.addWidget(self.btn_singbox_owner_locks_clear)
owner_actions.addWidget(QLabel("Engine:"))
self.cmb_singbox_owner_engine_scope = QComboBox()
self.cmb_singbox_owner_engine_scope.addItem("All", "all")
self.cmb_singbox_owner_engine_scope.addItem("Transport", "transport")
self.cmb_singbox_owner_engine_scope.addItem("AdGuard VPN", "adguardvpn")
self.cmb_singbox_owner_engine_scope.currentIndexChanged.connect(
self.on_singbox_owner_engine_scope_changed
)
owner_actions.addWidget(self.cmb_singbox_owner_engine_scope)
owner_actions.addStretch(1)
owner_locks_layout.addLayout(owner_actions)
self.lbl_singbox_owner_locks_summary = QLabel("Ownership: — | Locks: —")
self.lbl_singbox_owner_locks_summary.setStyleSheet("color: gray;")
owner_locks_layout.addWidget(self.lbl_singbox_owner_locks_summary)
self.lbl_singbox_interfaces_hint = QLabel("Interfaces (read-only)")
self.lbl_singbox_interfaces_hint.setStyleSheet("color: #666;")
owner_locks_layout.addWidget(self.lbl_singbox_interfaces_hint)
self.tbl_singbox_interfaces = QTableWidget(0, 7)
self.tbl_singbox_interfaces.setHorizontalHeaderLabels(
["Iface ID", "Mode", "Runtime iface", "NetNS", "Routing table", "Clients UP/Total", "Updated"]
)
self.tbl_singbox_interfaces.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tbl_singbox_interfaces.setSelectionMode(QAbstractItemView.SingleSelection)
self.tbl_singbox_interfaces.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tbl_singbox_interfaces.verticalHeader().setVisible(False)
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
self.tbl_singbox_interfaces.horizontalHeader().setSectionResizeMode(6, QHeaderView.Stretch)
self.tbl_singbox_interfaces.setMinimumHeight(120)
owner_locks_layout.addWidget(self.tbl_singbox_interfaces)
self.lbl_singbox_policy_quick_help = QLabel(
"Policy flow: 1) Add demo/fill intent -> 2) Validate policy -> 3) Validate & apply."
)
self.lbl_singbox_policy_quick_help.setStyleSheet("color: #1f6b2f;")
owner_locks_layout.addWidget(self.lbl_singbox_policy_quick_help)
policy_group = QGroupBox("Policy intents")
policy_layout = QVBoxLayout(policy_group)
self.lbl_singbox_policy_input_help = QLabel(
"Intent fields: selector type | selector value | client | mode | priority"
)
self.lbl_singbox_policy_input_help.setStyleSheet("color: #666;")
policy_layout.addWidget(self.lbl_singbox_policy_input_help)
policy_template_row = QHBoxLayout()
self.cmb_singbox_policy_template = QComboBox()
self.cmb_singbox_policy_template.addItem("Quick template...", "")
self.cmb_singbox_policy_template.addItem(
"Domain -> active client (strict)",
{
"selector_type": "domain",
"selector_value": "example.com",
"mode": "strict",
"priority": 100,
},
)
self.cmb_singbox_policy_template.addItem(
"Wildcard domain (fallback)",
{
"selector_type": "domain",
"selector_value": "*.example.com",
"mode": "fallback",
"priority": 200,
},
)
self.cmb_singbox_policy_template.addItem(
"CIDR subnet (strict)",
{
"selector_type": "cidr",
"selector_value": "1.2.3.0/24",
"mode": "strict",
"priority": 100,
},
)
self.cmb_singbox_policy_template.addItem(
"IP host (strict)",
{
"selector_type": "cidr",
"selector_value": "1.2.3.4",
"mode": "strict",
"priority": 100,
},
)
self.cmb_singbox_policy_template.addItem(
"App key (strict)",
{
"selector_type": "app_key",
"selector_value": "steam",
"mode": "strict",
"priority": 100,
},
)
self.cmb_singbox_policy_template.addItem(
"UID (strict)",
{
"selector_type": "uid",
"selector_value": "1000",
"mode": "strict",
"priority": 100,
},
)
self.cmb_singbox_policy_template.setToolTip(
"Prefill intent fields from a template. It does not add to draft automatically."
)
policy_template_row.addWidget(self.cmb_singbox_policy_template, stretch=2)
self.btn_singbox_policy_use_template = QPushButton("Use template")
self.btn_singbox_policy_use_template.clicked.connect(self.on_singbox_policy_use_template)
policy_template_row.addWidget(self.btn_singbox_policy_use_template)
self.btn_singbox_policy_add_demo = QPushButton("Add demo intent")
self.btn_singbox_policy_add_demo.setToolTip(
"Create one test intent (domain -> selected client) and add it to draft."
)
self.btn_singbox_policy_add_demo.clicked.connect(self.on_singbox_policy_add_demo_intent)
policy_template_row.addWidget(self.btn_singbox_policy_add_demo)
policy_template_row.addStretch(1)
policy_layout.addLayout(policy_template_row)
policy_input_row = QHBoxLayout()
self.cmb_singbox_policy_selector_type = QComboBox()
self.cmb_singbox_policy_selector_type.addItem("domain", "domain")
self.cmb_singbox_policy_selector_type.addItem("cidr", "cidr")
self.cmb_singbox_policy_selector_type.addItem("app_key", "app_key")
self.cmb_singbox_policy_selector_type.addItem("cgroup", "cgroup")
self.cmb_singbox_policy_selector_type.addItem("uid", "uid")
self.cmb_singbox_policy_selector_type.currentIndexChanged.connect(
self.on_singbox_policy_selector_type_changed
)
policy_input_row.addWidget(self.cmb_singbox_policy_selector_type)
self.ent_singbox_policy_selector_value = QLineEdit()
self.ent_singbox_policy_selector_value.setPlaceholderText("example.com")
self.ent_singbox_policy_selector_value.setToolTip(
"Examples: domain=example.com, cidr=1.2.3.0/24, app_key=steam, cgroup=user.slice/..., uid=1000. Press Enter to add intent."
)
self.ent_singbox_policy_selector_value.returnPressed.connect(self.on_singbox_policy_add_intent)
policy_input_row.addWidget(self.ent_singbox_policy_selector_value, stretch=2)
self.cmb_singbox_policy_client_id = QComboBox()
self.cmb_singbox_policy_client_id.setMinimumWidth(180)
policy_input_row.addWidget(self.cmb_singbox_policy_client_id, stretch=1)
self.cmb_singbox_policy_mode = QComboBox()
self.cmb_singbox_policy_mode.addItem("strict", "strict")
self.cmb_singbox_policy_mode.addItem("fallback", "fallback")
policy_input_row.addWidget(self.cmb_singbox_policy_mode)
self.spn_singbox_policy_priority = QSpinBox()
self.spn_singbox_policy_priority.setRange(1, 10000)
self.spn_singbox_policy_priority.setValue(100)
self.spn_singbox_policy_priority.setToolTip("Intent priority")
policy_input_row.addWidget(self.spn_singbox_policy_priority)
self.btn_singbox_policy_add = QPushButton("Add intent")
self.btn_singbox_policy_add.clicked.connect(self.on_singbox_policy_add_intent)
policy_input_row.addWidget(self.btn_singbox_policy_add)
self.btn_singbox_policy_load_selected = QPushButton("Load selected")
self.btn_singbox_policy_load_selected.clicked.connect(self.on_singbox_policy_load_selected_intent)
policy_input_row.addWidget(self.btn_singbox_policy_load_selected)
self.btn_singbox_policy_update_selected = QPushButton("Update selected")
self.btn_singbox_policy_update_selected.clicked.connect(self.on_singbox_policy_update_selected_intent)
policy_input_row.addWidget(self.btn_singbox_policy_update_selected)
self.btn_singbox_policy_remove = QPushButton("Remove selected")
self.btn_singbox_policy_remove.clicked.connect(self.on_singbox_policy_remove_selected)
policy_input_row.addWidget(self.btn_singbox_policy_remove)
policy_layout.addLayout(policy_input_row)
policy_actions_row = QHBoxLayout()
self.btn_singbox_policy_reload = QPushButton("Reload policy")
self.btn_singbox_policy_reload.clicked.connect(self.on_singbox_policy_reload)
policy_actions_row.addWidget(self.btn_singbox_policy_reload)
self.btn_singbox_policy_validate = QPushButton("Validate policy")
self.btn_singbox_policy_validate.clicked.connect(self.on_singbox_policy_validate)
policy_actions_row.addWidget(self.btn_singbox_policy_validate)
self.btn_singbox_policy_apply = QPushButton("Validate & apply")
self.btn_singbox_policy_apply.clicked.connect(self.on_singbox_policy_apply)
policy_actions_row.addWidget(self.btn_singbox_policy_apply)
self.btn_singbox_policy_rollback = QPushButton("Rollback policy")
self.btn_singbox_policy_rollback.clicked.connect(self.on_singbox_policy_rollback_explicit)
policy_actions_row.addWidget(self.btn_singbox_policy_rollback)
policy_actions_row.addStretch(1)
policy_layout.addLayout(policy_actions_row)
self.lbl_singbox_policy_state = QLabel("Policy editor: loading...")
self.lbl_singbox_policy_state.setStyleSheet("color: gray;")
policy_layout.addWidget(self.lbl_singbox_policy_state)
self.lbl_singbox_policy_conflicts_hint = QLabel(
"Validation conflicts (last validate/apply, read-only)"
)
self.lbl_singbox_policy_conflicts_hint.setStyleSheet("color: #666;")
policy_layout.addWidget(self.lbl_singbox_policy_conflicts_hint)
self.tbl_singbox_policy_conflicts = QTableWidget(0, 5)
self.tbl_singbox_policy_conflicts.setHorizontalHeaderLabels(
["Type", "Severity", "Owners", "Reason", "Suggested resolution"]
)
self.tbl_singbox_policy_conflicts.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tbl_singbox_policy_conflicts.setSelectionMode(QAbstractItemView.SingleSelection)
self.tbl_singbox_policy_conflicts.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tbl_singbox_policy_conflicts.verticalHeader().setVisible(False)
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch)
self.tbl_singbox_policy_conflicts.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch)
self.tbl_singbox_policy_conflicts.setMinimumHeight(100)
policy_layout.addWidget(self.tbl_singbox_policy_conflicts)
self.tbl_singbox_policy_intents = QTableWidget(0, 5)
self.tbl_singbox_policy_intents.setHorizontalHeaderLabels(
["Selector type", "Selector value", "Client ID", "Mode", "Priority"]
)
self.tbl_singbox_policy_intents.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tbl_singbox_policy_intents.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.tbl_singbox_policy_intents.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tbl_singbox_policy_intents.verticalHeader().setVisible(False)
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_intents.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_intents.setMinimumHeight(130)
self.tbl_singbox_policy_intents.itemDoubleClicked.connect(
self.on_singbox_policy_intent_double_clicked
)
policy_layout.addWidget(self.tbl_singbox_policy_intents)
self.lbl_singbox_policy_applied_hint = QLabel(
"Applied intents (read-only, current backend policy)"
)
self.lbl_singbox_policy_applied_hint.setStyleSheet("color: #666;")
policy_layout.addWidget(self.lbl_singbox_policy_applied_hint)
self.tbl_singbox_policy_applied = QTableWidget(0, 5)
self.tbl_singbox_policy_applied.setHorizontalHeaderLabels(
["Selector type", "Selector value", "Client ID", "Mode", "Priority"]
)
self.tbl_singbox_policy_applied.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tbl_singbox_policy_applied.setSelectionMode(QAbstractItemView.SingleSelection)
self.tbl_singbox_policy_applied.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tbl_singbox_policy_applied.verticalHeader().setVisible(False)
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_applied.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.tbl_singbox_policy_applied.setMinimumHeight(110)
policy_layout.addWidget(self.tbl_singbox_policy_applied)
owner_locks_layout.addWidget(policy_group)
self.lbl_singbox_ownership_hint = QLabel(
"Ownership (read-only, populated after policy apply)"
)
self.lbl_singbox_ownership_hint.setStyleSheet("color: #666;")
owner_locks_layout.addWidget(self.lbl_singbox_ownership_hint)
self.tbl_singbox_ownership = QTableWidget(0, 6)
self.tbl_singbox_ownership.setHorizontalHeaderLabels(
["Selector", "Owner", "Owner scope", "Iface / table", "Status", "Lock"]
)
self.tbl_singbox_ownership.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tbl_singbox_ownership.setSelectionMode(QAbstractItemView.SingleSelection)
self.tbl_singbox_ownership.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tbl_singbox_ownership.verticalHeader().setVisible(False)
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.tbl_singbox_ownership.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
self.tbl_singbox_ownership.setMinimumHeight(130)
owner_locks_layout.addWidget(self.tbl_singbox_ownership)
filters_row = QHBoxLayout()
self.ent_singbox_owner_lock_client = QLineEdit()
self.ent_singbox_owner_lock_client.setPlaceholderText("Filter client_id (optional)")
filters_row.addWidget(self.ent_singbox_owner_lock_client, stretch=1)
self.ent_singbox_owner_lock_destination = QLineEdit()
self.ent_singbox_owner_lock_destination.setPlaceholderText(
"Destination IP or CSV list (optional)"
)
filters_row.addWidget(self.ent_singbox_owner_lock_destination, stretch=2)
owner_locks_layout.addLayout(filters_row)
self.lbl_singbox_locks_hint = QLabel(
"Destination locks (read-only, conntrack sticky state)"
)
self.lbl_singbox_locks_hint.setStyleSheet("color: #666;")
owner_locks_layout.addWidget(self.lbl_singbox_locks_hint)
self.tbl_singbox_owner_locks = QTableWidget(0, 6)
self.tbl_singbox_owner_locks.setHorizontalHeaderLabels(
["Destination", "Owner", "Kind", "Iface", "Mark/Proto", "Updated"]
)
self.tbl_singbox_owner_locks.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tbl_singbox_owner_locks.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.tbl_singbox_owner_locks.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.tbl_singbox_owner_locks.verticalHeader().setVisible(False)
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.tbl_singbox_owner_locks.horizontalHeader().setSectionResizeMode(5, QHeaderView.Stretch)
self.tbl_singbox_owner_locks.setMinimumHeight(170)
owner_locks_layout.addWidget(self.tbl_singbox_owner_locks)
self.lbl_singbox_owner_locks_hint = QLabel(
"Clear flow is two-step confirm. Empty filter uses selected destination rows."
)
self.lbl_singbox_owner_locks_hint.setStyleSheet("color: gray;")
owner_locks_layout.addWidget(self.lbl_singbox_owner_locks_hint)
return group
def _create_singbox_metric_card(self, title: str) -> tuple[QFrame, QLabel, QLabel]:
frame = QFrame()
frame.setFrameShape(QFrame.StyledPanel)
frame.setObjectName("singboxMetricCard")
frame.setStyleSheet(
"""
QFrame#singboxMetricCard {
border: 1px solid #c9c9c9;
border-radius: 6px;
background: #f7f7f7;
}
"""
)
lay = QVBoxLayout(frame)
lay.setContentsMargins(10, 8, 10, 8)
lay.setSpacing(2)
lbl_title = QLabel(title)
lbl_title.setStyleSheet("color: #555; font-size: 11px;")
lay.addWidget(lbl_title)
lbl_value = QLabel("")
lbl_value.setStyleSheet("font-weight: 600;")
lay.addWidget(lbl_value)
lbl_sub = QLabel("")
lbl_sub.setStyleSheet("color: #666; font-size: 11px;")
lay.addWidget(lbl_sub)
return frame, lbl_value, lbl_sub

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from main_window.ui_tabs_singbox_editor_mixin import UITabsSingBoxEditorMixin
from main_window.ui_tabs_singbox_layout_mixin import UITabsSingBoxLayoutMixin
class UITabsSingBoxMixin(
UITabsSingBoxEditorMixin,
UITabsSingBoxLayoutMixin,
):
"""Facade mixin for SingBox tab UI builders."""
__all__ = ["UITabsSingBoxMixin"]

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import time
from PySide6 import QtCore
from dashboard_controller import DashboardController
class EventThread(QtCore.QThread):
eventReceived = QtCore.Signal(object)
error = QtCore.Signal(str)
def __init__(self, controller: DashboardController, parent=None) -> None:
super().__init__(parent)
self.ctrl = controller
self._stop = False
self._since = 0
def stop(self) -> None:
self._stop = True
def run(self) -> None: # pragma: no cover - thread
while not self._stop:
try:
for ev in self.ctrl.iter_events(since=self._since, stop=lambda: self._stop):
if self._stop:
break
try:
self._since = int(getattr(ev, "id", self._since))
except Exception:
pass
self.eventReceived.emit(ev)
time.sleep(0.5)
except Exception as e:
self.error.emit(str(e))
time.sleep(1.5)
class LocationsThread(QtCore.QThread):
loaded = QtCore.Signal(object)
error = QtCore.Signal(str)
def __init__(
self,
controller: DashboardController,
force_refresh: bool = False,
parent=None,
) -> None:
super().__init__(parent)
self.ctrl = controller
self.force_refresh = bool(force_refresh)
def run(self) -> None: # pragma: no cover - thread
try:
if self.force_refresh:
self.ctrl.vpn_locations_refresh_trigger()
self.loaded.emit(self.ctrl.vpn_locations_state_view())
except Exception as e:
self.error.emit(str(e))
__all__ = ["EventThread", "LocationsThread"]