platform: modularize api/gui, add docs-tests-web foundation, and refresh root config
This commit is contained in:
26
selective-vpn-gui/main_window/__init__.py
Normal file
26
selective-vpn-gui/main_window/__init__.py
Normal 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",
|
||||
]
|
||||
81
selective-vpn-gui/main_window/constants.py
Normal file
81
selective-vpn-gui/main_window/constants.py
Normal 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",
|
||||
]
|
||||
18
selective-vpn-gui/main_window/runtime_actions_mixin.py
Normal file
18
selective-vpn-gui/main_window/runtime_actions_mixin.py
Normal 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"]
|
||||
209
selective-vpn-gui/main_window/runtime_auth_mixin.py
Normal file
209
selective-vpn-gui/main_window/runtime_auth_mixin.py
Normal 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")
|
||||
268
selective-vpn-gui/main_window/runtime_ops_mixin.py
Normal file
268
selective-vpn-gui/main_window/runtime_ops_mixin.py
Normal 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)
|
||||
427
selective-vpn-gui/main_window/runtime_refresh_mixin.py
Normal file
427
selective-vpn-gui/main_window/runtime_refresh_mixin.py
Normal 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")
|
||||
481
selective-vpn-gui/main_window/runtime_state_mixin.py
Normal file
481
selective-vpn-gui/main_window/runtime_state_mixin.py
Normal 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()
|
||||
11
selective-vpn-gui/main_window/singbox/__init__.py
Normal file
11
selective-vpn-gui/main_window/singbox/__init__.py
Normal 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",
|
||||
]
|
||||
205
selective-vpn-gui/main_window/singbox/cards_mixin.py
Normal file
205
selective-vpn-gui/main_window/singbox/cards_mixin.py
Normal 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
|
||||
633
selective-vpn-gui/main_window/singbox/editor_mixin.py
Normal file
633
selective-vpn-gui/main_window/singbox/editor_mixin.py
Normal 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}")
|
||||
271
selective-vpn-gui/main_window/singbox/links_actions_mixin.py
Normal file
271
selective-vpn-gui/main_window/singbox/links_actions_mixin.py
Normal 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)
|
||||
337
selective-vpn-gui/main_window/singbox/links_helpers_mixin.py
Normal file
337
selective-vpn-gui/main_window/singbox/links_helpers_mixin.py
Normal 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"},
|
||||
],
|
||||
},
|
||||
}
|
||||
16
selective-vpn-gui/main_window/singbox/links_mixin.py
Normal file
16
selective-vpn-gui/main_window/singbox/links_mixin.py
Normal 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"]
|
||||
391
selective-vpn-gui/main_window/singbox/links_parsers_mixin.py
Normal file
391
selective-vpn-gui/main_window/singbox/links_parsers_mixin.py
Normal 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}")
|
||||
136
selective-vpn-gui/main_window/singbox/runtime_cards_mixin.py
Normal file
136
selective-vpn-gui/main_window/singbox/runtime_cards_mixin.py
Normal 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"
|
||||
)
|
||||
16
selective-vpn-gui/main_window/singbox/runtime_mixin.py
Normal file
16
selective-vpn-gui/main_window/singbox/runtime_mixin.py
Normal 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"]
|
||||
428
selective-vpn-gui/main_window/singbox/runtime_profiles_mixin.py
Normal file
428
selective-vpn-gui/main_window/singbox/runtime_profiles_mixin.py
Normal 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")
|
||||
1457
selective-vpn-gui/main_window/singbox/runtime_transport_mixin.py
Normal file
1457
selective-vpn-gui/main_window/singbox/runtime_transport_mixin.py
Normal file
File diff suppressed because it is too large
Load Diff
20
selective-vpn-gui/main_window/singbox_mixin.py
Normal file
20
selective-vpn-gui/main_window/singbox_mixin.py
Normal 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"]
|
||||
61
selective-vpn-gui/main_window/ui_helpers_mixin.py
Normal file
61
selective-vpn-gui/main_window/ui_helpers_mixin.py
Normal 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()
|
||||
448
selective-vpn-gui/main_window/ui_location_runtime_mixin.py
Normal file
448
selective-vpn-gui/main_window/ui_location_runtime_mixin.py
Normal 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()
|
||||
16
selective-vpn-gui/main_window/ui_shell_mixin.py
Normal file
16
selective-vpn-gui/main_window/ui_shell_mixin.py
Normal 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"]
|
||||
244
selective-vpn-gui/main_window/ui_tabs_main_mixin.py
Normal file
244
selective-vpn-gui/main_window/ui_tabs_main_mixin.py
Normal 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")
|
||||
16
selective-vpn-gui/main_window/ui_tabs_mixin.py
Normal file
16
selective-vpn-gui/main_window/ui_tabs_mixin.py
Normal 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"]
|
||||
305
selective-vpn-gui/main_window/ui_tabs_other_mixin.py
Normal file
305
selective-vpn-gui/main_window/ui_tabs_other_mixin.py
Normal 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")
|
||||
420
selective-vpn-gui/main_window/ui_tabs_singbox_editor_mixin.py
Normal file
420
selective-vpn-gui/main_window/ui_tabs_singbox_editor_mixin.py
Normal 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))
|
||||
734
selective-vpn-gui/main_window/ui_tabs_singbox_layout_mixin.py
Normal file
734
selective-vpn-gui/main_window/ui_tabs_singbox_layout_mixin.py
Normal 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
|
||||
14
selective-vpn-gui/main_window/ui_tabs_singbox_mixin.py
Normal file
14
selective-vpn-gui/main_window/ui_tabs_singbox_mixin.py
Normal 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"]
|
||||
63
selective-vpn-gui/main_window/workers.py
Normal file
63
selective-vpn-gui/main_window/workers.py
Normal 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"]
|
||||
Reference in New Issue
Block a user