Add service status detection and improve tray icon feedback

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-06-02 14:14:43 +02:00
parent dbee65337b
commit 0a01fbc59a
2 changed files with 217 additions and 12 deletions
+20
View File
@@ -93,3 +93,23 @@ chmod +x ~/.local/bin/nextdns-tray
mkdir -p ~/.config/autostart
cp ~/.local/share/applications/nextdns-tray.desktop ~/.config/autostart/
```
## Status Detection
The tray distinguishes between service state and real DNS usage.
- `Stopped`: NextDNS service is not running.
- `Running / Not In Use`: service is running, but resolver settings do not point to NextDNS.
- `Running / Usage Unknown`: service is running and resolver looks correct, but live verification is temporarily unavailable.
- `Running / In Use`: service is running and usage is verified.
Verification model:
- Fast local checks run on each refresh cycle.
- End-to-end validation uses `https://test.nextdns.io` and is cached for 30 seconds.
- A start/restart action forces an immediate end-to-end re-check.
Notes:
- This validates host DNS routing; application-specific DoH settings may bypass host resolver behavior.
- `curl` is used for the end-to-end check. If unavailable, status may appear as `Usage Unknown`.
+197 -12
View File
@@ -6,6 +6,8 @@ import time
import shutil
import webbrowser
import base64
import json
import ipaddress
from PyQt6.QtWidgets import QApplication, QSystemTrayIcon, QMenu
from PyQt6.QtGui import QIcon, QAction, QPixmap
from PyQt6.QtCore import QTimer
@@ -35,6 +37,13 @@ class NextDNSTray:
self.tray = QSystemTrayIcon()
self.menu = QMenu()
# End-to-end validation cache (seconds)
self.e2e_interval = 30
self.last_e2e_check = 0.0
self.last_e2e_state = None
self.last_e2e_reason = "usage check not run yet"
self.force_e2e_check = True
# Poll status every 5 seconds
self.timer = QTimer()
self.timer.timeout.connect(self.refresh_ui)
@@ -53,19 +62,180 @@ class NextDNSTray:
# Fallback to a system icon if decoding fails
return QIcon.fromTheme("yast-security")
def is_running(self):
"""Check if nextdns service is active."""
def service_status(self):
"""Return daemon status and a human-readable reason."""
try:
result = subprocess.run([NEXTDNS_PATH, "status"], capture_output=True, text=True)
return "running" in result.stdout.lower()
result = subprocess.run(
[NEXTDNS_PATH, "status"],
capture_output=True,
text=True,
timeout=4,
)
running = "running" in (result.stdout or "").lower()
if running:
return True, "service is running"
if result.returncode != 0:
return False, "service is not running"
return False, "service is not running"
except FileNotFoundError:
return False, "nextdns command not found"
except subprocess.TimeoutExpired:
return False, "service status check timed out"
except Exception:
return False
return False, "service status check failed"
def _extract_ips(self, text):
"""Extract valid IP addresses from command output."""
ips = []
for token in text.replace("\n", " ").split():
value = token.strip("[](),;")
if "%" in value:
value = value.split("%", 1)[0]
try:
ipaddress.ip_address(value)
ips.append(value)
except ValueError:
continue
return ips
def resolver_uses_nextdns(self):
"""Check whether system resolver targets local/NextDNS endpoints."""
candidate_ips = []
resolvectl = shutil.which("resolvectl")
if resolvectl:
try:
result = subprocess.run(
[resolvectl, "dns"],
capture_output=True,
text=True,
timeout=4,
)
candidate_ips = self._extract_ips(result.stdout or "")
except Exception:
candidate_ips = []
if not candidate_ips:
try:
with open("/etc/resolv.conf", "r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if line.lower().startswith("nameserver "):
token = line.split(None, 1)[1].strip()
try:
ipaddress.ip_address(token)
candidate_ips.append(token)
except ValueError:
continue
except Exception:
return False, "unable to read resolver configuration"
if not candidate_ips:
return False, "no DNS nameserver found in resolver configuration"
for ip_raw in candidate_ips:
ip = ipaddress.ip_address(ip_raw)
if ip.is_loopback:
return True, "resolver points to local DNS listener"
if ip.version == 4:
text = str(ip)
if text.startswith("45.90.28.") or text.startswith("45.90.30."):
return True, f"resolver points to NextDNS endpoint ({text})"
first = candidate_ips[0]
return False, f"resolver points elsewhere ({first})"
def check_nextdns_usage(self):
"""Validate if DNS resolution is actively going through NextDNS."""
curl = shutil.which("curl")
if not curl:
return None, "curl is not installed"
try:
result = subprocess.run(
[
curl,
"-L",
"-fsS",
"--max-time",
"5",
"https://test.nextdns.io",
],
capture_output=True,
text=True,
timeout=6,
)
payload = json.loads(result.stdout or "{}")
status = str(payload.get("status", "")).lower()
if status == "ok":
return True, "verified by test.nextdns.io"
if status:
return False, f"test.nextdns.io returned status '{status}'"
return None, "test.nextdns.io returned an unexpected response"
except subprocess.CalledProcessError:
return None, "unable to reach test.nextdns.io"
except subprocess.TimeoutExpired:
return None, "test.nextdns.io check timed out"
except json.JSONDecodeError:
return None, "test.nextdns.io returned invalid JSON"
except Exception:
return None, "usage check failed"
def evaluate_health(self):
"""Compute tray health state and explanatory reason."""
service_running, service_reason = self.service_status()
if not service_running:
return {
"state": "stopped",
"service_running": False,
"reason": service_reason,
}
resolver_ok, resolver_reason = self.resolver_uses_nextdns()
if not resolver_ok:
return {
"state": "running_not_in_use",
"service_running": True,
"reason": resolver_reason,
}
now = time.time()
should_check = (
self.force_e2e_check
or self.last_e2e_state is None
or (now - self.last_e2e_check) >= self.e2e_interval
)
if should_check:
self.last_e2e_state, self.last_e2e_reason = self.check_nextdns_usage()
self.last_e2e_check = now
self.force_e2e_check = False
if self.last_e2e_state is True:
return {
"state": "healthy_in_use",
"service_running": True,
"reason": self.last_e2e_reason,
}
if self.last_e2e_state is False:
return {
"state": "running_not_in_use",
"service_running": True,
"reason": self.last_e2e_reason,
}
return {
"state": "running_unknown",
"service_running": True,
"reason": self.last_e2e_reason,
}
def run_cmd(self, cmd):
"""Execute start/stop/restart via pkexec."""
try:
# Only prompts for password when an action is taken
subprocess.run(f"pkexec {NEXTDNS_PATH} {cmd}", shell=True, check=True)
self.force_e2e_check = True
time.sleep(1.5)
self.refresh_ui()
except subprocess.CalledProcessError:
@@ -74,15 +244,24 @@ class NextDNSTray:
def refresh_ui(self):
"""Updates icons and menu with a status header."""
running = self.is_running()
health = self.evaluate_health()
running = health["service_running"]
state = health["state"]
reason = health["reason"]
# Main Tray Icon (The Shield)
if running:
if state == "healthy_in_use":
self.tray.setIcon(self.custom_icon)
self.tray.setToolTip("NextDNS: Running")
self.tray.setToolTip("NextDNS: Running and in use")
elif state == "stopped":
self.tray.setIcon(self.custom_icon_disabled)
self.tray.setToolTip(f"NextDNS: Stopped ({reason})")
elif state == "running_not_in_use":
self.tray.setIcon(self.custom_icon_disabled)
self.tray.setToolTip(f"NextDNS: Running, but DNS not using NextDNS ({reason})")
else:
self.tray.setIcon(self.custom_icon_disabled)
self.tray.setToolTip("NextDNS: Stopped")
self.tray.setToolTip(f"NextDNS: Running (usage check unavailable: {reason})")
self.menu.clear()
@@ -92,9 +271,15 @@ class NextDNSTray:
# title_act.setEnabled(False)
self.menu.addAction(title_act)
status = " Enabled/Running" if running else " Disabled/Stopped"
status_icon = "emblem-checked" if running else "emblem-unmounted"
toggle_status = QAction(QIcon.fromTheme(status_icon), status, self.menu)
status_map = {
"healthy_in_use": ("Running / In Use", "emblem-checked"),
"stopped": ("Stopped", "emblem-unmounted"),
"running_not_in_use": ("Running / Not In Use", "emblem-important"),
"running_unknown": ("Running / Usage Unknown", "dialog-question"),
}
status, status_icon = status_map.get(state, ("Unknown", "dialog-question"))
status_text = f" {status}\n {reason}"
toggle_status = QAction(QIcon.fromTheme(status_icon), status_text, self.menu)
toggle_status.setEnabled(False)
self.menu.addAction(toggle_status)