Files
elmprodvpn/selective-vpn-gui/vpn-dashboard.py
beckline 10a10f44a8 baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
2026-02-14 15:52:20 +03:00

902 lines
34 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Selective-VPN Dashboard (UI only)
RULES:
- This file must NOT know anything about REST paths, HTTP methods, or JSON keys.
- It talks ONLY to DashboardController (which uses ApiClient).
"""
from __future__ import annotations
import re
import subprocess
import sys
import tkinter as tk
from tkinter import messagebox
from tkinter import ttk
from typing import Literal, Optional, cast, Tuple
from api_client import ApiClient, DnsUpstreams
from dashboard_controller import DashboardController
TraceMode = Literal["full", "gui", "smartdns"]
# убираем спам автопроверки из логов UI (на всякий случай, даже если почистил controller)
_NEXT_CHECK_RE = re.compile(r"(?:\b\d+s\.)?\s*Next check in\s+\d+s\.?", re.IGNORECASE)
class App(ttk.Frame):
def __init__(self, master: tk.Tk, ctrl: DashboardController) -> None:
super().__init__(master)
self.master = master
self.ctrl = ctrl
# login-flow runtime
self._login_flow_active: bool = False
self._login_cursor: int = 0
self._login_url_opened: bool = False
self._login_poll_after_id: Optional[str] = None
self._build_ui()
self._wire_events()
self.after(50, self.refresh_everything)
self.master.protocol("WM_DELETE_WINDOW", self._on_close)
# ---------------- UI BUILD ----------------
def _build_ui(self) -> None:
self.master.title("Selective-VPN Dashboard")
self.pack(fill="both", expand=True)
# Top bar
top = ttk.Frame(self)
top.pack(fill="x", padx=10, pady=(10, 6))
self.btn_refresh = ttk.Button(top, text="Refresh all", command=self.refresh_everything)
self.btn_refresh.pack(side="left")
# Login indicator (dot + text)
self.login_dot = tk.Canvas(top, width=12, height=12, highlightthickness=0)
self.login_dot.pack(side="left", padx=(12, 4))
self._login_dot_id = self.login_dot.create_oval(2, 2, 10, 10, fill="gray", outline="")
self.lbl_login = ttk.Label(top, text="AdGuard VPN: ...", font=("TkDefaultFont", 10, "bold"))
self.lbl_login.pack(side="left", padx=(0, 10))
# Single auth button (Login/Logout)
self.btn_auth = ttk.Button(top, text="Login", command=self.on_auth_button)
self.btn_auth.pack(side="left")
self.lbl_hint = ttk.Label(top, text="(GUI contains no API logic)", foreground="gray")
self.lbl_hint.pack(side="right")
# Notebook
self.nb = ttk.Notebook(self)
self.nb.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self._build_tab_status()
self._build_tab_vpn()
self._build_tab_routes()
self._build_tab_dns()
self._build_tab_domains()
self._build_tab_trace()
def _build_tab_status(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="Status")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
grid = ttk.Frame(frm)
grid.pack(fill="x")
def row(r: int, label: str) -> ttk.Label:
ttk.Label(grid, text=label).grid(row=r, column=0, sticky="w", pady=2)
v = ttk.Label(grid, text="")
v.grid(row=r, column=1, sticky="w", pady=2, padx=(10, 0))
return v
self.st_timestamp = row(0, "Timestamp")
self.st_counts = row(1, "Counts")
self.st_iface = row(2, "Iface/Table/Mark")
self.st_route = row(3, "Policy route")
self.st_routesvc = row(4, "Routes service")
self.st_smartdns = row(5, "SmartDNS service")
self.st_vpnsvc = row(6, "VPN service")
btns = ttk.Frame(frm)
btns.pack(fill="x", pady=(10, 0))
ttk.Button(btns, text="Refresh status", command=self.refresh_status_tab).pack(side="left")
def _build_tab_vpn(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="AdGuardVPN")
# Pages container
self.vpn_pages = ttk.Frame(tab)
self.vpn_pages.pack(fill="both", expand=True, padx=10, pady=10)
self.vpn_page_main = ttk.Frame(self.vpn_pages)
self.vpn_page_login = ttk.Frame(self.vpn_pages)
for p in (self.vpn_page_main, self.vpn_page_login):
p.grid(row=0, column=0, sticky="nsew")
self.vpn_pages.rowconfigure(0, weight=1)
self.vpn_pages.columnconfigure(0, weight=1)
# -------- Page 1: main VPN controls (Enter Login removed) --------
frm = self.vpn_page_main
top_actions = ttk.Frame(frm)
top_actions.pack(fill="x", pady=(0, 10))
ttk.Button(top_actions, text="Refresh", command=self.refresh_vpn_tab).pack(side="right")
# Autoconnect toggle
ac = ttk.LabelFrame(frm, text="Auto-connect")
ac.pack(fill="x")
self.var_autoconnect = tk.BooleanVar(value=False)
self.chk_autoconnect = ttk.Checkbutton(
ac,
text="Enable auto-connect",
variable=self.var_autoconnect,
command=self.on_toggle_autoconnect,
)
self.chk_autoconnect.pack(side="left", padx=10, pady=8)
# Location picker
loc = ttk.LabelFrame(frm, text="Location")
loc.pack(fill="x", pady=(10, 0))
self.cmb_location = ttk.Combobox(loc, state="readonly", width=40)
self.cmb_location.pack(side="left", padx=10, pady=8)
self.btn_set_location = ttk.Button(loc, text="Set location", command=self.on_set_location)
self.btn_set_location.pack(side="left", padx=6, pady=8)
self.lbl_vpn_desired = ttk.Label(loc, text="Desired: —", foreground="gray")
self.lbl_vpn_desired.pack(side="left", padx=12)
# Status output
st = ttk.LabelFrame(frm, text="VPN Status")
st.pack(fill="both", expand=True, pady=(10, 0))
self.txt_vpn = tk.Text(st, height=12, wrap="none")
self.txt_vpn.pack(fill="both", expand=True, padx=10, pady=10)
# -------- Page 2: Login flow --------
lf = self.vpn_page_login
lf_top = ttk.Frame(lf)
lf_top.pack(fill="x", pady=(0, 10))
ttk.Button(lf_top, text="← Back", command=self.on_login_back).pack(side="left")
self.login_flow_dot = tk.Canvas(lf_top, width=14, height=14, highlightthickness=0)
self.login_flow_dot.pack(side="left", padx=(10, 4))
self._login_flow_dot_id = self.login_flow_dot.create_oval(2, 2, 12, 12, fill="orange", outline="")
self.lbl_login_flow_status = ttk.Label(lf_top, text="Status: —", font=("TkDefaultFont", 10, "bold"))
self.lbl_login_flow_status.pack(side="left", padx=(0, 10))
self.lbl_login_flow_email = ttk.Label(lf_top, text="", foreground="gray")
self.lbl_login_flow_email.pack(side="left")
url_row = ttk.Frame(lf)
url_row.pack(fill="x", pady=(0, 10))
ttk.Label(url_row, text="URL:").pack(side="left")
self.var_login_url = tk.StringVar(value="")
self.ent_login_url = ttk.Entry(url_row, textvariable=self.var_login_url, state="readonly")
self.ent_login_url.pack(side="left", fill="x", expand=True, padx=8)
self.btn_login_copy = ttk.Button(url_row, text="Copy", command=self.on_login_copy)
self.btn_login_copy.pack(side="left", padx=(0, 6))
self.btn_login_open = ttk.Button(url_row, text="Open", command=self.on_login_open)
self.btn_login_open.pack(side="left")
ctrl_row = ttk.Frame(lf)
ctrl_row.pack(fill="x", pady=(0, 10))
self.btn_login_check = ttk.Button(ctrl_row, text="Check", command=self.on_login_check)
self.btn_login_check.pack(side="left")
self.btn_login_close = ttk.Button(ctrl_row, text="Close (cancel)", command=self.on_login_cancel)
self.btn_login_close.pack(side="left", padx=6)
self.btn_login_stop = ttk.Button(ctrl_row, text="Stop (force)", command=self.on_login_stop)
self.btn_login_stop.pack(side="left", padx=6)
# Log output
out = ttk.LabelFrame(lf, text="Login output")
out.pack(fill="both", expand=True)
self.txt_login_flow = tk.Text(out, wrap="word", height=16)
self.txt_login_flow.pack(fill="both", expand=True, padx=10, pady=10)
self._show_vpn_page("main")
def _build_tab_routes(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="Routes")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
svc = ttk.LabelFrame(frm, text="Routes service")
svc.pack(fill="x")
ttk.Button(svc, text="Start", command=lambda: self.on_routes_action("start")).pack(side="left", padx=10, pady=8)
ttk.Button(svc, text="Stop", command=lambda: self.on_routes_action("stop")).pack(side="left", padx=6, pady=8)
ttk.Button(svc, text="Restart", command=lambda: self.on_routes_action("restart")).pack(side="left", padx=6, pady=8)
ttk.Button(svc, text="Clear routes", command=self.on_routes_clear).pack(side="right", padx=10, pady=8)
timer = ttk.LabelFrame(frm, text="Timer")
timer.pack(fill="x", pady=(10, 0))
self.var_timer = tk.BooleanVar(value=False)
self.chk_timer = ttk.Checkbutton(timer, text="Enable timer", variable=self.var_timer, command=self.on_toggle_timer)
self.chk_timer.pack(side="left", padx=10, pady=8)
ttk.Button(timer, text="Fix policy route", command=self.on_fix_policy_route).pack(side="right", padx=10, pady=8)
out = ttk.LabelFrame(frm, text="Output")
out.pack(fill="both", expand=True, pady=(10, 0))
self.txt_routes = tk.Text(out, height=12, wrap="none")
self.txt_routes.pack(fill="both", expand=True, padx=10, pady=10)
def _build_tab_dns(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="DNS")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
ups = ttk.LabelFrame(frm, text="Upstreams")
ups.pack(fill="x")
def add_field(r: int, label: str) -> ttk.Entry:
ttk.Label(ups, text=label).grid(row=r, column=0, sticky="w", padx=10, pady=4)
e = ttk.Entry(ups, width=60)
e.grid(row=r, column=1, sticky="we", padx=10, pady=4)
return e
ups.columnconfigure(1, weight=1)
self.ent_def1 = add_field(0, "default1")
self.ent_def2 = add_field(1, "default2")
self.ent_meta1 = add_field(2, "meta1")
self.ent_meta2 = add_field(3, "meta2")
btns = ttk.Frame(frm)
btns.pack(fill="x", pady=(10, 0))
ttk.Button(btns, text="Refresh", command=self.refresh_dns_tab).pack(side="left")
ttk.Button(btns, text="Save", command=self.on_save_upstreams).pack(side="left", padx=6)
sm = ttk.LabelFrame(frm, text="SmartDNS")
sm.pack(fill="both", expand=True, pady=(10, 0))
top = ttk.Frame(sm)
top.pack(fill="x", padx=10, pady=(10, 6))
self.lbl_smartdns_state = ttk.Label(top, text="Service: —")
self.lbl_smartdns_state.pack(side="left")
ttk.Button(top, text="Start", command=lambda: self.on_smartdns_action("start")).pack(side="right", padx=6)
ttk.Button(top, text="Stop", command=lambda: self.on_smartdns_action("stop")).pack(side="right")
mid = ttk.Frame(sm)
mid.pack(fill="both", expand=True, padx=10, pady=(0, 10))
ttk.Label(mid, text="Wildcards (one per line):").pack(anchor="w")
self.txt_wildcards = tk.Text(mid, height=10, wrap="none")
self.txt_wildcards.pack(fill="both", expand=True, pady=(4, 6))
btns2 = ttk.Frame(mid)
btns2.pack(fill="x")
ttk.Button(btns2, text="Refresh", command=self.refresh_dns_tab).pack(side="left")
ttk.Button(btns2, text="Save", command=self.on_save_wildcards).pack(side="left", padx=6)
def _build_tab_domains(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="Domains")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
left = ttk.Frame(frm)
left.pack(side="left", fill="y")
right = ttk.Frame(frm)
right.pack(side="left", fill="both", expand=True, padx=(10, 0))
ttk.Label(left, text="Files:").pack(anchor="w")
self.lst_files = tk.Listbox(left, height=6, exportselection=False)
for name in ("bases", "meta", "subs", "static"):
self.lst_files.insert("end", name)
self.lst_files.selection_set(0)
self.lst_files.pack(fill="y", pady=(4, 8))
ttk.Button(left, text="Refresh table", command=self.refresh_domains_tab).pack(fill="x")
ttk.Button(left, text="Load file", command=self.on_domains_load).pack(fill="x", pady=(6, 0))
ttk.Button(left, text="Save file", command=self.on_domains_save).pack(fill="x", pady=(6, 0))
ttk.Button(left, text="Load AGVPN table", command=self.on_load_agvpn_table).pack(fill="x", pady=(10, 0))
ttk.Button(left, text="Load SmartDNS table", command=self.on_load_smartdns_table).pack(fill="x", pady=(6, 0))
top = ttk.Frame(right)
top.pack(fill="x")
self.lbl_domains_info = ttk.Label(top, text="", foreground="gray")
self.lbl_domains_info.pack(side="left")
self.txt_domains = tk.Text(right, wrap="none")
self.txt_domains.pack(fill="both", expand=True, pady=(6, 0))
def _build_tab_trace(self) -> None:
tab = ttk.Frame(self.nb)
self.nb.add(tab, text="Trace")
frm = ttk.Frame(tab)
frm.pack(fill="both", expand=True, padx=10, pady=10)
top = ttk.Frame(frm)
top.pack(fill="x")
self.var_trace_mode = tk.StringVar(value="full")
for m, title in (("full", "Full"), ("gui", "GUI"), ("smartdns", "SmartDNS")):
ttk.Radiobutton(top, text=title, value=m, variable=self.var_trace_mode, command=self.refresh_trace_tab).pack(
side="left", padx=(0, 10)
)
ttk.Button(top, text="Refresh", command=self.refresh_trace_tab).pack(side="right")
self.txt_trace = tk.Text(frm, wrap="none")
self.txt_trace.pack(fill="both", expand=True, pady=(10, 0))
def _wire_events(self) -> None:
self.lst_files.bind("<<ListboxSelect>>", lambda _e: self.on_domains_load())
# ---------------- UI HELPERS ----------------
def _set_text(self, widget: tk.Text, text: str) -> None:
widget.config(state="normal")
widget.delete("1.0", "end")
widget.insert("1.0", text)
widget.config(state="normal")
def _append_text(self, widget: tk.Text, text: str) -> None:
widget.config(state="normal")
widget.insert("end", text)
widget.see("end")
widget.config(state="normal")
def _clean_ui_lines(self, lines) -> str:
# финальная страховка: убираем "Next check" и нормализуем \r
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()
def _get_selected_domains_file(self) -> str:
sel = self.lst_files.curselection()
if not sel:
return "bases"
return str(self.lst_files.get(sel[0]))
def _read_local_file(self, path: str) -> str:
try:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
except Exception:
return ""
def _safe(self, fn, *, title: str = "Error"):
try:
return fn()
except Exception as e:
messagebox.showerror(title, str(e))
return None
def _set_dot(self, canvas: tk.Canvas, dot_id: int, color: str) -> None:
c = (color or "").strip().lower()
if c in ("green", "ok", "true"):
fill = "green"
elif c in ("red", "error", "false"):
fill = "red"
elif c in ("orange", "yellow", "try", "unknown", "pending", "wait"):
fill = "orange"
else:
fill = "gray"
try:
canvas.itemconfigure(dot_id, fill=fill)
except Exception:
pass
def _show_vpn_page(self, which: Literal["main", "login"]) -> None:
if which == "login":
self.vpn_page_login.tkraise()
else:
self.vpn_page_main.tkraise()
def _parse_login_banner(self, text: str, color: str) -> Tuple[bool, str]:
# считаем "logged" если зеленый
is_logged = (color or "").strip().lower() == "green"
email = ""
t = (text or "")
# пытаемся вытащить email из строки
m = re.search(r"([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})", t)
if m:
email = m.group(1)
return is_logged, email
def _set_auth_button(self, logged: bool) -> None:
self.btn_auth.config(text=("Logout" if logged else "Login"))
# ---------------- REFRESH ----------------
def refresh_everything(self) -> None:
self.refresh_status_tab()
self.refresh_vpn_tab()
self.refresh_routes_tab()
self.refresh_dns_tab()
self.refresh_domains_tab()
self.refresh_trace_tab()
self.refresh_login_banner()
def refresh_login_banner(self) -> None:
def work():
view = self.ctrl.get_login_view()
self.lbl_login.config(text=view.text)
self._set_dot(self.login_dot, self._login_dot_id, view.color)
# НЕ гадаем по цвету: используем нормализованную логику controller-а
self._set_auth_button(bool(view.logged_in))
try:
self.lbl_login.config(foreground=view.color)
except tk.TclError:
pass
self._safe(work, title="Login state error")
def refresh_status_tab(self) -> None:
def work():
view = self.ctrl.get_status_overview()
self.st_timestamp.config(text=view.timestamp)
self.st_counts.config(text=view.counts)
self.st_iface.config(text=view.iface_table_mark)
self.st_route.config(text=view.policy_route)
self.st_routesvc.config(text=view.routes_service)
self.st_smartdns.config(text=view.smartdns_service)
self.st_vpnsvc.config(text=view.vpn_service)
self._safe(work, title="Status error")
def refresh_vpn_tab(self) -> None:
def work():
locs = self.ctrl.vpn_locations_view()
self.cmb_location["values"] = [f"{x.iso}{x.label}" for x in locs]
st = self.ctrl.vpn_status_view()
self.lbl_vpn_desired.config(text=f"Desired: {st.desired_location or ''}")
self._set_text(self.txt_vpn, st.pretty_text)
self.var_autoconnect.set(self.ctrl.vpn_autoconnect_enabled())
self._safe(work, title="VPN error")
def refresh_routes_tab(self) -> None:
def work():
self.var_timer.set(self.ctrl.routes_timer_enabled())
self._safe(work, title="Routes error")
def refresh_dns_tab(self) -> None:
def work():
cfg = self.ctrl.dns_upstreams_view()
self.ent_def1.delete(0, "end"); self.ent_def1.insert(0, cfg.default1)
self.ent_def2.delete(0, "end"); self.ent_def2.insert(0, cfg.default2)
self.ent_meta1.delete(0, "end"); self.ent_meta1.insert(0, cfg.meta1)
self.ent_meta2.delete(0, "end"); self.ent_meta2.insert(0, cfg.meta2)
sd = self.ctrl.smartdns_service_view()
self.lbl_smartdns_state.config(text=f"Service: {sd.state}")
wc = self.ctrl.smartdns_wildcards_view()
self._set_text(self.txt_wildcards, "\n".join(wc.domains).strip() + ("\n" if wc.domains else ""))
self._safe(work, title="DNS error")
def refresh_domains_tab(self) -> None:
def work():
table = self.ctrl.domains_table_view()
self.lbl_domains_info.config(text=f"Table lines: {len(table.lines)}")
self._safe(work, title="Domains error")
def refresh_trace_tab(self) -> None:
def work():
mode = cast(TraceMode, self.var_trace_mode.get())
dump = self.ctrl.trace_view(mode)
self._set_text(self.txt_trace, "\n".join(dump.lines).strip() + ("\n" if dump.lines else ""))
self._safe(work, title="Trace error")
# ---------------- LOGIN FLOW (UI) ----------------
def _login_flow_reset_ui(self) -> None:
self._login_cursor = 0
self._login_url_opened = False
self.var_login_url.set("")
self.lbl_login_flow_status.config(text="Status: —")
self.lbl_login_flow_email.config(text="")
self._set_dot(self.login_flow_dot, self._login_flow_dot_id, "orange")
self._set_text(self.txt_login_flow, "")
def _login_flow_set_buttons(self, *, can_open: bool, can_check: bool, can_cancel: bool) -> None:
def set_state(btn: ttk.Button, enabled: bool) -> None:
try:
btn.config(state=("normal" if enabled else "disabled"))
except Exception:
pass
set_state(self.btn_login_open, can_open)
set_state(self.btn_login_copy, bool(self.var_login_url.get().strip()))
set_state(self.btn_login_check, can_check)
set_state(self.btn_login_close, can_cancel)
# stop — страховка, но если уже success/already_logged, можно тоже выключить не обязательно
try:
self.btn_login_stop.config(state="normal")
except Exception:
pass
def _login_flow_autopoll_start(self) -> None:
self._login_flow_active = True
self._login_poll_tick()
def _login_flow_autopoll_stop(self) -> None:
self._login_flow_active = False
if self._login_poll_after_id is not None:
try:
self.after_cancel(self._login_poll_after_id)
except Exception:
pass
self._login_poll_after_id = None
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)
# indicator + status
self._set_dot(self.login_flow_dot, self._login_flow_dot_id, view.dot_color)
self.lbl_login_flow_status.config(text=f"Status: {view.status_text or ''}")
self.lbl_login_flow_email.config(text=(f"User: {view.email}" if view.email else ""))
if view.url:
self.var_login_url.set(view.url)
# buttons
self._login_flow_set_buttons(can_open=view.can_open, can_check=view.can_check, can_cancel=view.can_cancel)
# append cleaned lines
cleaned = self._clean_ui_lines(view.lines)
if cleaned:
self._append_text(self.txt_login_flow, cleaned + "\n")
# auto-open browser once when url appears
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"):
# Авто-обновляем баннер при успехе/уже залогинен
if phase in ("success", "already_logged"):
self.after(250, self.refresh_login_banner)
# и возвращаемся на main страницу VPN, чтобы UX был как у тебя на примере
self.after(500, lambda: self._show_vpn_page("main"))
# на терминале — стопаем polling
self._login_flow_autopoll_stop()
# в терминале делаем кнопки логина неактивными (как в твоём "идеальном" окне)
self._login_flow_set_buttons(can_open=False, can_check=False, can_cancel=False)
try:
self.btn_login_stop.config(state="disabled")
except Exception:
pass
self._safe(work, title="Login flow error")
if self._login_flow_active:
self._login_poll_after_id = self.after(250, self._login_poll_tick)
# ---------------- TOP AUTH BUTTON ----------------
def on_auth_button(self) -> None:
# decide based on current banner
def work():
view = self.ctrl.get_login_view()
if bool(view.logged_in):
self.on_logout()
else:
self.on_start_login()
self._safe(work, title="Auth error")
# ---------------- ACTIONS ----------------
def on_start_login(self) -> None:
def work():
self.ctrl.log_gui("Top Login clicked")
self._login_flow_reset_ui()
start = self.ctrl.login_flow_start()
# reflect start info
self._set_dot(self.login_flow_dot, self._login_flow_dot_id, start.dot_color)
self.lbl_login_flow_status.config(text=f"Status: {start.status_text or ''}")
self.lbl_login_flow_email.config(text=(f"User: {start.email}" if start.email else ""))
if start.url:
self.var_login_url.set(start.url)
cleaned = self._clean_ui_lines(start.lines)
if cleaned:
self._append_text(self.txt_login_flow, cleaned + "\n")
# already logged: banner update and stop
phase = (start.phase or "").strip().lower()
if phase == "already_logged":
self.refresh_login_banner()
messagebox.showinfo("Login", f"Already logged in{f' as {start.email}' if start.email else ''}.")
return
# show login page and start polling
self._show_vpn_page("login")
self._login_cursor = int(start.cursor or 0)
self._login_flow_set_buttons(can_open=start.can_open, can_check=start.can_check, can_cancel=start.can_cancel)
self._login_flow_autopoll_start()
self._safe(work, title="Login start error")
def on_login_back(self) -> None:
self._login_flow_autopoll_stop()
self._show_vpn_page("main")
self.refresh_login_banner()
def on_login_copy(self) -> None:
u = self.var_login_url.get().strip()
if not u:
return
try:
self.master.clipboard_clear()
self.master.clipboard_append(u)
except Exception:
pass
def on_login_open(self) -> None:
def work():
self.ctrl.login_flow_action("open")
u = self.var_login_url.get().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():
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()
self.after(250, self.refresh_login_banner)
self._safe(work, title="Login stop error")
def on_logout(self) -> None:
def work():
if not messagebox.askyesno("Logout", "Logout from AdGuard VPN account?"):
return
res = self.ctrl.vpn_logout()
self.ctrl.log_gui("VPN logout executed")
messagebox.showinfo("Logout", res.pretty_text.strip() or "Done.")
self.refresh_login_banner()
self.refresh_vpn_tab()
self._safe(work, title="Logout error")
def on_toggle_autoconnect(self) -> None:
def work():
enable = bool(self.var_autoconnect.get())
res = self.ctrl.vpn_set_autoconnect(enable)
self._set_text(self.txt_vpn, res.pretty_text)
self.ctrl.log_gui(f"Auto-connect set to {enable}")
self._safe(work, title="Auto-connect error")
def on_set_location(self) -> None:
def work():
val = self.cmb_location.get().strip()
if not val:
messagebox.showinfo("Location", "Choose a location first.")
return
iso = val.split("", 1)[0].strip()
res = self.ctrl.vpn_set_location(iso)
self._set_text(self.txt_vpn, res.pretty_text)
self.ctrl.log_gui(f"Location set to {iso}")
self.refresh_vpn_tab()
self._safe(work, title="Set location error")
def on_routes_action(self, action: str) -> None:
def work():
res = self.ctrl.routes_service_action(action)
self._set_text(self.txt_routes, res.pretty_text)
self.ctrl.log_gui(f"Routes service: {action}")
self.refresh_status_tab()
self._safe(work, title="Routes service error")
def on_routes_clear(self) -> None:
def work():
res = self.ctrl.routes_clear()
self._set_text(self.txt_routes, res.pretty_text)
self.ctrl.log_gui("Routes cleared")
self.refresh_status_tab()
self._safe(work, title="Clear routes error")
def on_toggle_timer(self) -> None:
def work():
enabled = bool(self.var_timer.get())
res = self.ctrl.routes_timer_set(enabled)
self._set_text(self.txt_routes, res.pretty_text)
self.ctrl.log_gui(f"Routes timer set to {enabled}")
self.refresh_status_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)
self.ctrl.log_gui("Policy route fix executed")
self.refresh_status_tab()
self._safe(work, title="Fix policy route error")
def on_save_upstreams(self) -> None:
def work():
cfg = DnsUpstreams(
default1=self.ent_def1.get().strip(),
default2=self.ent_def2.get().strip(),
meta1=self.ent_meta1.get().strip(),
meta2=self.ent_meta2.get().strip(),
)
self.ctrl.dns_upstreams_save(cfg)
self.ctrl.log_gui("DNS upstreams saved")
messagebox.showinfo("DNS", "Saved.")
self.refresh_dns_tab()
self._safe(work, title="Save upstreams error")
def on_smartdns_action(self, action: str) -> None:
def work():
_res = self.ctrl.smartdns_service_action(action)
self.ctrl.log_gui(f"SmartDNS action: {action}")
self.refresh_dns_tab()
self.refresh_trace_tab()
self._safe(work, title="SmartDNS error")
def on_save_wildcards(self) -> None:
def work():
raw = self.txt_wildcards.get("1.0", "end")
domains = [x.strip() for x in raw.splitlines() if x.strip()]
self.ctrl.smartdns_wildcards_save(domains)
self.ctrl.log_gui(f"Wildcards saved: {len(domains)}")
messagebox.showinfo("SmartDNS", "Wildcards saved.")
self.refresh_dns_tab()
self._safe(work, title="Save wildcards error")
def on_domains_load(self) -> None:
def work():
name = self._get_selected_domains_file()
f = self.ctrl.domains_file_load(name)
content = f.content or ""
source = getattr(f, "source", "") or "file"
if not content:
path = f"/etc/selective-vpn/domains/{name}.txt"
content = self._read_local_file(path)
if content:
source = f"{source}+fallback" if source else "fallback"
self._set_text(self.txt_domains, content)
self.lbl_domains_info.config(text=f"{name} (source: {source})")
self.ctrl.log_gui(f"Domains file loaded: {name} source={source}")
self._safe(work, title="Load domains file error")
def on_domains_save(self) -> None:
def work():
name = self._get_selected_domains_file()
content = self.txt_domains.get("1.0", "end")
self.ctrl.domains_file_save(name, content)
self.ctrl.log_gui(f"Domains file saved: {name}")
messagebox.showinfo("Domains", "Saved.")
self.refresh_status_tab()
self._safe(work, title="Save domains file error")
def on_load_agvpn_table(self) -> None:
path = "/var/lib/selective-vpn/last-ips-map.txt"
data = self._read_local_file(path)
self._set_text(self.txt_domains, data or "(empty)")
self.lbl_domains_info.config(text=f"AGVPN table: {path}")
def on_load_smartdns_table(self) -> None:
path = "/etc/selective-vpn/smartdns.conf"
data = self._read_local_file(path)
self._set_text(self.txt_domains, data or "(empty)")
self.lbl_domains_info.config(text=f"SmartDNS table: {path}")
# ---------------- CLOSE HANDLER ----------------
def _on_close(self) -> None:
def work():
if self._login_flow_active:
try:
self.ctrl.login_flow_action("cancel")
except Exception:
pass
try:
self.ctrl.login_flow_stop()
except Exception:
pass
self._login_flow_autopoll_stop()
try:
work()
finally:
self.master.destroy()
def main() -> int:
client = ApiClient.from_env()
ctrl = DashboardController(client)
root = tk.Tk()
try:
root.minsize(900, 650)
except Exception:
pass
try:
style = ttk.Style()
if "clam" in style.theme_names():
style.theme_use("clam")
except Exception:
pass
_app = App(root, ctrl)
root.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(main())