Merge pull request #1561 from nvandamme/list_subscriptions

[Feature] Blocklist subscriptions plugin with auto scheduled downloads and management UI
This commit is contained in:
Gustavo Iñiguez Goia
2026-04-12 23:31:00 +02:00
committed by GitHub
63 changed files with 13846 additions and 1 deletions
+1 -1
View File
@@ -4,4 +4,4 @@
.vscode/
.idea/
.DS_Store
.DS_Store
@@ -0,0 +1,15 @@
# This file is part of OpenSnitch.
#
# OpenSnitch is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# OpenSnitch is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with OpenSnitch. If not, see <http://www.gnu.org/licenses/>.
@@ -0,0 +1,57 @@
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from PyQt6 import QtCore, QtGui, QtWidgets
class RuleOperatorLike(Protocol):
operand: str
data: str | None
list: list["RuleOperatorLike"]
class RuleLike(Protocol):
name: str | None
enabled: bool
operator: RuleOperatorLike
class StatsDialogProto(Protocol):
"""Typed subset of StatsDialog used by the plugin.
actionsButton is injected by uic from stats.ui and otherwise appears
as unknown to static analyzers.
"""
actionsButton: "QtWidgets.QPushButton"
def windowIcon(self) -> "QtGui.QIcon": ...
class RulesEditorDialogProto(Protocol):
"""Typed subset of RulesEditorDialog used by the plugin controllers."""
_old_rule_name: str
buttonBox: "QtWidgets.QDialogButtonBox"
ruleNameEdit: "QtWidgets.QLineEdit"
ruleDescEdit: "QtWidgets.QPlainTextEdit"
nodesCombo: "QtWidgets.QComboBox"
nodeApplyAllCheck: "QtWidgets.QCheckBox"
uidCombo: "QtWidgets.QComboBox"
uidCheck: "QtWidgets.QCheckBox"
enableCheck: "QtWidgets.QCheckBox"
durationCombo: "QtWidgets.QComboBox"
dstListsCheck: "QtWidgets.QCheckBox"
dstListsLine: "QtWidgets.QLineEdit"
def installEventFilter(self, filterObj: "QtCore.QObject") -> None: ...
def hide(self) -> None: ...
def raise_(self) -> None: ...
def activateWindow(self) -> None: ...
def new_rule(self) -> None: ...
def edit_rule(self, records: Any, _addr: str | None = None) -> None: ...
@@ -0,0 +1,10 @@
"""Runtime compatibility shim for StatsDialog across OpenSnitch versions."""
# Runtime class kept for isinstance checks.
try:
from opensnitch.dialogs.events import StatsDialog
except ImportError:
from opensnitch.dialogs.stats import StatsDialog # type: ignore[assignment]
__all__ = ["StatsDialog"]
@@ -0,0 +1,394 @@
import os
import re
from datetime import datetime
from typing import Any, Final
from urllib.parse import urlparse, unquote
from opensnitch.utils.xdg import xdg_config_home
ACTION_FILE: Final[str] = os.path.join(
xdg_config_home, "opensnitch", "actions", "list_subscriptions.json"
)
DEFAULT_LISTS_DIR: Final[str] = os.path.join(
xdg_config_home, "opensnitch", "list_subscriptions"
)
PLUGIN_DIR: Final[str] = os.path.abspath(os.path.dirname(__file__))
RES_DIR: Final[str] = os.path.join(PLUGIN_DIR, "res")
INTERVAL_UNITS: Final[tuple[str, ...]] = (
"seconds",
"minutes",
"hours",
"days",
"weeks",
)
TIMEOUT_UNITS: Final[tuple[str, ...]] = ("seconds", "minutes", "hours", "days", "weeks")
SIZE_UNITS: Final[tuple[str, ...]] = ("bytes", "KB", "MB", "GB")
TIME_MULT: Final[dict[str, int]] = {
"seconds": 1,
"minutes": 60,
"hours": 60 * 60,
"days": 24 * 60 * 60,
"weeks": 7 * 24 * 60 * 60,
"s": 1,
"m": 60,
"h": 60 * 60,
"d": 24 * 60 * 60,
"w": 7 * 24 * 60 * 60,
}
SHORT_TIME_MULT: Final[dict[str, int]] = {
"s": TIME_MULT["seconds"],
"m": TIME_MULT["minutes"],
"h": TIME_MULT["hours"],
"d": TIME_MULT["days"],
"w": TIME_MULT["weeks"],
}
SIZE_MULT: Final[dict[str, int]] = {
"bytes": 1,
"kb": 1024,
"mb": 1024 * 1024,
"gb": 1024 * 1024 * 1024,
}
DEFAULT_UA: Final[str] = (
"Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0"
)
DEFAULT_NOTIFY_CONFIG: Final[dict[str, dict[str, str]]] = {
"success": {"desktop": "Lists subscriptions updated"},
"error": {"desktop": "Error updating lists subscriptions"},
}
def now_iso():
return datetime.now().astimezone().isoformat()
def parse_iso(ts: str):
try:
return datetime.fromisoformat(ts)
except Exception:
return None
def normalize_iso_timestamp(value: Any, fallback: str | None = None):
text = str(value or "").strip()
if text != "" and parse_iso(text) is not None:
return text
if fallback:
return fallback
return now_iso()
def opt_int(value: Any):
try:
return int(value) if value is not None else None
except Exception:
return None
def opt_str(value: Any):
try:
if value is None:
return None
normalized = (str(value) or "").strip().lower()
return normalized if normalized != "" else None
except Exception:
return None
def display_str(value: Any):
if value is None:
return ""
return str(value)
def strip_or_none(value: Any):
text = str(value or "").strip()
return text or None
def normalize_lists_dir(path: str | None):
default_dir = os.path.join(xdg_config_home, "opensnitch", "list_subscriptions")
raw = (path or "").strip()
if raw == "":
raw = default_dir
expanded = os.path.expandvars(os.path.expanduser(raw))
if not os.path.isabs(expanded):
return os.path.abspath(expanded)
return expanded
def is_valid_url(value: str | None):
parsed = urlparse(str(value or "").strip())
return parsed.scheme in {"http", "https"} and parsed.netloc != ""
def safe_filename(value: Any):
return os.path.basename((str(value or "")).strip())
def normalized_list_type(value: str | None):
return (value or "hosts").strip().lower()
def filename_from_url(url: str | None):
try:
parsed = urlparse((url or "").strip())
return safe_filename(unquote(parsed.path or ""))
except Exception:
return ""
def slugify_name(name: str | None):
raw = (name or "").strip().lower()
if raw == "":
return "subscription.list"
slug = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-._")
if slug == "":
slug = "subscription"
if "." not in slug:
slug += ".list"
return safe_filename(slug)
def deslugify_filename(filename: str | None, list_type: str | None):
safe = safe_filename(filename)
base, _ext = os.path.splitext(safe)
suffix = f"-{normalized_list_type(list_type)}"
if base.lower().endswith(suffix):
base = base[: -len(suffix)]
pretty = re.sub(r"[-_.]+", " ", base).strip()
pretty = re.sub(r"\s+", " ", pretty)
if pretty == "":
return safe
return pretty.title()
def filename_from_content_disposition(value: str | None):
cd = str(value or "").strip()
if cd == "":
return ""
filename = ""
m_star = re.search(
r'filename\*\s*=\s*[^\'";]+\'[^\'";]*\'([^;]+)', cd, re.IGNORECASE
)
if m_star:
filename = unquote(m_star.group(1).strip().strip('"'))
if filename == "":
params = {}
try:
raw_params = ";".join(cd.split(";")[1:])
for part in raw_params.split(";"):
if "=" not in part:
continue
key, raw_value = part.split("=", 1)
params[key.strip().lower()] = raw_value.strip().strip('"')
except Exception:
params = {}
raw = params.get("filename")
if raw:
filename = unquote(str(raw)).strip()
return safe_filename(filename)
def derive_filename(
name: str | None,
url: str | None,
filename: str | None,
header_filename: str | None = None,
):
fn = safe_filename(header_filename)
if fn != "":
return fn
fn = safe_filename(filename)
if fn != "":
return fn
fn = filename_from_url(url)
if fn != "":
return fn
return slugify_name(name)
def ensure_filename_type_suffix(filename: str, list_type: str):
fn = safe_filename(filename)
base, ext = os.path.splitext(fn)
ltype = normalized_list_type(list_type)
suffix = f"-{ltype}"
if not base.lower().endswith(suffix):
base = f"{base}{suffix}" if base else ltype
if ext == "":
ext = ".txt"
return safe_filename(f"{base}{ext}")
def normalized_subscription_filename(filename: str | None, list_type: str | None):
safe_name = safe_filename(filename)
if safe_name == "":
safe_name = "subscription.list"
return ensure_filename_type_suffix(safe_name, normalized_list_type(list_type))
def subscription_dirname(filename: str | None, list_type: str | None):
safe_name = normalized_subscription_filename(filename, list_type)
base, _ext = os.path.splitext(safe_name)
normalized_type = normalized_list_type(list_type)
suffix = f"-{normalized_type}"
dirname = base if base else "subscription"
if not dirname.lower().endswith(suffix):
dirname = f"{dirname}{suffix}"
return dirname
def list_file_path(lists_dir: str, filename: str | None, list_type: str | None):
safe_name = normalized_subscription_filename(filename, list_type)
return os.path.join(lists_dir, "sources.list.d", safe_name)
def subscription_rule_dir(lists_dir: str, filename: str | None, list_type: str | None):
return os.path.join(
lists_dir,
"rules.list.d",
subscription_dirname(filename, list_type),
)
def normalize_unit(value: str | None, allowed: tuple[str, ...], fallback: str):
normalized = (value or "").strip().lower()
for unit in allowed:
if unit.lower() == normalized:
return unit
return fallback
def timestamp_sort_key(value: str | None):
normalized = str(value or "").strip()
return (normalized == "", normalized)
def normalize_group(group: str | None):
raw = (group or "").strip().lower()
if raw == "":
return ""
raw = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-._")
return raw
def normalize_groups(groups: Any):
out: list[str] = []
if isinstance(groups, (list, tuple, set)):
raw_items = [str(x) for x in groups]
else:
raw_items = str(groups or "").split(",")
seen: set[str] = set()
for item in raw_items:
g = normalize_group(item)
if g == "" or g == "all" or g in seen:
continue
seen.add(g)
out.append(g)
return out
def dedupe_subscription_identity(
filename: str,
name: str,
url: str,
list_type: str,
seen_filenames: dict[str, str] | None,
):
if seen_filenames is None:
return filename, name, False
key = filename
seen_url = seen_filenames.get(key)
if seen_url is None:
seen_filenames[key] = url
return filename, name, False
if seen_url == url:
return filename, name, True
base, ext = os.path.splitext(filename)
if ext == "":
ext = ".txt"
suffix = f"-{normalized_list_type(list_type)}"
root = base
if root.lower().endswith(suffix):
root = root[: -len(suffix)]
root = root.rstrip("-")
n = 2
candidate = filename
while candidate in seen_filenames:
candidate = f"{root}-{n}{suffix}{ext}"
n += 1
display_name = (name or "").strip()
if display_name == "":
display_name = root or "subscription"
seen_filenames[candidate] = url
return candidate, f"{display_name} ({n - 1})", False
def to_seconds(value: Any, units: str | None, default_seconds: int):
try:
if value is None:
return default_seconds
u = (units or "seconds").lower()
mult = TIME_MULT.get(u)
if mult is None:
return default_seconds
sec = int(value) * mult
return sec if sec > 0 else default_seconds
except Exception:
return default_seconds
def parse_compact_duration(value: Any):
if not isinstance(value, str):
return None
s = value.strip().lower().replace(" ", "")
if not s:
return None
total = 0
pos = 0
for m in re.finditer(r"(\d+)([smhdw])", s):
if m.start() != pos:
return None
total += int(m.group(1)) * SHORT_TIME_MULT[m.group(2)]
pos = m.end()
if pos != len(s):
return None
return total if total > 0 else None
def to_max_bytes(value: Any, units: str | None, default_bytes: int):
try:
if value is None:
return default_bytes
u = (units or "bytes").lower()
mult = SIZE_MULT.get(u)
if mult is None:
return default_bytes
out = int(value) * mult
return out if out > 0 else default_bytes
except Exception:
return default_bytes
def is_hosts_file_like(sample_lines: list[str]):
valid = 0
total = 0
for line in sample_lines:
s = line.strip()
if not s or s.startswith("#"):
continue
total += 1
parts = s.split()
if len(parts) >= 2 and parts[0] in ("0.0.0.0", "127.0.0.1", "::"):
if "." in parts[1] and "/" not in parts[1]:
valid += 1
elif len(parts) == 1 and "." in parts[0]:
valid += 1
if total <= 10:
return True
return (valid / max(total, 1)) >= 0.60
@@ -0,0 +1,19 @@
{
"version": 1,
"url": "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/light.txt",
"format": "hosts",
"etag": "\"abc123\"",
"last_modified": "Sun, 01 Mar 2026 08:40:00 GMT",
"last_checked": "2026-03-01T12:00:00+01:00",
"last_updated": "2026-03-01T12:00:02+01:00",
"last_result": "updated",
"last_error": "",
"fail_count": 0,
"backoff_until": "",
"bytes": 1234567
}
@@ -0,0 +1,46 @@
{
"name": "listSubscriptionsActions",
"created": "",
"updated": "",
"description": "Manage and auto-update blocklist subscriptions (hosts format)",
"type": ["global", "main-dialog"],
"actions": {
"list_subscriptions": {
"enabled": true,
"config": {
"lists_dir": "~/.config/opensnitch/list_subscriptions",
"interval": 24,
"interval_units": "hours",
"timeout": 20,
"timeout_units": "seconds",
"max_size": 50,
"max_size_units": "MB",
"subscriptions": [
{
"name": "HaGeZi Light Hosts",
"enabled": true,
"url": "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/light.txt",
"format": "hosts",
"filename": "hagezi-light.txt",
"interval": 12,
"interval_units": "hours"
}
],
"notify": {
"success": {
"desktop": "Lists subscriptions updated"
},
"error": {
"desktop": "Error updating lists subscriptions"
}
}
}
}
}
}
@@ -0,0 +1 @@
@@ -0,0 +1,101 @@
import errno
import os
import time
class FileLock:
def __init__(self, lock_path: str):
self.lock_path = lock_path
self.fd: int | None = None
def _read_owner_pid(self):
try:
with open(self.lock_path, "r", encoding="utf-8") as f:
raw = f.read().strip()
except FileNotFoundError:
return None
except Exception:
return -1
if raw == "":
return -1
try:
return int(raw)
except Exception:
return -1
def _pid_is_alive(self, pid: int):
if pid <= 0:
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except Exception:
return True
return True
def is_stale(self, max_age: float = 30.0):
try:
st = os.stat(self.lock_path)
except FileNotFoundError:
return False
except Exception:
return False
pid = self._read_owner_pid()
if pid is not None and pid > 0 and self._pid_is_alive(pid):
return False
age = time.time() - st.st_mtime
return age > max_age
def break_stale(self, max_age: float = 30.0):
if not self.is_stale(max_age=max_age):
return False
try:
os.unlink(self.lock_path)
return True
except FileNotFoundError:
return True
except Exception:
return False
def acquire(self):
pid = os.getpid()
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
try:
fd = os.open(self.lock_path, flags, 0o600)
try:
os.write(fd, f"{pid}\n".encode("utf-8"))
os.fsync(fd)
except Exception:
try:
os.close(fd)
finally:
try:
os.unlink(self.lock_path)
except Exception:
pass
raise
self.fd = fd
return True
except FileExistsError:
return False
except OSError as exc:
if exc.errno == errno.EEXIST:
return False
raise
def release(self):
try:
if self.fd is not None:
os.close(self.fd)
finally:
self.fd = None
try:
os.unlink(self.lock_path)
except FileNotFoundError:
pass
@@ -0,0 +1,59 @@
import json
import os
import time
from typing import Any
from opensnitch.plugins.list_subscriptions.io.lock import FileLock
def read_json(path: str):
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def write_json_atomic(path: str, obj: dict[str, Any]):
d = os.path.dirname(path)
if d:
os.makedirs(d, exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(obj, f, indent=2, sort_keys=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def json_lock_path(path: str):
return f"{path}.lock"
def read_json_locked(path: str, timeout: float = 5.0, poll_interval: float = 0.05):
lock_path = json_lock_path(path)
lock = FileLock(lock_path)
deadline = time.monotonic() + max(timeout, 0.0)
while os.path.exists(lock_path):
lock.break_stale()
if not os.path.exists(lock_path):
break
if time.monotonic() >= deadline:
raise TimeoutError(f"timed out waiting for lock: {lock_path}")
time.sleep(poll_interval)
return read_json(path)
def write_json_atomic_locked(
path: str,
obj: dict[str, Any],
timeout: float = 5.0,
poll_interval: float = 0.05,
):
lock = FileLock(json_lock_path(path))
deadline = time.monotonic() + max(timeout, 0.0)
while not lock.acquire():
if time.monotonic() >= deadline:
raise TimeoutError(f"timed out waiting for lock: {lock.lock_path}")
time.sleep(poll_interval)
try:
write_json_atomic(path, obj)
finally:
lock.release()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
from opensnitch.plugins.list_subscriptions._utils import normalize_iso_timestamp, now_iso
from opensnitch.plugins.list_subscriptions.models.config import MutablePluginConfig
from dataclasses import dataclass, field
from typing import Any
@dataclass
class MutableActionConfig:
enabled: bool = False
plugin: MutablePluginConfig = field(default_factory=MutablePluginConfig.default)
action_name: str = "listSubscriptionsActions"
created: str = ""
updated: str = ""
description: str = "Manage and auto-update blocklist subscriptions (hosts format)"
types: list[str] = field(default_factory=lambda: ["global", "main-dialog"])
@staticmethod
def from_action_dict(raw_action: dict[str, Any], lists_dir: str | None = None):
action_name = str(raw_action.get("name", "listSubscriptionsActions"))
created = normalize_iso_timestamp(raw_action.get("created"))
updated = normalize_iso_timestamp(raw_action.get("updated"), fallback=created)
description = str(
raw_action.get(
"description",
"Manage and auto-update blocklist subscriptions (hosts format)",
)
)
action_types_raw = raw_action.get("type", ["global", "main-dialog"])
if isinstance(action_types_raw, list):
action_types = [str(t) for t in action_types_raw]
else:
action_types = ["global", "main-dialog"]
actions_obj = raw_action.get("actions", {})
action_cfg = (
actions_obj.get("list_subscriptions", {})
if isinstance(actions_obj, dict)
else {}
)
plugin_cfg_raw = (
action_cfg.get("config", {}) if isinstance(action_cfg, dict) else {}
)
plugin_cfg = plugin_cfg_raw if isinstance(plugin_cfg_raw, dict) else {}
mutable_plugin = MutablePluginConfig.from_dict(
plugin_cfg, lists_dir=plugin_cfg.get("lists_dir") or lists_dir
)
enabled = (
bool(action_cfg.get("enabled", False))
if isinstance(action_cfg, dict)
else False
)
return MutableActionConfig(
enabled=enabled,
plugin=mutable_plugin,
action_name=action_name,
created=created,
updated=updated,
description=description,
types=action_types,
)
@staticmethod
def default(lists_dir: str | None = None):
created = now_iso()
return MutableActionConfig(
enabled=True,
plugin=MutablePluginConfig.default(lists_dir),
created=created,
updated=created,
)
def to_action_dict(self):
created = normalize_iso_timestamp(self.created)
updated = now_iso()
self.created = created
self.updated = updated
return {
"name": self.action_name,
"created": created,
"updated": updated,
"description": self.description,
"type": list(self.types),
"actions": {
"list_subscriptions": {
"enabled": bool(self.enabled),
"config": self.plugin.to_dict(),
}
},
}
@@ -0,0 +1,132 @@
from opensnitch.plugins.list_subscriptions._utils import DEFAULT_NOTIFY_CONFIG, DEFAULT_UA, normalize_lists_dir
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
from opensnitch.plugins.list_subscriptions.models.subscriptions import MutableSubscriptionSpec, SubscriptionSpec, normalize_subscription_identities
from dataclasses import dataclass, field, replace
from typing import Any
@dataclass(frozen=True)
class PluginConfig:
defaults: GlobalDefaults = field(
default_factory=lambda: GlobalDefaults.from_dict({})
)
subscriptions: list[SubscriptionSpec] = field(default_factory=list)
notify: dict[str, Any] = field(default_factory=lambda: dict(DEFAULT_NOTIFY_CONFIG))
@staticmethod
def from_dict(
raw_cfg: dict[str, Any],
lists_dir: str | None = None,
invalidate_duplicates: bool = False,
):
raw_cfg = raw_cfg or {}
if not isinstance(raw_cfg, dict):
raw_cfg = {}
defaults = GlobalDefaults.from_dict(raw_cfg, lists_dir)
subs: list[SubscriptionSpec] = []
for item in raw_cfg.get("subscriptions") or []:
sub = SubscriptionSpec.from_dict(
item,
defaults,
)
if sub is not None:
subs.append(sub)
normalized_subs = normalize_subscription_identities(
subs,
invalidate_duplicates=invalidate_duplicates,
clone=lambda sub, name, url, filename, list_type: replace(
sub,
name=name,
url=url,
filename=filename,
format=list_type,
),
)
if normalized_subs is None:
normalized_subs = []
notify = raw_cfg.get("notify")
if not isinstance(notify, dict):
notify = dict(DEFAULT_NOTIFY_CONFIG)
return PluginConfig(
defaults=defaults, subscriptions=normalized_subs, notify=notify
)
@dataclass
class MutablePluginConfig:
defaults: GlobalDefaults = field(
default_factory=lambda: GlobalDefaults.from_dict({})
)
subscriptions: list[MutableSubscriptionSpec] = field(default_factory=list)
notify: dict[str, Any] = field(default_factory=lambda: dict(DEFAULT_NOTIFY_CONFIG))
@staticmethod
def from_plugin_config(config: PluginConfig):
return MutablePluginConfig(
defaults=config.defaults,
subscriptions=[
MutableSubscriptionSpec.from_spec(sub) for sub in config.subscriptions
],
notify=dict(config.notify),
)
@staticmethod
def from_dict(raw_cfg: dict[str, Any], lists_dir: str | None = None):
compiled_cfg = PluginConfig.from_dict(raw_cfg, lists_dir=lists_dir)
return MutablePluginConfig.from_plugin_config(compiled_cfg)
@staticmethod
def default(lists_dir: str | None = None):
return MutablePluginConfig(
defaults=GlobalDefaults.from_dict({}, lists_dir=lists_dir),
subscriptions=[],
notify=dict(DEFAULT_NOTIFY_CONFIG),
)
def normalize_subscriptions(self, invalidate_duplicates: bool = False):
normalized = normalize_subscription_identities(
self.subscriptions,
invalidate_duplicates=invalidate_duplicates,
clone=lambda sub, name, url, filename, list_type: MutableSubscriptionSpec(
name=name,
url=url,
filename=filename,
groups=list(sub.groups),
enabled=sub.enabled,
format=list_type,
interval=sub.interval,
interval_units=sub.interval_units,
timeout=sub.timeout,
timeout_units=sub.timeout_units,
max_size=sub.max_size,
max_size_units=sub.max_size_units,
),
)
if normalized is None:
return None
self.subscriptions = normalized
return normalized
def to_dict(self):
return {
"lists_dir": normalize_lists_dir(self.defaults.lists_dir),
"interval": int(self.defaults.interval),
"interval_units": self.defaults.interval_units,
"timeout": int(self.defaults.timeout),
"timeout_units": self.defaults.timeout_units,
"max_size": int(self.defaults.max_size),
"max_size_units": self.defaults.max_size_units,
"user_agent": (
self.defaults.user_agent
if self.defaults.user_agent is not None
else DEFAULT_UA
),
"subscriptions": [sub.to_dict() for sub in self.subscriptions],
"notify": self.notify,
}
@@ -0,0 +1,41 @@
from typing import TypedDict
from enum import IntEnum
class SubscriptionEventItem(TypedDict, total=False):
key: str
name: str
url: str
filename: str
format: str
state: str | None
path: str | None
class SubscriptionEventPayload(TypedDict, total=False):
enabled: bool
name: str
url: str
filename: str
format: str
groups: list[str]
interval: int | None
interval_units: str | None
timeout: int | None
timeout_units: str | None
max_size: int | None
max_size_units: str | None
class RuntimeEventType(IntEnum):
RUNTIME_ENABLED = 1
CONFIG_RELOADED = 2
RUNTIME_DISABLED = 3
RUNTIME_STOPPED = 4
RUNTIME_ERROR = 5
DOWNLOAD_STARTED = 6
DOWNLOAD_FINISHED = 7
DOWNLOAD_FAILED = 8
FILE_SAVE_FINISHED = 9
FILE_SAVE_ERROR = 10
FILE_LOAD_FINISHED = 11
FILE_LOAD_ERROR = 12
@@ -0,0 +1,42 @@
from opensnitch.plugins.list_subscriptions._utils import DEFAULT_UA, normalize_lists_dir
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class GlobalDefaults:
lists_dir: str
interval: int = 24
interval_units: str = "hours"
timeout: int = 60
timeout_units: str = "seconds"
max_size: int = 20
max_size_units: str = "MB"
user_agent: str | None = DEFAULT_UA
@staticmethod
def from_dict(d: dict[str, Any], lists_dir: str | None = None):
lists_dir = normalize_lists_dir(str(d.get("lists_dir") or lists_dir or ""))
def _int(v: int | float | str | None, default: int):
try:
return int(v) if v is not None else default
except Exception:
return default
def _str(v: str | None, default: str):
v = (v or "").strip()
return v if v else default
return GlobalDefaults(
lists_dir=lists_dir,
interval=_int(d.get("interval"), 24),
interval_units=_str(d.get("interval_units"), "hours"),
timeout=_int(d.get("timeout"), 60),
timeout_units=_str(d.get("timeout_units"), "seconds"),
max_size=_int(d.get("max_size"), 20),
max_size_units=_str(d.get("max_size_units"), "MB"),
user_agent=(d.get("user_agent") or DEFAULT_UA),
)
@@ -0,0 +1,51 @@
from dataclasses import asdict, dataclass
from typing import Any
@dataclass
class ListMetadata:
version: int = 1
url: str = ""
format: str = "hosts"
etag: str = ""
last_modified: str = ""
last_checked: str = ""
last_updated: str = ""
backoff_until: str = ""
last_result: str = "never"
last_error: str = ""
fail_count: int = 0
bytes: int = 0
@staticmethod
def from_dict(d: dict[str, Any]):
m = ListMetadata()
if not isinstance(d, dict):
return m
def _int(v: Any, default: int):
try:
return int(v) if v is not None else default
except Exception:
return default
def _str(v: Any, default: str = ""):
return str(v or default)
m.version = _int(d.get("version", 1), 1)
m.url = _str(d.get("url", ""))
m.format = _str(d.get("format", "hosts")) or "hosts"
m.etag = _str(d.get("etag", ""))
m.last_modified = _str(d.get("last_modified", ""))
m.last_checked = _str(d.get("last_checked", ""))
m.last_updated = _str(d.get("last_updated", ""))
m.backoff_until = _str(d.get("backoff_until", ""))
m.last_result = _str(d.get("last_result", "never")) or "never"
m.last_error = _str(d.get("last_error", ""))
m.fail_count = _int(d.get("fail_count", 0), 0)
m.bytes = _int(d.get("bytes", 0), 0)
return m
def to_dict(self):
return asdict(self)
@@ -0,0 +1,284 @@
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, TypeVar
from opensnitch.plugins.list_subscriptions._utils import dedupe_subscription_identity, derive_filename, ensure_filename_type_suffix, normalize_groups, opt_int, opt_str, parse_compact_duration, safe_filename, to_max_bytes, to_seconds
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
SubscriptionLike = TypeVar(
"SubscriptionLike", "SubscriptionSpec", "MutableSubscriptionSpec"
)
@dataclass(frozen=True)
class SubscriptionSpec:
name: str
url: str
filename: str
groups: tuple[str, ...] = ()
enabled: bool = True
format: str = "hosts"
interval: int | None = None
interval_units: str | None = None
timeout: int | None = None
timeout_units: str | None = None
max_size: int | None = None
max_size_units: str | None = None
interval_seconds: int = 24 * 3600
timeout_seconds: int = 60
max_bytes: int = 20 * 1024 * 1024
@staticmethod
def from_dict(
d: dict[str, Any] | None,
defaults: GlobalDefaults | None = None,
require_url: bool = True,
ensure_suffix: bool = True,
):
d = d or {}
defaults = defaults or GlobalDefaults.from_dict({})
name = (d.get("name") or "").strip()
url = (d.get("url") or "").strip()
list_type = str(d.get("format", "hosts") or "hosts").strip().lower()
filename = derive_filename(name, url, d.get("filename"))
if ensure_suffix and filename != "":
filename = ensure_filename_type_suffix(filename, list_type)
elif not ensure_suffix and filename == "":
filename = ""
groups_raw = d.get("groups")
if "group" in d:
legacy_group = d.get("group")
if isinstance(groups_raw, (list, tuple, set)):
groups_raw = list(groups_raw) + [legacy_group]
elif groups_raw is None:
groups_raw = [legacy_group]
else:
groups_raw = [groups_raw, legacy_group]
groups = normalize_groups(groups_raw)
if require_url and not url:
return None
if require_url and not name:
name = filename
interval_raw: Any = d.get("interval")
timeout_raw: Any = d.get("timeout")
interval_units_raw: Any = d.get("interval_units")
timeout_units_raw: Any = d.get("timeout_units")
interval = opt_int(interval_raw)
interval_units_opt = opt_str(interval_units_raw)
interval_units = interval_units_opt
timeout = opt_int(timeout_raw)
timeout_units_opt = opt_str(timeout_units_raw)
timeout_units = timeout_units_opt
max_size = opt_int(d.get("max_size"))
max_size_units = opt_str(d.get("max_size_units"))
default_interval_seconds = to_seconds(
defaults.interval, defaults.interval_units, 24 * 3600
)
default_timeout_seconds = to_seconds(
defaults.timeout, defaults.timeout_units, 60
)
default_max_bytes = to_max_bytes(
defaults.max_size, defaults.max_size_units, 20 * 1024 * 1024
)
effective_interval = interval if interval is not None else defaults.interval
effective_interval_units = interval_units or defaults.interval_units
effective_timeout = timeout if timeout is not None else defaults.timeout
effective_timeout_units = timeout_units or defaults.timeout_units
effective_max_size = max_size if max_size is not None else defaults.max_size
effective_max_size_units = max_size_units or defaults.max_size_units
interval_seconds: int | None = None
interval_is_composite = False
if interval_units_opt is None:
interval_seconds = parse_compact_duration(interval_raw)
interval_is_composite = interval_seconds is not None
if interval_seconds is None:
interval_seconds = to_seconds(
effective_interval, effective_interval_units, default_interval_seconds
)
elif interval_is_composite:
interval = interval_seconds
interval_units = "composite"
timeout_seconds: int | None = None
timeout_is_composite = False
if timeout_units_opt is None:
timeout_seconds = parse_compact_duration(timeout_raw)
timeout_is_composite = timeout_seconds is not None
if timeout_seconds is None:
timeout_seconds = to_seconds(
effective_timeout, effective_timeout_units, default_timeout_seconds
)
elif timeout_is_composite:
timeout = timeout_seconds
timeout_units = "composite"
max_bytes = to_max_bytes(
effective_max_size, effective_max_size_units, default_max_bytes
)
return SubscriptionSpec(
name=name,
url=url,
filename=filename,
groups=tuple(groups),
enabled=bool(d.get("enabled", True)),
format=list_type,
interval=interval,
interval_units=interval_units,
timeout=timeout,
timeout_units=timeout_units,
max_size=max_size,
max_size_units=max_size_units,
interval_seconds=interval_seconds,
timeout_seconds=timeout_seconds,
max_bytes=max_bytes,
)
@dataclass
class MutableSubscriptionSpec:
name: str = ""
url: str = ""
filename: str = ""
groups: list[str] = field(default_factory=list)
enabled: bool = True
format: str = "hosts"
interval: int | None = None
interval_units: str | None = None
timeout: int | None = None
timeout_units: str | None = None
max_size: int | None = None
max_size_units: str | None = None
@staticmethod
def from_spec(spec: SubscriptionSpec):
return MutableSubscriptionSpec(
name=spec.name,
url=spec.url,
filename=spec.filename,
groups=list(spec.groups),
enabled=spec.enabled,
format=spec.format,
interval=spec.interval,
interval_units=spec.interval_units,
timeout=spec.timeout,
timeout_units=spec.timeout_units,
max_size=spec.max_size,
max_size_units=spec.max_size_units,
)
@staticmethod
def from_dict(
d: dict[str, Any] | None,
defaults: GlobalDefaults | None = None,
require_url: bool = True,
ensure_suffix: bool = True,
):
spec = SubscriptionSpec.from_dict(
d,
defaults,
require_url=require_url,
ensure_suffix=ensure_suffix,
)
if spec is None:
return None
d = d or {}
def _has_value(value: Any):
return value is not None and str(value).strip() != ""
return MutableSubscriptionSpec(
name=spec.name,
url=spec.url,
filename=spec.filename,
groups=list(spec.groups),
enabled=spec.enabled,
format=spec.format,
interval=(
spec.interval
if _has_value(d.get("interval")) or spec.interval_units == "composite"
else None
),
interval_units=(
spec.interval_units
if _has_value(d.get("interval_units"))
or spec.interval_units == "composite"
else None
),
timeout=(
spec.timeout
if _has_value(d.get("timeout")) or spec.timeout_units == "composite"
else None
),
timeout_units=(
spec.timeout_units
if _has_value(d.get("timeout_units"))
or spec.timeout_units == "composite"
else None
),
max_size=spec.max_size if _has_value(d.get("max_size")) else None,
max_size_units=(
spec.max_size_units if _has_value(d.get("max_size_units")) else None
),
)
def to_dict(self):
data: dict[str, Any] = {
"enabled": bool(self.enabled),
"name": (self.name or "").strip(),
"url": (self.url or "").strip(),
"filename": safe_filename(self.filename),
"format": (self.format or "hosts").strip().lower(),
"groups": normalize_groups(self.groups),
}
if self.interval is not None:
data["interval"] = int(self.interval)
data["interval_units"] = (self.interval_units or "hours").strip().lower()
if self.timeout is not None:
data["timeout"] = int(self.timeout)
data["timeout_units"] = (self.timeout_units or "seconds").strip().lower()
if self.max_size is not None:
data["max_size"] = int(self.max_size)
data["max_size_units"] = (self.max_size_units or "MB").strip()
return data
def normalize_subscription_identities(
subscriptions: list[SubscriptionLike],
invalidate_duplicates: bool = False,
clone: (
Callable[[SubscriptionLike, str, str, str, str], SubscriptionLike] | None
) = None,
):
normalized: list[SubscriptionLike] = []
seen_filenames: dict[str, str] = {}
for sub in subscriptions:
url = (sub.url or "").strip()
if url == "":
return None
list_type = (sub.format or "hosts").strip().lower()
filename = ensure_filename_type_suffix(
derive_filename(sub.name, url, sub.filename), list_type
)
name = (sub.name or "").strip() or filename
filename, name, duplicate_same_url = dedupe_subscription_identity(
filename,
name,
url,
list_type,
seen_filenames,
)
if duplicate_same_url and invalidate_duplicates:
return None
if clone is None:
normalized.append(sub)
else:
normalized.append(clone(sub, name, url, filename, list_type))
return normalized
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>AttachedRulesDialog</class>
<widget class="QDialog" name="AttachedRulesDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>760</width>
<height>420</height>
</rect>
</property>
<property name="windowTitle">
<string>Attached rules</string>
</property>
<layout class="QVBoxLayout" name="rootLayout">
<property name="leftMargin">
<number>12</number>
</property>
<property name="topMargin">
<number>12</number>
</property>
<property name="rightMargin">
<number>12</number>
</property>
<property name="bottomMargin">
<number>12</number>
</property>
<property name="spacing">
<number>8</number>
</property>
<item>
<widget class="QTableWidget" name="rules_table"/>
</item>
<item>
<layout class="QHBoxLayout" name="buttonsLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="create_button">
<property name="text">
<string>Create rule</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="edit_button">
<property name="text">
<string>Edit selected</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="toggle_button">
<property name="text">
<string>Disable</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="remove_button">
<property name="text">
<string>Remove</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="close_button">
<property name="text">
<string>Close</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 640 640"
version="1.1"
id="svg1"
sodipodi:docname="ban-solid-full.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.8125"
inkscape:cx="319.72414"
inkscape:cy="320"
inkscape:window-width="2560"
inkscape:window-height="1368"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<!--!Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2026 Fonticons, Inc.-->
<path
d="M431.2 476.5L163.5 208.8C141.1 240.2 128 278.6 128 320C128 426 214 512 320 512C361.5 512 399.9 498.9 431.2 476.5zM476.5 431.2C498.9 399.8 512 361.4 512 320C512 214 426 128 320 128C278.5 128 240.1 141.1 208.8 163.5L476.5 431.2zM64 320C64 178.6 178.6 64 320 64C461.4 64 576 178.6 576 320C576 461.4 461.4 576 320 576C178.6 576 64 461.4 64 320z"
id="path1"
style="fill:#ff0000" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>BulkEditDialog</class>
<widget class="QDialog" name="BulkEditDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>640</width>
<height>360</height>
</rect>
</property>
<property name="windowTitle">
<string>Edit selected subscriptions</string>
</property>
<layout class="QVBoxLayout" name="rootLayout">
<item>
<widget class="QFrame" name="changes_section_bar">
<layout class="QHBoxLayout" name="changesSectionBarLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="changes_section_label">
<property name="text">
<string>Selected changes</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QLabel" name="selection_hint_label">
<property name="text">
<string>Choose which changes to apply to the selected subscriptions.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QTreeWidget" name="changes_tree">
<column>
<property name="text">
<string>Property</string>
</property>
</column>
<column>
<property name="text">
<string>New value</string>
</property>
</column>
</widget>
</item>
<item>
<widget class="Line" name="footer_separator_line">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="buttons_layout">
<item>
<widget class="QLabel" name="error_label">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="buttons_spacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="cancel_button">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="save_button">
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
@@ -0,0 +1,408 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ListSubscriptionsDialog</class>
<widget class="QDialog" name="ListSubscriptionsDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1180</width>
<height>680</height>
</rect>
</property>
<property name="windowTitle">
<string>List subscriptions</string>
</property>
<layout class="QVBoxLayout" name="rootLayout">
<item>
<layout class="QHBoxLayout" name="topRowLayout">
<item>
<widget class="QCheckBox" name="enable_plugin_check">
<property name="text">
<string>Enable list subscriptions plugin</string>
</property>
</widget>
</item>
<item>
<spacer name="topSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="create_file_button">
<property name="text">
<string>Create action file</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="save_button">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="reload_button">
<property name="text">
<string>Reload</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="start_runtime_button">
<property name="text">
<string>Start</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="stop_runtime_button">
<property name="text">
<string>Stop</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="runtime_status_title_label">
<property name="text">
<string>Status</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="runtime_status_label">
<property name="text">
<string>Runtime: inactive</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="defaultsSectionLayout">
<item>
<widget class="QFrame" name="defaults_section_bar">
<layout class="QHBoxLayout" name="defaultsSectionBarLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="defaults_section_label">
<property name="text">
<string>Defaults</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QGridLayout" name="defaultsGridLayout">
<item row="0" column="0">
<widget class="QLabel" name="lists_dir_label">
<property name="text">
<string>Lists directory</string>
</property>
</widget>
</item>
<item row="0" column="1" colspan="5">
<widget class="QLineEdit" name="lists_dir_edit"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="default_interval_label">
<property name="text">
<string>Default interval</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="default_interval_spin"/>
</item>
<item row="1" column="2">
<widget class="QComboBox" name="default_interval_units"/>
</item>
<item row="1" column="3">
<widget class="QLabel" name="default_timeout_label">
<property name="text">
<string>Default timeout</string>
</property>
</widget>
</item>
<item row="1" column="4">
<widget class="QSpinBox" name="default_timeout_spin"/>
</item>
<item row="1" column="5">
<widget class="QComboBox" name="default_timeout_units"/>
</item>
<item row="2" column="0">
<widget class="QLabel" name="default_max_size_label">
<property name="text">
<string>Default max size</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="default_max_size_spin"/>
</item>
<item row="2" column="2">
<widget class="QComboBox" name="default_max_size_units"/>
</item>
<item row="2" column="3">
<widget class="QLabel" name="default_user_agent_label">
<property name="text">
<string>Default User-Agent</string>
</property>
</widget>
</item>
<item row="2" column="4" colspan="2">
<widget class="QLineEdit" name="default_user_agent"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="node_label">
<property name="text">
<string>Node</string>
</property>
</widget>
</item>
<item row="3" column="1" colspan="2">
<widget class="QComboBox" name="nodes_combo"/>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="tableSectionLayout">
<item>
<widget class="QFrame" name="table_section_bar">
<layout class="QHBoxLayout" name="tableSectionBarLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="table_section_label">
<property name="text">
<string>List subscriptions</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="tableContentLayout">
<item>
<widget class="QTableWidget" name="table"/>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="actionsRowLayout">
<item>
<layout class="QVBoxLayout" name="globalActionsSectionLayout">
<item>
<widget class="QFrame" name="global_actions_bar">
<layout class="QHBoxLayout" name="globalActionsBarLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="global_actions_label">
<property name="text">
<string>Global actions</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="globalActionsLayout">
<item>
<widget class="QPushButton" name="add_sub_button">
<property name="text">
<string>Add subscription</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="refresh_state_button">
<property name="text">
<string>Refresh all</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="create_global_rule_button">
<property name="text">
<string>Create global rule</string>
</property>
</widget>
</item>
<item>
<spacer name="globalActionsSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="actionsSeparatorLayout">
<item>
<widget class="QFrame" name="actions_vertical_separator">
<property name="frameShape">
<enum>QFrame::VLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QVBoxLayout" name="selectedActionsSectionLayout">
<item>
<widget class="QFrame" name="selected_actions_bar">
<layout class="QHBoxLayout" name="selectedActionsBarLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="selected_actions_label">
<property name="text">
<string>Selected subscriptions</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="ruleActionsLayout">
<item>
<widget class="QPushButton" name="edit_sub_button">
<property name="text">
<string>Edit</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="remove_sub_button">
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="refresh_now_button">
<property name="text">
<string>Refresh</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="create_rule_button">
<property name="text">
<string>Create rule</string>
</property>
</widget>
</item>
<item>
<spacer name="ruleActionsSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</item>
<item>
<widget class="QFrame" name="status_separator_line">
<property name="frameShape">
<enum>QFrame::HLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="status_label">
<property name="text">
<string/>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>StatusLogDialog</class>
<widget class="QDialog" name="StatusLogDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>760</width>
<height>420</height>
</rect>
</property>
<property name="windowTitle">
<string>Status log</string>
</property>
<layout class="QVBoxLayout" name="rootLayout">
<property name="leftMargin">
<number>12</number>
</property>
<property name="topMargin">
<number>12</number>
</property>
<property name="rightMargin">
<number>12</number>
</property>
<property name="bottomMargin">
<number>12</number>
</property>
<property name="spacing">
<number>8</number>
</property>
<item>
<widget class="QTextEdit" name="text_view"/>
</item>
<item>
<layout class="QHBoxLayout" name="buttonsLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="copy_button">
<property name="text">
<string>Copy</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="close_button">
<property name="text">
<string>Close</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
@@ -0,0 +1,440 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SubscriptionDialog</class>
<widget class="QDialog" name="SubscriptionDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>920</width>
<height>420</height>
</rect>
</property>
<property name="windowTitle">
<string>Subscription</string>
</property>
<layout class="QVBoxLayout" name="rootLayout">
<item>
<layout class="QHBoxLayout" name="bodyLayout">
<item>
<widget class="QGroupBox" name="settings_group">
<property name="title">
<string/>
</property>
<property name="flat">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="settings_group_layout">
<item>
<widget class="QFrame" name="settings_section_bar">
<layout class="QHBoxLayout" name="settingsSectionBarLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="settings_section_label">
<property name="text">
<string>Settings</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QFormLayout" name="settings_form">
<item row="0" column="0" colspan="2">
<widget class="QCheckBox" name="enabled_check">
<property name="text">
<string>Enabled</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="name_label">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="name_edit"/>
</item>
<item row="2" column="1">
<widget class="QLabel" name="name_error_label">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="url_label">
<property name="text">
<string>URL</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="url_edit"/>
</item>
<item row="4" column="1">
<widget class="QLabel" name="url_error_label">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="filename_label">
<property name="text">
<string>Filename</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QLineEdit" name="filename_edit"/>
</item>
<item row="6" column="1">
<widget class="QLabel" name="filename_error_label">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="format_label">
<property name="text">
<string>Format</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QComboBox" name="format_combo"/>
</item>
<item row="8" column="0">
<widget class="QLabel" name="groups_label">
<property name="text">
<string>Groups</string>
</property>
</widget>
</item>
<item row="8" column="1">
<widget class="QComboBox" name="group_combo"/>
</item>
<item row="9" column="1">
<widget class="QLabel" name="group_error_label">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="10" column="0">
<widget class="QLabel" name="interval_label">
<property name="text">
<string>Interval</string>
</property>
</widget>
</item>
<item row="10" column="1">
<layout class="QHBoxLayout" name="interval_layout">
<item>
<widget class="QSpinBox" name="interval_spin"/>
</item>
<item>
<widget class="QComboBox" name="interval_units"/>
</item>
</layout>
</item>
<item row="11" column="0">
<widget class="QLabel" name="timeout_label">
<property name="text">
<string>Timeout</string>
</property>
</widget>
</item>
<item row="11" column="1">
<layout class="QHBoxLayout" name="timeout_layout">
<item>
<widget class="QSpinBox" name="timeout_spin"/>
</item>
<item>
<widget class="QComboBox" name="timeout_units"/>
</item>
</layout>
</item>
<item row="12" column="0">
<widget class="QLabel" name="max_size_label">
<property name="text">
<string>Max size</string>
</property>
</widget>
</item>
<item row="12" column="1">
<layout class="QHBoxLayout" name="max_size_layout">
<item>
<widget class="QSpinBox" name="max_size_spin"/>
</item>
<item>
<widget class="QComboBox" name="max_size_units"/>
</item>
</layout>
</item>
</layout>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="meta_group">
<property name="title">
<string/>
</property>
<property name="flat">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="meta_group_layout">
<item>
<widget class="QFrame" name="meta_section_bar">
<layout class="QHBoxLayout" name="metaSectionBarLayout">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QLabel" name="meta_section_label">
<property name="text">
<string>Metadata</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<layout class="QGridLayout" name="meta_grid">
<property name="horizontalSpacing">
<number>12</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="meta_state_label">
<property name="text">
<string>State</string>
</property>
</widget>
</item>
<item row="0" column="1" rowspan="9">
<widget class="QFrame" name="meta_separator">
<property name="frameShape">
<enum>QFrame::VLine</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Plain</enum>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLabel" name="meta_state">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="meta_last_checked_label">
<property name="text">
<string>Last checked</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLabel" name="meta_last_checked">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="meta_last_updated_label">
<property name="text">
<string>Last updated</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QLabel" name="meta_last_updated">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="meta_failures_label">
<property name="text">
<string>Failures</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QLabel" name="meta_failures">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="meta_error_label">
<property name="text">
<string>Error</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QLabel" name="meta_error">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="meta_file_present_label">
<property name="text">
<string>List file present</string>
</property>
</widget>
</item>
<item row="5" column="2">
<widget class="QLabel" name="meta_file_present">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QLabel" name="meta_meta_present_label">
<property name="text">
<string>List meta present</string>
</property>
</widget>
</item>
<item row="6" column="2">
<widget class="QLabel" name="meta_meta_present">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="meta_list_path_label">
<property name="text">
<string>List path</string>
</property>
</widget>
</item>
<item row="7" column="2">
<widget class="QLabel" name="meta_list_path">
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="8" column="0">
<widget class="QLabel" name="meta_meta_path_label">
<property name="text">
<string>Meta path</string>
</property>
</widget>
</item>
<item row="8" column="2">
<widget class="QLabel" name="meta_meta_path">
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
</item>
</layout>
</item>
<item>
<widget class="Line" name="footer_separator_line">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="buttons_layout">
<item>
<widget class="QLabel" name="error_label">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="buttons_spacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="test_url_button">
<property name="text">
<string>Test URL</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="cancel_button">
<property name="text">
<string>Cancel</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="add_button">
<property name="text">
<string>Save</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SubscriptionStatusDialog</class>
<widget class="QDialog" name="SubscriptionStatusDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>700</width>
<height>440</height>
</rect>
</property>
<property name="windowTitle">
<string>Subscription status</string>
</property>
<layout class="QVBoxLayout" name="rootLayout">
<property name="leftMargin">
<number>12</number>
</property>
<property name="topMargin">
<number>12</number>
</property>
<property name="rightMargin">
<number>12</number>
</property>
<property name="bottomMargin">
<number>12</number>
</property>
<property name="spacing">
<number>8</number>
</property>
<item>
<widget class="QLabel" name="title_label">
<property name="text">
<string/>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QScrollArea" name="details_scroll">
<property name="frameShape">
<enum>QFrame::NoFrame</enum>
</property>
<property name="widgetResizable">
<bool>true</bool>
</property>
<widget class="QWidget" name="details_container"/>
</widget>
</item>
<item>
<layout class="QHBoxLayout" name="buttons_layout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="refresh_button">
<property name="text">
<string>Refresh</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="edit_button">
<property name="text">
<string>Edit</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="close_button">
<property name="text">
<string>Close</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>TextInspectDialog</class>
<widget class="QDialog" name="TextInspectDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>760</width>
<height>420</height>
</rect>
</property>
<property name="windowTitle">
<string>Inspect</string>
</property>
<layout class="QVBoxLayout" name="rootLayout">
<property name="leftMargin">
<number>12</number>
</property>
<property name="topMargin">
<number>12</number>
</property>
<property name="rightMargin">
<number>12</number>
</property>
<property name="bottomMargin">
<number>12</number>
</property>
<property name="spacing">
<number>8</number>
</property>
<item>
<widget class="QPlainTextEdit" name="text_view"/>
</item>
<item>
<layout class="QHBoxLayout" name="buttonsLayout">
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="copy_button">
<property name="text">
<string>Copy</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="close_button">
<property name="text">
<string>Close</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
@@ -0,0 +1,42 @@
import sys
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
# Keep static typing deterministic for linters/IDEs.
# Runtime still supports both PyQt6/PyQt5 below.
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from PyQt6.QtCore import QCoreApplication as QC
from PyQt6.uic.load_ui import loadUiType as load_ui_type
else:
if "PyQt6" in sys.modules:
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from PyQt6.QtCore import QCoreApplication as QC
from PyQt6.uic.load_ui import loadUiType as load_ui_type
elif "PyQt5" in sys.modules:
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import QCoreApplication as QC
load_ui_type = uic.loadUiType
else:
try:
from PyQt6 import QtCore, QtGui, QtWidgets, uic
from PyQt6.QtCore import QCoreApplication as QC
from PyQt6.uic.load_ui import loadUiType as load_ui_type
except Exception:
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from PyQt5.QtCore import QCoreApplication as QC
load_ui_type = uic.loadUiType
__all__ = [
"QtCore",
"QtGui",
"QtWidgets",
"uic",
"QC",
"load_ui_type",
"Any",
"TYPE_CHECKING",
]
@@ -0,0 +1,279 @@
import os
from typing import TYPE_CHECKING
from opensnitch.plugins.list_subscriptions.io.storage import (
read_json_locked,
write_json_atomic_locked,
)
from opensnitch.plugins.list_subscriptions.models.action import MutableActionConfig
from opensnitch.plugins.list_subscriptions.models.config import PluginConfig
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
from opensnitch.plugins.list_subscriptions.ui import QC
from opensnitch.plugins.list_subscriptions._utils import (
DEFAULT_LISTS_DIR,
normalize_lists_dir,
safe_filename,
)
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class ActionFileController:
def __init__(
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
):
self._dialog = dialog
self._cols = columns
def _col(self, key: str):
return self._cols[key]
def load_action_file(self):
with self._dialog._table_view_controller.sorting_suspended():
self._dialog._loading = True
self._dialog._status_controller.set_status("")
self._dialog._defaults_ui_controller.reload_nodes()
self._dialog.table.setRowCount(0)
self._dialog.create_file_button.setVisible(True)
self._dialog.lists_dir_edit.setText(DEFAULT_LISTS_DIR)
self._dialog.enable_plugin_check.setChecked(True)
self._dialog._runtime_controller.set_runtime_state(active=False)
self._dialog._global_defaults = GlobalDefaults.from_dict(
{}, lists_dir=DEFAULT_LISTS_DIR
)
self._dialog._defaults_ui_controller.apply_defaults_to_widgets()
if not os.path.exists(self._dialog._action_path):
self._dialog._status_controller.set_status(
QC.translate(
"stats", "Action file not found. Click 'Create action file'."
),
error=False,
)
self._dialog._loading = False
return
try:
data = read_json_locked(self._dialog._action_path)
except Exception as e:
self._dialog._status_controller.set_status(
QC.translate("stats", "Error reading action file: {0}").format(
str(e)
),
error=True,
)
self._dialog._loading = False
return
action_model = MutableActionConfig.from_action_dict(
data, lists_dir=DEFAULT_LISTS_DIR
)
self._dialog._global_defaults = action_model.plugin.defaults
self._dialog.enable_plugin_check.setChecked(True)
self._dialog._runtime_controller.sync_runtime_binding_state()
self._dialog.lists_dir_edit.setText(
normalize_lists_dir(self._dialog._global_defaults.lists_dir)
)
self._dialog._defaults_ui_controller.apply_defaults_to_widgets()
normalized_subs = action_model.plugin.subscriptions
actions_obj = data.get("actions", {})
action_cfg = (
actions_obj.get("list_subscriptions", {})
if isinstance(actions_obj, dict)
else {}
)
plugin_cfg_raw = (
action_cfg.get("config", {}) if isinstance(action_cfg, dict) else {}
)
plugin_cfg = plugin_cfg_raw if isinstance(plugin_cfg_raw, dict) else {}
raw_subs = plugin_cfg.get("subscriptions")
migrated_legacy_group = False
if isinstance(raw_subs, list):
for item in raw_subs:
if (
isinstance(item, dict)
and ("group" in item)
and ("groups" not in item)
):
migrated_legacy_group = True
break
normalized_subs_dicts = [s.to_dict() for s in normalized_subs]
fixed_count = (
1
if (isinstance(raw_subs, list) and raw_subs != normalized_subs_dicts)
else 0
)
for sub in normalized_subs:
self._dialog._table_data_controller.append_row(sub)
self._dialog._loading = False
self._dialog._table_data_controller.refresh_states()
self._dialog._selection_controller.update_selected_actions_state()
self._dialog.create_file_button.setVisible(False)
if migrated_legacy_group:
self._dialog._status_controller.append_log(
QC.translate(
"stats",
"Detected legacy 'group' fields and migrated them to 'groups'.",
),
level="WARN",
)
self.save_action_file()
self._dialog._status_controller.set_status(
QC.translate(
"stats",
"Migrated legacy 'group' entries to 'groups' and auto-saved configuration.",
),
error=False,
)
return
if fixed_count > 0:
self._dialog._status_controller.append_log(
QC.translate(
"stats",
"Normalized subscription fields while loading configuration.",
),
level="WARN",
)
self._dialog._status_controller.set_status(
QC.translate(
"stats",
"Loaded configuration with normalized subscription fields.",
),
error=False,
)
else:
self._dialog._status_controller.set_status(
QC.translate("stats", "List subscriptions configuration loaded."),
error=False,
)
def create_action_file(self):
try:
os.makedirs(
os.path.dirname(self._dialog._action_path), mode=0o700, exist_ok=True
)
if not os.path.exists(self._dialog._action_path):
action_model = MutableActionConfig.default(DEFAULT_LISTS_DIR)
write_json_atomic_locked(
self._dialog._action_path,
action_model.to_action_dict(),
)
self.load_action_file()
self._dialog._status_controller.set_status(
QC.translate("stats", "Action file created."),
error=False,
)
except Exception as e:
self._dialog._status_controller.set_status(
QC.translate("stats", "Error creating action file: {0}").format(str(e)),
error=True,
)
def save_action_file(self):
if self._dialog._loading:
return
if not os.path.exists(self._dialog._action_path):
self.create_action_file()
if not os.path.exists(self._dialog._action_path):
return
subscriptions = self._dialog._table_data_controller.collect_subscriptions()
if subscriptions is None:
return
lists_dir = normalize_lists_dir(
self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR
)
try:
os.makedirs(lists_dir, mode=0o700, exist_ok=True)
except Exception:
pass
defaults = GlobalDefaults(
lists_dir=lists_dir,
interval=max(1, int(self._dialog.default_interval_spin.value())),
interval_units=self._dialog.default_interval_units.currentText(),
timeout=max(1, int(self._dialog.default_timeout_spin.value())),
timeout_units=self._dialog.default_timeout_units.currentText(),
max_size=max(1, int(self._dialog.default_max_size_spin.value())),
max_size_units=self._dialog.default_max_size_units.currentText(),
user_agent=(self._dialog.default_user_agent.text() or "").strip(),
)
action_model = MutableActionConfig.default(lists_dir)
action_model.enabled = True
action_model.plugin.defaults = defaults
action_model.plugin.subscriptions = subscriptions
normalized_subscriptions = action_model.plugin.normalize_subscriptions(
invalidate_duplicates=True
)
if normalized_subscriptions is None:
self._dialog._status_controller.set_status(
QC.translate(
"stats",
"Invalid subscriptions: duplicate filename for the same URL.",
),
error=True,
)
return
action = action_model.to_action_dict()
compiled_cfg = PluginConfig.from_dict(
action_model.plugin.to_dict(),
lists_dir=lists_dir,
invalidate_duplicates=True,
)
if len(compiled_cfg.subscriptions) != len(normalized_subscriptions):
self._dialog._status_controller.set_status(
QC.translate(
"stats", "Invalid subscriptions: URL and filename are mandatory."
),
error=True,
)
return
for row, sub in enumerate(normalized_subscriptions):
self._dialog._table_data_controller.set_text_item(
row,
self._col("name"),
sub.name,
)
self._dialog._table_data_controller.set_text_item(
row,
self._col("filename"),
safe_filename(sub.filename),
)
try:
write_json_atomic_locked(self._dialog._action_path, action)
except Exception as e:
self._dialog._status_controller.set_status(
QC.translate("stats", "Error saving action file: {0}").format(str(e)),
error=True,
)
return
self._dialog._status_controller.append_log(
QC.translate(
"stats",
"Saving configuration: {0} subscriptions, runtime {1}.",
).format(
len(normalized_subscriptions),
QC.translate("stats", "enabled")
if action_model.enabled
else QC.translate("stats", "disabled"),
),
)
self._dialog._runtime_controller.apply_runtime_state(
action_model.enabled
)
self._dialog._table_data_controller.refresh_states()
self._dialog._status_controller.set_status(
QC.translate("stats", "List subscriptions configuration saved."),
error=False,
)
@@ -0,0 +1,118 @@
import os
from typing import Any
class AttachedRulesIndex:
def __init__(self) -> None:
self._snapshot: dict[str, list[dict[str, Any]]] = {}
def snapshot(self) -> dict[str, list[dict[str, Any]]]:
return dict(self._snapshot)
def set_from_snapshot_obj(
self,
snapshot: object,
) -> dict[str, list[dict[str, Any]]]:
data: dict[str, list[dict[str, Any]]] = {}
if isinstance(snapshot, dict):
for key, value in snapshot.items():
if not isinstance(key, str) or not isinstance(value, list):
continue
normalized_key = os.path.normpath(key)
items: list[dict[str, Any]] = []
for entry in value:
if isinstance(entry, dict):
items.append(entry)
data[normalized_key] = items
self._snapshot = data
return data
def apply_rule_editor_change(self, change: dict[str, Any]) -> bool:
addr = str(change.get("addr", "") or "").strip()
if addr == "":
return False
old_name = str(change.get("old_name", "") or "").strip()
new_name = str(change.get("new_name", "") or "").strip()
enabled = bool(change.get("enabled", True))
directories = [
str(path).strip()
for path in list(change.get("directories", []))
if str(path).strip() != ""
]
names_to_remove = {name for name in (old_name, new_name) if name != ""}
self._remove_rule_from_snapshot(addr, names_to_remove)
if new_name != "" and directories:
self._upsert_rule_in_snapshot(
addr=addr,
rule_name=new_name,
enabled=enabled,
directories=directories,
)
return True
def update_rule_enabled(self, addr: str, rule_name: str, enabled: bool) -> bool:
changed = False
for entries in self._snapshot.values():
for entry in entries:
if (
str(entry.get("addr", "")).strip() == addr
and str(entry.get("name", "")).strip() == rule_name
):
entry["enabled"] = bool(enabled)
changed = True
return changed
def remove_rule(self, addr: str, rule_name: str) -> None:
self._remove_rule_from_snapshot(addr, {rule_name})
def _remove_rule_from_snapshot(self, addr: str, rule_names: set[str]) -> None:
if not rule_names:
return
for directory, entries in list(self._snapshot.items()):
filtered = [
entry
for entry in entries
if not (
str(entry.get("addr", "")).strip() == addr
and str(entry.get("name", "")).strip() in rule_names
)
]
if filtered:
self._snapshot[directory] = filtered
else:
del self._snapshot[directory]
def _upsert_rule_in_snapshot(
self,
*,
addr: str,
rule_name: str,
enabled: bool,
directories: list[str],
) -> None:
normalized_dirs = [
os.path.normpath(path)
for path in directories
if path.strip()
]
if not normalized_dirs:
return
for directory in normalized_dirs:
entries = self._snapshot.setdefault(directory, [])
for entry in entries:
if (
str(entry.get("addr", "")).strip() == addr
and str(entry.get("name", "")).strip() == rule_name
):
entry["enabled"] = bool(enabled)
break
else:
entries.append(
{
"addr": addr,
"name": rule_name,
"enabled": bool(enabled),
}
)
@@ -0,0 +1,134 @@
from typing import TYPE_CHECKING
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
from opensnitch.plugins.list_subscriptions.ui.views.bulk_edit_dialog import (
BulkEditDialog,
)
from opensnitch.plugins.list_subscriptions._utils import (
INTERVAL_UNITS,
TIMEOUT_UNITS,
SIZE_UNITS,
display_str,
normalize_groups,
)
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class BulkEditController:
def __init__(
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
):
self._dialog = dialog
self._cols = columns
def _col(self, key: str):
return self._cols[key]
def bulk_edit(self, rows: list[int]):
if not rows:
return
dlg = BulkEditDialog(
self._dialog,
self._dialog._global_defaults,
groups=self._dialog._selection_controller.known_groups(),
selected_count=len(rows),
)
if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
return
values = dlg.values()
with self._dialog._table_view_controller.sorting_suspended():
for row in rows:
if values.get("enabled") is not None:
enabled_item = self._dialog.table.item(row, self._col("enabled"))
if enabled_item is None:
enabled_item = self._dialog._table_data_controller.new_enabled_item(False)
self._dialog.table.setItem(
row,
self._col("enabled"),
enabled_item,
)
enabled_item.setCheckState(
QtCore.Qt.CheckState.Checked
if bool(values["enabled"])
else QtCore.Qt.CheckState.Unchecked
)
if values.get("groups") is not None:
self._dialog._table_data_controller.set_text_item(
row,
self._col("group"),
", ".join(normalize_groups(values["groups"])),
)
if values.get("format") is not None:
self._dialog._table_data_controller.set_text_item(
row,
self._col("format"),
str(values["format"]),
)
if values.get("apply_interval"):
self._dialog._table_data_controller.set_text_item(
row,
self._col("interval"),
display_str(values.get("interval")),
)
interval_units = display_str(values.get("interval_units"))
self._dialog._table_data_controller.set_text_item(
row,
self._col("interval_units"),
interval_units,
)
self._dialog._defaults_ui_controller.set_units_combo(
row,
self._col("interval_units"),
INTERVAL_UNITS,
interval_units,
)
if values.get("apply_timeout"):
self._dialog._table_data_controller.set_text_item(
row,
self._col("timeout"),
display_str(values.get("timeout")),
)
timeout_units = display_str(values.get("timeout_units"))
self._dialog._table_data_controller.set_text_item(
row,
self._col("timeout_units"),
timeout_units,
)
self._dialog._defaults_ui_controller.set_units_combo(
row,
self._col("timeout_units"),
TIMEOUT_UNITS,
timeout_units,
)
if values.get("apply_max_size"):
self._dialog._table_data_controller.set_text_item(
row,
self._col("max_size"),
display_str(values.get("max_size")),
)
max_size_units = display_str(values.get("max_size_units"))
self._dialog._table_data_controller.set_text_item(
row,
self._col("max_size_units"),
max_size_units,
)
self._dialog._defaults_ui_controller.set_units_combo(
row,
self._col("max_size_units"),
SIZE_UNITS,
max_size_units,
)
self._dialog._table_data_controller.ensure_row_final_filename(row)
self._dialog._table_data_controller.update_row_sort_keys(row)
self._dialog._action_file_controller.save_action_file()
self._dialog._table_data_controller.refresh_states()
self._dialog._status_controller.set_status(
QC.translate("stats", "Updated {0} selected subscriptions.").format(
len(rows)
),
error=False,
)
@@ -0,0 +1,105 @@
from typing import TYPE_CHECKING, Any
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class ContextMenuController:
def __init__(self, *, dialog: "ListSubscriptionsDialog"):
self._dialog = dialog
def open_table_context_menu(self, pos: Any):
rows = self._dialog._selection_controller.selected_rows()
if not rows:
row = self._dialog.table.rowAt(pos.y())
if row >= 0:
self._dialog.table.selectRow(row)
rows = [row]
menu = QtWidgets.QMenu(self._dialog.table)
viewport = self._dialog.table.viewport()
if viewport is None:
return
if not rows:
act_reset_sort = menu.addAction(QC.translate("stats", "Reset sorting"))
act_reset_widths = menu.addAction(
QC.translate("stats", "Reset column widths")
)
chosen = QtWidgets.QMenu.exec(
menu.actions(),
viewport.mapToGlobal(pos),
None,
menu,
)
if chosen is act_reset_sort:
self._dialog._table_view_controller.reset_table_sort_for_current_tab()
elif chosen is act_reset_widths:
self._dialog._table_view_controller.reset_table_column_widths_for_current_tab()
return
if len(rows) == 1:
act_inspect = menu.addAction(QC.translate("stats", "Inspect"))
act_edit = menu.addAction(QC.translate("stats", "Edit"))
act_remove = menu.addAction(QC.translate("stats", "Delete"))
act_refresh = menu.addAction(QC.translate("stats", "Refresh"))
act_rule = menu.addAction(QC.translate("stats", "Rules"))
menu.addSeparator()
act_reset_sort = menu.addAction(QC.translate("stats", "Reset sorting"))
act_reset_widths = menu.addAction(
QC.translate("stats", "Reset column widths")
)
chosen = QtWidgets.QMenu.exec(
menu.actions(),
viewport.mapToGlobal(pos),
None,
menu,
)
if chosen is act_inspect:
self._dialog._selection_controller.open_selected_inspector()
elif chosen is act_edit:
self._dialog._subscription_edit_controller.edit_selected_subscription()
elif chosen is act_remove:
self._dialog.remove_selected_subscription()
elif chosen is act_refresh:
self._dialog._table_data_controller.refresh_selected_now()
elif chosen is act_rule:
self._dialog._rules_attachment_controller.show_attached_rules_dialog()
elif chosen is act_reset_sort:
self._dialog._table_view_controller.reset_table_sort_for_current_tab()
elif chosen is act_reset_widths:
self._dialog._table_view_controller.reset_table_column_widths_for_current_tab()
return
act_inspect = menu.addAction(QC.translate("stats", "Inspect"))
act_edit = menu.addAction(QC.translate("stats", "Edit"))
act_remove = menu.addAction(QC.translate("stats", "Delete"))
act_refresh = menu.addAction(QC.translate("stats", "Refresh"))
act_rule = menu.addAction(QC.translate("stats", "Create rule"))
menu.addSeparator()
act_reset_sort = menu.addAction(QC.translate("stats", "Reset sorting"))
act_reset_widths = menu.addAction(QC.translate("stats", "Reset column widths"))
chosen = QtWidgets.QMenu.exec(
menu.actions(),
viewport.mapToGlobal(pos),
None,
menu,
)
if chosen is act_inspect:
self._dialog._selection_controller.open_selected_inspector()
elif chosen is act_edit:
self._dialog._bulk_edit_controller.bulk_edit(rows)
elif chosen is act_remove:
self._dialog.remove_selected_subscription()
elif chosen is act_refresh:
self._dialog._table_data_controller.refresh_selected_now()
elif chosen is act_rule:
self._dialog._rules_editor_controller.create_rule_from_selected()
elif chosen is act_reset_sort:
self._dialog._table_view_controller.reset_table_sort_for_current_tab()
elif chosen is act_reset_widths:
self._dialog._table_view_controller.reset_table_column_widths_for_current_tab()
@@ -0,0 +1,77 @@
from typing import TYPE_CHECKING
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import _configure_spin_and_units
from opensnitch.plugins.list_subscriptions._utils import (
INTERVAL_UNITS,
SIZE_UNITS,
TIMEOUT_UNITS,
normalize_unit,
)
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class DefaultsUiController:
def __init__(self, *, dialog: "ListSubscriptionsDialog"):
self._dialog = dialog
def reload_nodes(self):
self._dialog.nodes_combo.blockSignals(True)
self._dialog.nodes_combo.clear()
for addr in self._dialog._nodes.get_nodes():
self._dialog.nodes_combo.addItem(addr, addr)
self._dialog.nodes_combo.blockSignals(False)
def apply_defaults_to_widgets(self):
_configure_spin_and_units(
self._dialog.default_interval_spin,
self._dialog.default_interval_units,
value=int(self._dialog._global_defaults.interval),
unit_value=self._dialog._global_defaults.interval_units,
allowed_units=INTERVAL_UNITS,
fallback_unit="hours",
min_value=1,
)
_configure_spin_and_units(
self._dialog.default_timeout_spin,
self._dialog.default_timeout_units,
value=int(self._dialog._global_defaults.timeout),
unit_value=self._dialog._global_defaults.timeout_units,
allowed_units=TIMEOUT_UNITS,
fallback_unit="seconds",
min_value=1,
)
_configure_spin_and_units(
self._dialog.default_max_size_spin,
self._dialog.default_max_size_units,
value=int(self._dialog._global_defaults.max_size),
unit_value=self._dialog._global_defaults.max_size_units,
allowed_units=SIZE_UNITS,
fallback_unit="MB",
min_value=1,
)
self._dialog.default_user_agent.setText(
(self._dialog._global_defaults.user_agent or "").strip()
)
def set_units_combo(
self, row: int, col: int, allowed: tuple[str, ...], value: str | None
):
combo = QtWidgets.QComboBox()
combo.addItem("")
combo.addItems(allowed)
combo.setToolTip(
QC.translate(
"stats",
"Leave blank to inherit the global default for this subscription.",
)
)
if value is None or value.strip() == "":
combo.setCurrentIndex(0)
else:
combo.setCurrentText(normalize_unit(value, allowed, allowed[0]))
self._dialog.table.setCellWidget(row, col, combo)
@@ -0,0 +1,378 @@
from typing import TYPE_CHECKING
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
from opensnitch.plugins.list_subscriptions.ui.views.text_inspect_dialog import (
TextInspectDialog,
)
from opensnitch.plugins.list_subscriptions._utils import safe_filename, timestamp_sort_key
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class InspectorController:
def __init__(
self,
*,
dialog: "ListSubscriptionsDialog",
columns: dict[str, int],
error_preview_limit: int,
):
self._dialog = dialog
self._cols = columns
self._error_preview_limit = error_preview_limit
def _col(self, key: str):
return self._cols[key]
def show_error_inspect_dialog(self):
text = (self._dialog._inspect_error_full_text or "").strip()
dlg = TextInspectDialog(
self._dialog,
title=QC.translate("stats", "Error details"),
text=text,
)
dlg.exec()
def set_inspector_toggle_icon(self):
style = self._dialog.style()
if style is None:
return
if not self._dialog._inspect_has_selection:
icon = style.standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_ArrowLeft
)
tip = QC.translate("stats", "Select a subscription to inspect")
self._dialog._inspect_toggle_button.setIcon(icon)
self._dialog._inspect_toggle_button.setToolTip(tip)
self._dialog._inspect_toggle_button.setEnabled(False)
return
if self._dialog._inspect_collapsed:
icon = style.standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_ArrowLeft
)
tip = QC.translate("stats", "Expand inspector")
else:
icon = style.standardIcon(
QtWidgets.QStyle.StandardPixmap.SP_ArrowRight
)
tip = QC.translate("stats", "Collapse inspector")
self._dialog._inspect_toggle_button.setEnabled(True)
self._dialog._inspect_toggle_button.setIcon(icon)
self._dialog._inspect_toggle_button.setToolTip(tip)
def toggle_inspector_collapsed(self):
if not self._dialog._inspect_has_selection:
return
self._dialog._inspect_collapsed = not self._dialog._inspect_collapsed
if not self._dialog._inspect_panel.isVisible():
return
if self._dialog._inspect_collapsed:
self._dialog._inspect_scroll.setVisible(False)
self._dialog._inspect_title_label.setVisible(False)
self._dialog._inspect_header_separator.setVisible(False)
self._dialog._inspect_panel.setMinimumWidth(36)
self._dialog._inspect_panel.setMaximumWidth(36)
total = max(36, self._dialog._table_inspect_splitter.width())
self._dialog._table_inspect_splitter.setSizes([max(1, total - 36), 36])
else:
self._dialog._inspect_scroll.setVisible(True)
self._dialog._inspect_title_label.setVisible(True)
self._dialog._inspect_header_separator.setVisible(True)
self._dialog._inspect_panel.setMinimumWidth(240)
self._dialog._inspect_panel.setMaximumWidth(16777215)
total = max(300, self._dialog._table_inspect_splitter.width())
width = min(self._dialog._inspect_default_width, max(280, total // 2))
self._dialog._table_inspect_splitter.setSizes([max(1, total - width), width])
self.set_inspector_toggle_icon()
def set_inspector_visible(self, visible: bool):
if not hasattr(self._dialog, "_inspect_panel"):
return
self._dialog._inspect_has_selection = bool(visible)
self._dialog._inspect_panel.setVisible(True)
if not self._dialog._inspect_has_selection:
self._dialog._inspect_collapsed = True
self._dialog._inspect_scroll.setVisible(False)
self._dialog._inspect_title_label.setVisible(False)
self._dialog._inspect_header_separator.setVisible(False)
self._dialog._inspect_panel.setMinimumWidth(36)
self._dialog._inspect_panel.setMaximumWidth(36)
total = max(36, self._dialog._table_inspect_splitter.width())
self._dialog._table_inspect_splitter.setSizes([max(1, total - 36), 36])
self.set_inspector_toggle_icon()
return
if self._dialog._inspect_collapsed:
self._dialog._inspect_scroll.setVisible(False)
self._dialog._inspect_title_label.setVisible(False)
self._dialog._inspect_header_separator.setVisible(False)
self._dialog._inspect_panel.setMinimumWidth(36)
self._dialog._inspect_panel.setMaximumWidth(36)
total = max(36, self._dialog._table_inspect_splitter.width())
self._dialog._table_inspect_splitter.setSizes([max(1, total - 36), 36])
else:
self._dialog._inspect_scroll.setVisible(True)
self._dialog._inspect_title_label.setVisible(True)
self._dialog._inspect_header_separator.setVisible(True)
self._dialog._inspect_panel.setMinimumWidth(240)
self._dialog._inspect_panel.setMaximumWidth(16777215)
total = max(300, self._dialog._table_inspect_splitter.width())
width = min(self._dialog._inspect_default_width, max(240, total // 3))
self._dialog._table_inspect_splitter.setSizes([max(1, total - width), width])
self.set_inspector_toggle_icon()
def set_inspector_values(
self,
*,
row: int,
name: str,
url: str,
filename: str,
meta: dict[str, str],
):
enabled_item = self._dialog.table.item(row, self._col("enabled"))
enabled = enabled_item is not None and (
enabled_item.checkState() == QtCore.Qt.CheckState.Checked
)
interval_value = self._dialog._table_data_controller.cell_text(
row, self._col("interval")
)
interval_units = self._dialog._table_data_controller.cell_text(
row, self._col("interval_units")
)
timeout_value = self._dialog._table_data_controller.cell_text(
row, self._col("timeout")
)
timeout_units = self._dialog._table_data_controller.cell_text(
row, self._col("timeout_units")
)
max_size_value = self._dialog._table_data_controller.cell_text(
row, self._col("max_size")
)
max_size_units = self._dialog._table_data_controller.cell_text(
row, self._col("max_size_units")
)
values = {
"enabled": QC.translate("stats", "Yes") if enabled else QC.translate("stats", "No"),
"name": name,
"url": url,
"filename": filename,
"format": self._dialog._table_data_controller.cell_text(
row, self._col("format")
),
"groups": self._dialog._table_data_controller.cell_text(
row, self._col("group")
),
"interval": " ".join(
part for part in (interval_value, interval_units) if (part or "").strip() != ""
),
"timeout": " ".join(
part for part in (timeout_value, timeout_units) if (part or "").strip() != ""
),
"max_size": " ".join(
part for part in (max_size_value, max_size_units) if (part or "").strip() != ""
),
"state": meta.get("state", ""),
"last_checked": meta.get("last_checked", ""),
"last_updated": meta.get("last_updated", ""),
"failures": meta.get("failures", ""),
"error": meta.get("error", ""),
"list_path": meta.get("list_path", ""),
"meta_path": meta.get("meta_path", ""),
}
for key, value in values.items():
label = self._dialog._inspect_value_labels.get(key)
if label is None:
continue
text = (str(value or "")).strip() or "-"
if key == "error":
self.set_error_preview(text)
continue
if key == "state":
label.setText(text)
label.setStyleSheet(
f"color: {self.state_bucket_color(text).name()};"
)
continue
label.setText(text)
def state_bucket_color(self, state: str):
normalized = (state or "").strip().lower()
if normalized in ("updated", "not_modified"):
return self._dialog._table_data_controller.state_text_color("updated")
if normalized == "pending":
return self._dialog._table_data_controller.state_text_color("pending")
return self._dialog._table_data_controller.state_text_color("error")
def set_error_preview(self, text: str):
error_label = self._dialog._inspect_value_labels.get("error")
if error_label is None:
return
normalized = (text or "").strip()
if normalized in ("", "-"):
self._dialog._inspect_error_full_text = ""
error_label.setText("-")
error_label.setToolTip("")
if self._dialog._inspect_error_button is not None:
self._dialog._inspect_error_button.setVisible(False)
return
self._dialog._inspect_error_full_text = normalized
if len(normalized) <= self._error_preview_limit:
error_label.setText(normalized)
error_label.setToolTip(normalized)
if self._dialog._inspect_error_button is not None:
self._dialog._inspect_error_button.setVisible(False)
return
preview = normalized[: self._error_preview_limit - 1].rstrip() + "..."
error_label.setText(preview)
error_label.setToolTip(normalized)
if self._dialog._inspect_error_button is not None:
self._dialog._inspect_error_button.setVisible(True)
self._dialog._inspect_error_button.setEnabled(True)
def set_inspector_multi_selection_mode(self, enabled: bool):
if enabled:
self._dialog._inspect_details_widget.setVisible(False)
self._dialog._inspect_summary_widget.setVisible(True)
return
self._dialog._inspect_details_widget.setVisible(True)
self._dialog._inspect_summary_widget.setVisible(False)
def set_inspector_summary_values(self, rows: list[int]):
selected_count = len(rows)
enabled_count = 0
healthy_count = 0
pending_count = 0
problematic_count = 0
total_failures = 0
with_errors = 0
newest_checked = ""
oldest_checked = ""
newest_key = None
oldest_key = None
for row in rows:
enabled_item = self._dialog.table.item(row, self._col("enabled"))
if enabled_item is not None and (
enabled_item.checkState() == QtCore.Qt.CheckState.Checked
):
enabled_count += 1
meta = self._dialog._table_data_controller.row_meta_snapshot(row)
state = (meta.get("state", "") or "").strip().lower()
if state in ("updated", "not_modified"):
healthy_count += 1
elif state == "pending":
pending_count += 1
else:
problematic_count += 1
failures_text = (meta.get("failures", "") or "").strip()
try:
total_failures += int(failures_text or "0")
except Exception:
pass
if (meta.get("error", "") or "").strip() != "":
with_errors += 1
checked = (meta.get("last_checked", "") or "").strip()
if checked == "":
continue
checked_key = timestamp_sort_key(checked)
if newest_key is None or checked_key > newest_key:
newest_key = checked_key
newest_checked = checked
if oldest_key is None or checked_key < oldest_key:
oldest_key = checked_key
oldest_checked = checked
values = {
"selected": str(selected_count),
"enabled": f"{enabled_count}/{selected_count}",
"healthy": str(healthy_count),
"pending": str(pending_count),
"problematic": str(problematic_count),
"failures": str(total_failures),
"with_errors": str(with_errors),
"newest_checked": newest_checked,
"oldest_checked": oldest_checked,
}
for key, value in values.items():
label = self._dialog._inspect_summary_labels.get(key)
if label is None:
continue
label.setText((value or "").strip() or "-")
def update_inspector_panel(self):
if not hasattr(self._dialog, "_inspect_panel"):
return
rows = self._dialog._selection_controller.selected_rows()
if not rows:
self.set_inspector_visible(False)
return
self.set_inspector_multi_selection_mode(len(rows) > 1)
if len(rows) > 1:
self.set_inspector_summary_values(rows)
self.set_inspector_visible(True)
return
row = rows[0]
name = self._dialog._table_data_controller.cell_text(row, self._col("name"))
url = self._dialog._table_data_controller.cell_text(row, self._col("url"))
filename = safe_filename(
self._dialog._table_data_controller.cell_text(row, self._col("filename"))
)
meta = self._dialog._table_data_controller.row_meta_snapshot(row)
self.set_inspector_values(
row=row,
name=name,
url=url,
filename=filename,
meta=meta,
)
self.set_inspector_visible(True)
def handle_table_selection_changed(self, *_):
self._dialog._selection_controller.update_selected_actions_state()
self.update_inspector_panel()
def on_subscription_state_refreshed(self, url: str, filename: str, meta: dict[str, str]):
if not hasattr(self._dialog, "_inspect_panel"):
return
if not self._dialog._inspect_panel.isVisible():
return
rows = self._dialog._selection_controller.selected_rows()
if not rows:
self.set_inspector_visible(False)
return
if len(rows) > 1:
changed_row = self._dialog._subscription_status_controller.find_row_by_identity(
url,
filename,
)
if changed_row in rows:
self.set_inspector_summary_values(rows)
return
row = rows[0]
row_url = self._dialog._table_data_controller.cell_text(row, self._col("url"))
row_filename = safe_filename(
self._dialog._table_data_controller.cell_text(row, self._col("filename"))
)
if row_url != url or row_filename != filename:
return
self.set_inspector_values(
row=row,
name=self._dialog._table_data_controller.cell_text(row, self._col("name")),
url=row_url,
filename=row_filename,
meta=meta,
)
@@ -0,0 +1,927 @@
import os
import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, cast
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
from opensnitch.plugins.list_subscriptions.ui.views.attached_rules_dialog import (
AttachedRulesDialog,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.attached_rules_index import (
AttachedRulesIndex,
)
from opensnitch.plugins.list_subscriptions.ui.workers import attached_rules_snapshot_worker as attached_rules_workers
from opensnitch.database import Database
from opensnitch.config import Config
from opensnitch.rules import Rule, Rules
from opensnitch.proto import ui_pb2 as ui_pb2
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
def _is_memory_db_file(db_file: str) -> bool:
value = str(db_file or "").strip().lower()
if value in ("", ":memory:", "file::memory:"):
return True
if value.startswith("file::memory"):
return True
return "mode=memory" in value
def _is_shared_memory_db_file(db_file: str) -> bool:
value = str(db_file or "").strip().lower()
# OpenSnitch's default in-memory URI uses file::memory: and enables
# shared-cache via connection options at DB initialization time.
if value.startswith("file::memory:"):
return True
if "cache=shared" not in value:
return False
return "mode=memory" in value or value.startswith("file::memory:")
def _is_sqlite_uri(db_file: str) -> bool:
return str(db_file or "").strip().lower().startswith("file:")
ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS = 2000
ATTACHED_RULES_SNAPSHOT_WARN_RULES = 100
ATTACHED_RULES_COUNT_CACHE_TTL_SECONDS = 6 * 60 * 60
ATTACHED_RULES_COUNT_BASELINE_MS = 8
ATTACHED_RULES_TIMEOUT_MAX_MS = 8000
ATTACHED_RULES_FETCH_BASELINE_MS_PER_ROW = 0.1
ATTACHED_RULES_PROCESS_BASELINE_MS_PER_ROW = 0.05
ATTACHED_RULES_TIMEOUT_SAFETY_FACTOR = 3.0
ATTACHED_RULES_WARN_MIN_ESTIMATED_DELAY_MS = 2000
class RulesAttachmentController:
def __init__(self, *, dialog: "ListSubscriptionsDialog"):
self._dialog = dialog
self._rules = Rules.instance()
self._rules_index = AttachedRulesIndex()
self._snapshot_cache_dirty = False
self._snapshot_worker: Any = None
self._snapshot_thread: QtCore.QThread | None = None
self._snapshot_phase = "idle"
self._snapshot_callbacks: list[
Callable[[dict[str, list[dict[str, Any]]]], None]
] = []
self._rules_count_cache: dict[
tuple[str, tuple[str, ...]],
tuple[int | None, bool, float],
] = {}
self._pending_snapshot_db_file = ""
self._pending_snapshot_local_nodes: list[str] = []
self._pending_snapshot_in_memory_db = False
self._pending_snapshot_shared_memory_db = False
self._pending_snapshot_count_over_limit = False
self._pending_snapshot_row_count = 0
self._count_query_delay_factor = 1.0
self._fetch_ms_per_row = ATTACHED_RULES_FETCH_BASELINE_MS_PER_ROW
self._process_ms_per_row = ATTACHED_RULES_PROCESS_BASELINE_MS_PER_ROW
self._snapshot_timeout_timer = QtCore.QTimer(dialog)
self._snapshot_timeout_timer.setSingleShot(True)
self._snapshot_timeout_timer.setInterval(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS)
self._snapshot_timeout_timer.timeout.connect(self._on_snapshot_worker_timeout)
self._snapshot_timed_out = False
try:
self._dialog._nodes.nodesUpdated.connect(self._on_nodes_updated)
except Exception:
pass
try:
self._rules.updated.connect(self._on_rules_updated)
except Exception:
pass
dialog.destroyed.connect(self._on_dialog_destroyed)
def _on_dialog_destroyed(self, *_args):
worker = self._snapshot_worker
thread = self._snapshot_thread
if worker is None or thread is None:
return
try:
worker.stop()
except Exception:
pass
self._snapshot_timeout_timer.stop()
if thread.isRunning():
thread.quit()
thread.wait(300)
def attached_rules_snapshot(self):
return self._rules_index.snapshot()
def has_active_snapshot(self) -> bool:
thread = self._snapshot_thread
return bool(thread is not None and thread.isRunning())
def cancel_active_snapshot(self) -> None:
worker = self._snapshot_worker
thread = self._snapshot_thread
self._snapshot_timeout_timer.stop()
if worker is None or thread is None or not thread.isRunning():
return
try:
stop = getattr(worker, "stop", None)
if callable(stop):
stop()
except Exception:
pass
thread.quit()
self._snapshot_phase = "idle"
if self._snapshot_callbacks:
snapshot = self._rules_index.snapshot()
callbacks = self._snapshot_callbacks[:]
self._snapshot_callbacks.clear()
for callback in callbacks:
callback(snapshot)
def apply_rule_editor_change(self, change: dict[str, Any]) -> None:
if not self._rules_index.apply_rule_editor_change(change):
self._mark_snapshot_cache_dirty("rule-editor-missing-addr")
return
self._snapshot_cache_dirty = False
def update_cached_rule_enabled(self, addr: str, rule_name: str, enabled: bool) -> None:
if not self._rules_index.update_rule_enabled(addr, rule_name, enabled):
self._mark_snapshot_cache_dirty("rule-enabled-miss")
return
self._snapshot_cache_dirty = False
def remove_cached_rule(self, addr: str, rule_name: str) -> None:
self._rules_index.remove_rule(addr, rule_name)
self._snapshot_cache_dirty = False
def _mark_snapshot_cache_dirty(self, reason: str) -> None:
self._snapshot_cache_dirty = True
self._rules_count_cache.clear()
self._dialog._status_controller.debug(
QC.translate(
"stats",
"Attached-rules cache invalidated: {0}",
).format(reason),
origin="ui:attached-rules",
)
def invalidate_snapshot_cache(self, reason: str) -> None:
self._mark_snapshot_cache_dirty(reason)
def _on_nodes_updated(self, _count: int) -> None:
self._mark_snapshot_cache_dirty("nodes-updated")
def _on_rules_updated(self, _value: int) -> None:
self._mark_snapshot_cache_dirty("rules-updated")
def _attached_rules_snapshot_sync(self):
attached_rules_by_dir: dict[str, list[dict[str, Any]]] = {}
seen_entries: set[tuple[str, str, str]] = set()
for addr in self._dialog._nodes.get().keys():
try:
if not self._dialog._nodes.is_local(addr):
continue
except Exception:
continue
records = self._dialog._nodes.get_rules(addr)
if records is None or records == -1:
continue
while records.next():
try:
rule = cast(ui_pb2.Rule, Rule.new_from_records(records))
except Exception:
continue
rule_name = str(getattr(rule, "name", "") or "").strip()
if rule_name == "":
continue
rule_enabled = bool(getattr(rule, "enabled", True))
if rule.operator.operand == Config.OPERAND_LIST_DOMAINS:
direct = os.path.normpath(str(rule.operator.data or "").strip())
if direct != "":
entry_key = (direct, addr, rule_name)
if entry_key not in seen_entries:
seen_entries.add(entry_key)
attached_rules_by_dir.setdefault(direct, []).append(
{
"addr": addr,
"name": rule_name,
"enabled": rule_enabled,
}
)
for operator in getattr(rule.operator, "list", []):
if operator.operand != Config.OPERAND_LIST_DOMAINS:
continue
nested = os.path.normpath(str(operator.data or "").strip())
if nested != "":
entry_key = (nested, addr, rule_name)
if entry_key not in seen_entries:
seen_entries.add(entry_key)
attached_rules_by_dir.setdefault(nested, []).append(
{
"addr": addr,
"name": rule_name,
"enabled": rule_enabled,
}
)
return attached_rules_by_dir
def _on_snapshot_worker_done(self, snapshot: object) -> None:
self._snapshot_timeout_timer.stop()
self._snapshot_phase = "idle"
actual_snapshot: object = snapshot
if isinstance(snapshot, dict) and "snapshot" in snapshot and "elapsed_ms" in snapshot:
raw_elapsed = snapshot.get("elapsed_ms")
raw_row_count = snapshot.get("row_count")
if isinstance(raw_elapsed, int):
row_count = raw_row_count if isinstance(raw_row_count, int) else 0
self._update_process_ms_per_row(raw_elapsed, row_count)
self._dialog._status_controller.debug(
QC.translate(
"stats",
"Attached-rules process: {0} ms for {1} rows ({2:.3f} ms/row EMA)",
).format(raw_elapsed, row_count, self._process_ms_per_row),
origin="ui:attached-rules",
)
actual_snapshot = snapshot.get("snapshot") or {}
data = self._rules_index.set_from_snapshot_obj(actual_snapshot)
self._snapshot_cache_dirty = False
if not self._snapshot_callbacks:
return
callbacks = self._snapshot_callbacks[:]
self._snapshot_callbacks.clear()
for callback in callbacks:
callback(data)
def _on_snapshot_worker_stopped(
self,
worker: Any,
thread: QtCore.QThread,
) -> None:
if self._snapshot_worker is worker:
self._snapshot_worker = None
if self._snapshot_thread is thread:
self._snapshot_thread = None
def _on_snapshot_worker_timeout(self) -> None:
worker = self._snapshot_worker
thread = self._snapshot_thread
if worker is None or thread is None or not thread.isRunning():
return
self._snapshot_timed_out = True
self._snapshot_phase = "timeout"
try:
stop = getattr(worker, "stop", None)
if callable(stop):
stop()
except Exception:
pass
thread.quit()
fallback_snapshot = self._rules_index.snapshot()
if fallback_snapshot:
self._dialog._status_controller.warn(
QC.translate(
"stats",
"Attached-rules lookup timed out; showing cached results.",
),
origin="ui:attached-rules",
)
else:
self._dialog._status_controller.warn(
QC.translate(
"stats",
"Attached-rules lookup timed out; showing no results.",
),
origin="ui:attached-rules",
)
callbacks = self._snapshot_callbacks[:]
self._snapshot_callbacks.clear()
for callback in callbacks:
callback(fallback_snapshot)
def _snapshot_db_mode(self, db_file: str) -> tuple[str, str]:
if _is_shared_memory_db_file(db_file):
return "memory-shared", "async"
if _is_memory_db_file(db_file):
return "memory-private", "sync"
if _is_sqlite_uri(db_file):
return "file-uri", "async"
return "file", "async"
def _count_cache_key(self, db_file: str, local_nodes: list[str]) -> tuple[str, tuple[str, ...]]:
return db_file, tuple(sorted(local_nodes))
def _get_cached_rules_count(
self,
db_file: str,
local_nodes: list[str],
) -> tuple[int | None, bool] | None:
key = self._count_cache_key(db_file, local_nodes)
cached = self._rules_count_cache.get(key)
if cached is None:
return None
count, over_limit, ts = cached
if (time.monotonic() - ts) > ATTACHED_RULES_COUNT_CACHE_TTL_SECONDS:
self._rules_count_cache.pop(key, None)
return None
return count, over_limit
def _set_cached_rules_count(
self,
db_file: str,
local_nodes: list[str],
count: int | None,
over_limit: bool,
) -> None:
key = self._count_cache_key(db_file, local_nodes)
self._rules_count_cache[key] = (count, over_limit, time.monotonic())
def _start_stage_worker(
self,
*,
worker: Any,
done_signal_name: str,
done_handler: Callable[[object], None],
thread_name: str,
) -> None:
worker_thread = QtCore.QThread(self._dialog)
worker_thread.setObjectName(thread_name)
worker.moveToThread(worker_thread)
self._snapshot_worker = worker
self._snapshot_thread = worker_thread
worker_thread.started.connect(worker.run)
getattr(worker, done_signal_name).connect(done_handler)
worker.finished.connect(worker_thread.quit)
worker.finished.connect(worker.deleteLater)
worker_thread.finished.connect(
lambda w=worker, t=worker_thread: self._on_snapshot_worker_stopped(w, t)
)
worker_thread.finished.connect(worker_thread.deleteLater)
worker_thread.start()
def _confirm_potential_snapshot_delay(
self,
*,
estimated_rules: int | None,
count_over_limit: bool,
in_memory_db: bool,
shared_memory_db: bool,
) -> bool:
may_freeze = in_memory_db and not shared_memory_db
estimated_delay_ms: int | None = None
if estimated_rules is not None and estimated_rules > 0:
estimated_delay_ms = int(
estimated_rules * (self._fetch_ms_per_row + self._process_ms_per_row)
)
may_delay = count_over_limit or (
estimated_delay_ms is not None
and estimated_delay_ms >= ATTACHED_RULES_WARN_MIN_ESTIMATED_DELAY_MS
)
if not may_freeze and not may_delay:
return True
if may_freeze and count_over_limit and estimated_rules is not None:
message = QC.translate(
"stats",
"Loading attached rules may freeze the UI briefly (at least {0} rules on local nodes). Continue?",
).format(estimated_rules)
elif may_freeze and estimated_rules is not None:
message = QC.translate(
"stats",
"Loading attached rules may freeze the UI briefly (about {0} rules on local nodes). Continue?",
).format(estimated_rules)
elif may_freeze:
message = QC.translate(
"stats",
"Loading attached rules may freeze the UI briefly on this setup. Continue?",
)
elif count_over_limit and estimated_rules is not None:
message = QC.translate(
"stats",
"Loading attached rules may take longer than usual (at least {0} rules on local nodes). Continue?",
).format(estimated_rules)
elif estimated_rules is not None and estimated_delay_ms is not None:
message = QC.translate(
"stats",
"Loading attached rules may take longer than usual (~{0:.1f}s, {1} rules on local nodes). Continue?",
).format(max(0.1, estimated_delay_ms / 1000.0), estimated_rules)
else:
message = QC.translate(
"stats",
"Loading attached rules may take longer than usual. Continue?",
)
reply = QtWidgets.QMessageBox.question(
self._dialog,
QC.translate("stats", "Attached rules lookup"),
message,
QtWidgets.QMessageBox.StandardButton.Yes
| QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.No,
)
return reply == QtWidgets.QMessageBox.StandardButton.Yes
def _update_count_query_delay_factor(self, elapsed_ms: int) -> None:
sample = max(0.25, min(4.0, elapsed_ms / ATTACHED_RULES_COUNT_BASELINE_MS))
self._count_query_delay_factor = (0.7 * self._count_query_delay_factor) + (0.3 * sample)
def _update_fetch_ms_per_row(self, elapsed_ms: int, row_count: int) -> None:
if row_count < 1:
return
sample = max(0.001, min(50.0, elapsed_ms / row_count))
self._fetch_ms_per_row = 0.7 * self._fetch_ms_per_row + 0.3 * sample
def _update_process_ms_per_row(self, elapsed_ms: int, row_count: int) -> None:
if row_count < 1:
return
sample = max(0.001, min(50.0, elapsed_ms / row_count))
self._process_ms_per_row = 0.7 * self._process_ms_per_row + 0.3 * sample
def _snapshot_timeout_interval_ms(self) -> int:
count = self._pending_snapshot_row_count
if count > 0:
estimated = int(
count
* (self._fetch_ms_per_row + self._process_ms_per_row)
* ATTACHED_RULES_TIMEOUT_SAFETY_FACTOR
)
return max(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS, min(ATTACHED_RULES_TIMEOUT_MAX_MS, estimated))
# fallback: scale base timeout by count query delay factor
scaled = int(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS * max(self._count_query_delay_factor, 0.75))
return max(ATTACHED_RULES_SNAPSHOT_TIMEOUT_MS, min(ATTACHED_RULES_TIMEOUT_MAX_MS, scaled))
def refresh_attached_rules_snapshot_async(
self,
callback: Callable[[dict[str, list[dict[str, Any]]]], None],
) -> None:
self._snapshot_callbacks.append(callback)
self._snapshot_timed_out = False
thread = self._snapshot_thread
if thread is not None and thread.isRunning():
return
local_nodes: list[str] = []
for addr in self._dialog._nodes.get().keys():
try:
if self._dialog._nodes.is_local(addr):
local_nodes.append(addr)
except Exception:
continue
db_file = str(Database.instance().get_db_file() or "").strip()
in_memory_db = _is_memory_db_file(db_file)
shared_memory_db = _is_shared_memory_db_file(db_file)
self._pending_snapshot_db_file = db_file
self._pending_snapshot_local_nodes = local_nodes
self._pending_snapshot_in_memory_db = in_memory_db
self._pending_snapshot_shared_memory_db = shared_memory_db
db_mode, snapshot_mode = self._snapshot_db_mode(db_file)
self._dialog._status_controller.debug(
QC.translate(
"stats",
"Attached-rules snapshot DB mode: {0} ({1})",
).format(db_mode, snapshot_mode),
origin="ui:attached-rules",
)
if self._snapshot_cache_dirty:
self._dialog._status_controller.debug(
QC.translate(
"stats",
"Attached-rules cache is dirty; forcing fresh snapshot.",
),
origin="ui:attached-rules",
)
# Three DB modes:
# 1) classic private in-memory -> no detached worker; use sync snapshot
# 2) shared in-memory URI -> detached worker is safe
# 3) file DB (path or file: URI) -> detached worker is safe
if in_memory_db and not shared_memory_db:
if not self._confirm_potential_snapshot_delay(
estimated_rules=None,
count_over_limit=False,
in_memory_db=in_memory_db,
shared_memory_db=shared_memory_db,
):
snapshot = self._rules_index.snapshot()
callbacks = self._snapshot_callbacks[:]
self._snapshot_callbacks.clear()
for pending_callback in callbacks:
pending_callback(snapshot)
return
snapshot = self._attached_rules_snapshot_sync()
self._on_snapshot_worker_done(snapshot)
return
cached_rules_count = self._get_cached_rules_count(db_file, local_nodes)
if cached_rules_count is None:
self._snapshot_phase = "count"
count_worker_cls = getattr(attached_rules_workers, "AttachedRulesCountWorker")
count_worker = count_worker_cls(
db_file=db_file,
local_nodes=local_nodes,
)
self._start_stage_worker(
worker=count_worker,
done_signal_name="count_done",
done_handler=self._on_rules_count_done,
thread_name="AttachedRulesCountWorkerThread",
)
return
cached_count, cached_over_limit = cached_rules_count
self._pending_snapshot_count_over_limit = cached_over_limit
self._continue_snapshot_after_count(cached_count)
def _continue_snapshot_after_count(self, estimated_rules: int | None) -> None:
if not self._confirm_potential_snapshot_delay(
estimated_rules=estimated_rules,
count_over_limit=self._pending_snapshot_count_over_limit,
in_memory_db=self._pending_snapshot_in_memory_db,
shared_memory_db=self._pending_snapshot_shared_memory_db,
):
snapshot = self._rules_index.snapshot()
callbacks = self._snapshot_callbacks[:]
self._snapshot_callbacks.clear()
for pending_callback in callbacks:
pending_callback(snapshot)
return
self._pending_snapshot_row_count = estimated_rules if estimated_rules is not None else 0
self._snapshot_timeout_timer.setInterval(self._snapshot_timeout_interval_ms())
self._snapshot_timeout_timer.start()
self._snapshot_phase = "fetch"
fetch_worker_cls = getattr(attached_rules_workers, "AttachedRulesFetchWorker")
fetch_worker = fetch_worker_cls(
db_file=self._pending_snapshot_db_file,
local_nodes=self._pending_snapshot_local_nodes,
)
self._start_stage_worker(
worker=fetch_worker,
done_signal_name="rows_done",
done_handler=self._on_snapshot_rows_fetched,
thread_name="AttachedRulesFetchWorkerThread",
)
def _on_rules_count_done(self, count_obj: object) -> None:
db_file = self._pending_snapshot_db_file
local_nodes = self._pending_snapshot_local_nodes
count: int | None = None
elapsed_ms: int | None = None
over_limit = False
if isinstance(count_obj, dict):
raw_count = count_obj.get("count")
raw_elapsed = count_obj.get("elapsed_ms")
raw_over_limit = count_obj.get("over_limit")
count = raw_count if isinstance(raw_count, int) else None
elapsed_ms = raw_elapsed if isinstance(raw_elapsed, int) else None
over_limit = bool(raw_over_limit)
elif isinstance(count_obj, int):
count = count_obj
if elapsed_ms is not None:
self._update_count_query_delay_factor(elapsed_ms)
self._dialog._status_controller.debug(
QC.translate(
"stats",
"Attached-rules count query: {0} ms (delay factor {1:.2f})",
).format(elapsed_ms, self._count_query_delay_factor),
origin="ui:attached-rules",
)
self._pending_snapshot_count_over_limit = over_limit
self._set_cached_rules_count(db_file, local_nodes, count, over_limit)
self._continue_snapshot_after_count(count)
def _on_snapshot_rows_fetched(self, rows_obj: object) -> None:
rows: list = []
if isinstance(rows_obj, dict):
raw_rows = rows_obj.get("rows")
raw_elapsed = rows_obj.get("elapsed_ms")
raw_row_count = rows_obj.get("row_count")
rows = raw_rows if isinstance(raw_rows, list) else []
if isinstance(raw_elapsed, int):
actual_count = raw_row_count if isinstance(raw_row_count, int) else len(rows)
self._update_fetch_ms_per_row(raw_elapsed, actual_count)
self._dialog._status_controller.debug(
QC.translate(
"stats",
"Attached-rules fetch: {0} ms for {1} rows ({2:.3f} ms/row EMA)",
).format(raw_elapsed, actual_count, self._fetch_ms_per_row),
origin="ui:attached-rules",
)
elif isinstance(rows_obj, list):
rows = rows_obj
self._snapshot_phase = "process"
process_worker_cls = getattr(attached_rules_workers, "AttachedRulesProcessWorker")
process_worker = process_worker_cls(rows=rows)
self._start_stage_worker(
worker=process_worker,
done_signal_name="snapshot_done",
done_handler=self._on_snapshot_worker_done,
thread_name="AttachedRulesSnapshotWorkerThread",
)
def show_attached_rules_dialog(self):
rows = self._dialog._selection_controller.selected_rows()
if len(rows) != 1:
self._dialog._status_controller.set_status(
QC.translate("stats", "Select a single subscription row first."),
error=True,
)
return
row = rows[0]
def _open_dialog(snapshot: dict[str, list[dict[str, Any]]]) -> None:
if not self._dialog.isVisible():
return
def _get_attached_rules() -> list[dict[str, Any]]:
from opensnitch.plugins.list_subscriptions._utils import (
DEFAULT_LISTS_DIR,
normalize_groups,
normalize_lists_dir,
safe_filename,
)
tdc = self._dialog._table_data_controller
lists_dir = normalize_lists_dir(
self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR
)
filename = safe_filename(tdc.cell_text(row, tdc._col("filename")))
list_type = (tdc.cell_text(row, tdc._col("format")) or "hosts").strip().lower()
groups = normalize_groups(tdc.cell_text(row, tdc._col("group")))
return self.aggregate_attached_rules(
tdc.rule_attachment_matches(
lists_dir,
filename,
list_type,
groups,
attached_rules_by_dir=snapshot,
include_disabled=True,
)
)
dlg = AttachedRulesDialog(
self._dialog,
get_attached_rules=_get_attached_rules,
on_create_rule=self._dialog._rules_editor_controller.create_rule_from_selected,
on_edit_rule=self.edit_attached_rule_entry,
on_toggle_rule=self.toggle_attached_rule_entry,
on_remove_rule=self.remove_attached_rule_entry,
)
dlg.exec()
self._dialog._status_controller.set_status(
QC.translate("stats", "Loading attached rules..."),
error=False,
log=False,
)
def _on_snapshot(snapshot: dict[str, list[dict[str, Any]]]) -> None:
timed_out = self._snapshot_timed_out
self._snapshot_timed_out = False
if timed_out:
if snapshot:
self._dialog._status_controller.set_status(
QC.translate(
"stats",
"Attached-rules lookup timed out; showing cached results.",
),
error=True,
origin="ui:attached-rules",
)
else:
self._dialog._status_controller.set_status(
QC.translate(
"stats",
"Attached-rules lookup timed out; showing no results.",
),
error=True,
origin="ui:attached-rules",
)
else:
self._dialog._status_controller.set_status("", error=False, log=False)
_open_dialog(snapshot)
self.refresh_attached_rules_snapshot_async(_on_snapshot)
def attached_rule_scope_parts(self, source: str):
normalized = (source or "").strip()
if normalized == "subscription":
return "single", ""
if normalized == "all":
return "all", ""
if normalized.startswith("group:"):
return "group", normalized.split(":", 1)[1].strip()
return normalized or "other", ""
def aggregate_attached_rules(
self,
attached_rules: list[dict[str, Any]],
) -> list[dict[str, Any]]:
aggregated: dict[tuple[str, str], dict[str, Any]] = {}
for entry in attached_rules:
addr = str(entry.get("addr", "")).strip()
name = str(entry.get("name", "")).strip()
if addr == "" or name == "":
continue
key = (addr, name)
current = aggregated.get(key)
if current is None:
current = {
"addr": addr,
"name": name,
"enabled": bool(entry.get("enabled", True)),
"single": False,
"all": False,
"groups": set(),
}
aggregated[key] = current
else:
current["enabled"] = bool(entry.get("enabled", True))
scope_kind, scope_value = self.attached_rule_scope_parts(
str(entry.get("source", ""))
)
if scope_kind == "single":
current["single"] = True
elif scope_kind == "all":
current["all"] = True
elif scope_kind == "group" and scope_value != "":
from typing import cast
cast(set[str], current["groups"]).add(scope_value)
aggregated_rows = list(aggregated.values())
for entry in aggregated_rows:
entry["groups"] = sorted(entry["groups"])
aggregated_rows.sort(key=lambda item: (item["name"].lower(), item["addr"]))
return aggregated_rows
def rule_attachment_scope_summary(self, matches: list[dict[str, Any]]):
has_single = False
has_all = False
groups: set[str] = set()
other_sources: set[str] = set()
for entry in matches:
scope_kind, scope_value = self.attached_rule_scope_parts(
str(entry.get("source", ""))
)
if scope_kind == "single":
has_single = True
elif scope_kind == "all":
has_all = True
elif scope_kind == "group" and scope_value != "":
groups.add(scope_value)
elif scope_kind != "":
other_sources.add(scope_kind)
parts: list[str] = []
if has_single:
parts.append(QC.translate("stats", "single"))
if has_all:
parts.append(QC.translate("stats", "all"))
if groups:
parts.append(
QC.translate("stats", "groups: {0}").format(
", ".join(sorted(groups))
)
)
if other_sources:
parts.extend(sorted(other_sources))
return ", ".join(parts)
def rule_entry_identity(self, entry: dict[str, Any]):
addr = str(entry.get("addr", "")).strip()
name = str(entry.get("name", "")).strip()
if addr == "" or name == "":
return None
return addr, name
def find_rule_record(self, addr: str, rule_name: str):
records = self._dialog._nodes.get_rules(addr)
if records is None or records == -1:
return None
while records.next():
try:
rule = cast(ui_pb2.Rule, Rule.new_from_records(records))
except Exception:
continue
if str(rule.name or "").strip() == rule_name:
return records
return None
def edit_attached_rule_entry(self, entry: dict[str, Any]):
identity = self.rule_entry_identity(entry)
if identity is None:
return
addr, name = identity
self.open_attached_rule_in_editor(addr, name)
def toggle_attached_rule_entry(self, entry: dict[str, Any]):
identity = self.rule_entry_identity(entry)
if identity is None:
return
addr, name = identity
records = self.find_rule_record(addr, name)
if records is None:
self._dialog._status_controller.set_status(
QC.translate("stats", "Rule not found: {0}").format(name),
error=True,
)
return
try:
rule = cast(ui_pb2.Rule, Rule.new_from_records(records))
except Exception:
self._dialog._status_controller.set_status(
QC.translate("stats", "Failed to load rule: {0}").format(name),
error=True,
)
return
if bool(getattr(rule, "enabled", True)):
self._dialog._nodes.disable_rule(addr, name)
self.update_cached_rule_enabled(addr, name, False)
self._dialog._status_controller.set_status(
QC.translate("stats", "Rule updated: {0} disabled").format(name),
error=False,
)
else:
rule.enabled = True
self._dialog._nodes.add_rules(addr, [rule])
self._dialog._nodes.send_notification(
addr,
ui_pb2.Notification(
type=ui_pb2.CHANGE_RULE,
rules=[rule],
),
None,
)
self.update_cached_rule_enabled(addr, name, True)
self._dialog._status_controller.set_status(
QC.translate("stats", "Rule updated: {0} enabled").format(name),
error=False,
)
self._dialog._table_data_controller.refresh_states()
def remove_attached_rule_entry(self, entry: dict[str, Any]):
identity = self.rule_entry_identity(entry)
if identity is None:
return
addr, name = identity
confirmed = QtWidgets.QMessageBox.question(
self._dialog,
QC.translate("stats", "Remove rule"),
QC.translate(
"stats",
"Remove rule '{0}' on node {1}? This action cannot be undone.",
).format(name, addr),
QtWidgets.QMessageBox.StandardButton.Yes
| QtWidgets.QMessageBox.StandardButton.No,
QtWidgets.QMessageBox.StandardButton.No,
)
if confirmed != QtWidgets.QMessageBox.StandardButton.Yes:
return
nid, _noti = self._dialog._nodes.delete_rule(name, addr, None)
if nid is None:
self._dialog._status_controller.set_status(
QC.translate("stats", "Failed to remove rule: {0}").format(name),
error=True,
)
return
self.remove_cached_rule(addr, name)
self._dialog._table_data_controller.refresh_states()
self._dialog._status_controller.set_status(
QC.translate("stats", "Rule deleted: {0}").format(name),
error=False,
)
def open_attached_rule_in_editor(self, addr: str, rule_name: str):
records = self.find_rule_record(addr, rule_name)
if records is None:
self._dialog._status_controller.set_status(
QC.translate("stats", "Rule not found: {0}").format(rule_name),
error=True,
)
return
rules_dialog = self._dialog._rules_editor_controller.ensure_rules_dialog()
if rules_dialog is None:
self._dialog._status_controller.set_status(
QC.translate("stats", "Rules editor is not available."),
error=True,
)
return
rules_dialog.edit_rule(records, _addr=addr)
self._dialog._table_data_controller.refresh_states()
@@ -0,0 +1,462 @@
import os
from typing import TYPE_CHECKING, Any
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
from opensnitch.plugins.list_subscriptions._utils import (
DEFAULT_LISTS_DIR,
list_file_path,
normalize_group,
normalize_groups,
normalize_lists_dir,
safe_filename,
subscription_rule_dir,
)
from opensnitch.config import Config
from opensnitch.dialogs.ruleseditor import RulesEditorDialog
from opensnitch.dialogs.ruleseditor import constants as ruleseditor_constants
from opensnitch.rules import Rules
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class _RulesDialogEventFilter(QtCore.QObject):
def __init__(self, controller: "RulesEditorController"):
super().__init__(controller._dialog)
self._controller = controller
def eventFilter(self, a0, a1):
event_type = a1.type() if a1 is not None else None
if event_type == QtCore.QEvent.Type.Show:
self._controller._on_rules_dialog_shown()
elif event_type in (
QtCore.QEvent.Type.Hide,
QtCore.QEvent.Type.Close,
):
self._controller._on_rules_dialog_hidden()
return super().eventFilter(a0, a1)
class RulesEditorController:
def __init__(
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
):
self._dialog = dialog
self._cols = columns
self._rules = Rules.instance()
self._pending_rule_change: dict[str, Any] | None = None
self._rules_dialog_event_filter: _RulesDialogEventFilter | None = None
self._rules.updated.connect(self._handle_rules_updated)
def _col(self, key: str):
return self._cols[key]
def ensure_rules_dialog(self):
if self._dialog._rules_dialog is None:
appicon = (
self._dialog.windowIcon()
if self._dialog.windowIcon() is not None
else None
)
try:
self._dialog._rules_dialog = RulesEditorDialog(
parent=None,
appicon=appicon,
)
except TypeError:
self._dialog._rules_dialog = RulesEditorDialog()
self._install_rules_dialog_event_filter()
self._connect_rules_dialog_signals()
return self._dialog._rules_dialog
def _install_rules_dialog_event_filter(self):
rules_dialog = self._dialog._rules_dialog
if rules_dialog is None:
return
if self._rules_dialog_event_filter is None:
self._rules_dialog_event_filter = _RulesDialogEventFilter(self)
rules_dialog.installEventFilter(self._rules_dialog_event_filter)
def _connect_rules_dialog_signals(self):
rules_dialog = self._dialog._rules_dialog
if rules_dialog is None:
return
save_button = rules_dialog.buttonBox.button(
QtWidgets.QDialogButtonBox.StandardButton.Save
)
if save_button is None:
return
try:
save_button.pressed.disconnect(self._capture_rule_save_context)
except Exception:
pass
save_button.pressed.connect(self._capture_rule_save_context)
def _capture_rule_save_context(self):
rules_dialog = self._dialog._rules_dialog
if rules_dialog is None:
return
node_index = rules_dialog.nodesCombo.currentIndex()
node_addr = str(rules_dialog.nodesCombo.itemData(node_index) or "").strip()
rule_dirs: list[str] = []
if rules_dialog.dstListsCheck.isChecked():
rule_dir = str(rules_dialog.dstListsLine.text() or "").strip()
if rule_dir != "":
rule_dirs.append(rule_dir)
self._pending_rule_change = {
"mode": int(getattr(ruleseditor_constants, "WORK_MODE", 0)),
"old_name": str(getattr(rules_dialog, "_old_rule_name", "") or "").strip(),
"new_name": str(rules_dialog.ruleNameEdit.text() or "").strip(),
"addr": node_addr,
"enabled": bool(rules_dialog.enableCheck.isChecked()),
"directories": rule_dirs,
"apply_all": bool(rules_dialog.nodeApplyAllCheck.isChecked()),
}
def _handle_rules_updated(self, _value: int):
if self._pending_rule_change is None:
return
# Defer so we exit cb_save_clicked's call chain before touching the DB.
# Running a DB scan synchronously inside Rules.updated (which is emitted
# from cb_save_clicked) blocks the main thread while the gRPC reply for
# the CHANGE_RULE notification is still pending, causing the daemon to
# appear locked.
QtCore.QTimer.singleShot(0, self._finalize_pending_rule_change)
def _finalize_pending_rule_change(self):
pending = self._pending_rule_change
self._pending_rule_change = None
if pending is None:
return
mode = int(pending.get("mode") or 0)
old_name = str(pending.get("old_name") or "").strip()
new_name = str(pending.get("new_name") or "").strip()
if mode == ruleseditor_constants.ADD_RULE:
message = QC.translate("stats", "Rule created: {0}").format(
new_name or old_name
)
elif old_name != "" and new_name != "" and old_name != new_name:
message = QC.translate(
"stats", "Rule updated: {0} (renamed from {1})"
).format(new_name, old_name)
else:
message = QC.translate("stats", "Rule updated: {0}").format(
new_name or old_name
)
if bool(pending.get("apply_all", False)):
self._dialog._rules_attachment_controller.invalidate_snapshot_cache(
"rule-editor-apply-all"
)
else:
self._dialog._rules_attachment_controller.apply_rule_editor_change(pending)
self._dialog._status_controller.set_status(message, error=False)
self._dialog._table_data_controller.refresh_states()
self._dialog._selection_controller.update_selected_actions_state()
def _on_rules_dialog_shown(self):
self._dialog._table_data_controller.stop_poll()
def _on_rules_dialog_hidden(self):
self._pending_rule_change = None
# Restart polling right away, but defer the DB-heavy refresh so it
# runs after the dialog teardown event chain has fully unwound.
self._dialog._table_data_controller.start_poll()
if self._dialog.isVisible() and not self._dialog._loading:
QtCore.QTimer.singleShot(0, self._deferred_post_editor_refresh)
def _deferred_post_editor_refresh(self):
if not self._dialog.isVisible() or self._dialog._loading:
return
self._dialog._table_data_controller.refresh_states()
self._dialog._selection_controller.update_selected_actions_state()
def configure_rules_dialog_for_local_user(self):
rules_dialog = self._dialog._rules_dialog
if rules_dialog is None:
return False
local_addr = None
for addr in self._dialog._nodes.get().keys():
try:
if self._dialog._nodes.is_local(addr):
local_addr = addr
break
except Exception:
continue
if local_addr is None:
self._dialog._status_controller.set_status(
QC.translate(
"stats",
"No local OpenSnitch node is connected. Rules can only be created for the local user.",
),
error=True,
)
rules_dialog.hide()
return False
nodes_combo = rules_dialog.nodesCombo
node_idx = nodes_combo.findData(local_addr)
if node_idx != -1:
nodes_combo.setCurrentIndex(node_idx)
nodes_combo.setEnabled(False)
rules_dialog.nodeApplyAllCheck.setChecked(False)
rules_dialog.nodeApplyAllCheck.setEnabled(False)
rules_dialog.nodeApplyAllCheck.setVisible(False)
uid_text = str(os.getuid())
uid_combo = rules_dialog.uidCombo
uid_idx = uid_combo.findData(int(uid_text))
rules_dialog.uidCheck.setChecked(True)
uid_combo.setEnabled(True)
if uid_idx != -1:
uid_combo.setCurrentIndex(uid_idx)
else:
uid_combo.setCurrentText(uid_text)
return True
def apply_rule_editor_defaults(self):
rules_dialog = self._dialog._rules_dialog
if rules_dialog is None:
return
rules_dialog.enableCheck.setChecked(True)
duration_idx = rules_dialog.durationCombo.findData(Config.DURATION_ALWAYS)
if duration_idx < 0:
duration_idx = rules_dialog.durationCombo.findText(
Config.DURATION_ALWAYS,
QtCore.Qt.MatchFlag.MatchFixedString,
)
if duration_idx < 0:
duration_idx = 8
rules_dialog.durationCombo.setCurrentIndex(duration_idx)
def choose_group_for_selected(self, rows: list[int]):
if not rows:
return None
selected_group_sets = [
set(
normalize_groups(
self._dialog._table_data_controller.cell_text(
r,
self._col("group"),
)
)
)
for r in rows
]
common = (
set.intersection(*selected_group_sets) if selected_group_sets else set()
)
known = self._dialog._selection_controller.known_groups()
default_group = ""
if common:
default_group = sorted(common)[0]
if default_group != "" and default_group not in known:
known.append(default_group)
known = sorted(set(known)) or [""]
try:
default_idx = known.index(default_group)
except ValueError:
default_idx = 0
value, ok = QtWidgets.QInputDialog.getItem(
self._dialog,
QC.translate("stats", "Create rule from multiple subscriptions"),
QC.translate(
"stats", "Select or enter a group to aggregate selected subscriptions:"
),
known,
default_idx,
True,
)
if not ok:
return None
group = normalize_group(value)
if group in ("", "all"):
self._dialog._status_controller.set_status(
QC.translate("stats", "Group cannot be empty."), error=True
)
return None
return group
def assign_group_to_rows(self, rows: list[int], group: str):
if not rows:
return False
target_group = normalize_group(group)
for row in rows:
groups = normalize_groups(
self._dialog._table_data_controller.cell_text(row, self._col("group"))
)
groups.append(target_group)
groups = normalize_groups(groups)
self._dialog._table_data_controller.set_text_item(
row,
self._col("group"),
", ".join(groups),
)
return True
def prepare_rule_dir(
self,
url: str,
filename: str,
list_path: str,
lists_dir: str,
list_type: str,
):
_ = (url, list_path)
rule_dir = subscription_rule_dir(lists_dir, filename, list_type)
try:
os.makedirs(rule_dir, mode=0o700, exist_ok=True)
return rule_dir
except Exception as e:
self._dialog._status_controller.set_status(
QC.translate(
"stats", "Error preparing list rule directory: {0}"
).format(str(e)),
error=True,
)
return None
def create_rule_from_selected(self):
rows = self._dialog._selection_controller.selected_rows()
if not rows:
row = self._dialog.table.currentRow()
if row >= 0:
rows = [row]
if not rows:
self._dialog._status_controller.set_status(
QC.translate("stats", "Select one or more subscriptions first."),
error=True,
)
return
lists_dir = normalize_lists_dir(
self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR
)
if len(rows) == 1:
row = rows[0]
url = self._dialog._table_data_controller.cell_text(row, self._col("url"))
filename, filename_changed = (
self._dialog._table_data_controller.ensure_row_final_filename(row)
)
if url == "" or filename == "":
self._dialog._status_controller.set_status(
QC.translate("stats", "URL and filename cannot be empty."),
error=True,
)
return
if filename_changed:
self._dialog._action_file_controller.save_action_file()
list_type = (
self._dialog._table_data_controller.cell_text(row, self._col("format"))
) or "hosts"
list_type = list_type.strip().lower()
list_path = list_file_path(lists_dir, filename, list_type)
rule_dir = self.prepare_rule_dir(
url,
filename,
list_path,
lists_dir,
list_type,
)
if rule_dir is None:
return
rule_token = os.path.splitext(safe_filename(filename))[0]
rule_name = f"00-blocklist-{rule_token}"
desc = f"From list subscription : {filename}"
else:
rule_group = self.choose_group_for_selected(rows)
if rule_group is None:
return
if not self.assign_group_to_rows(rows, rule_group):
return
self._dialog._action_file_controller.save_action_file()
rule_dir = os.path.join(lists_dir, "rules.list.d", rule_group)
try:
os.makedirs(rule_dir, mode=0o700, exist_ok=True)
except Exception as e:
self._dialog._status_controller.set_status(
QC.translate(
"stats", "Error preparing grouped rule directory: {0}"
).format(str(e)),
error=True,
)
return
rule_name = f"00-blocklist-{rule_group}"
desc = f"From list subscription : {rule_group}"
rules_dialog = self.ensure_rules_dialog()
if rules_dialog is None:
self._dialog._status_controller.set_status(
QC.translate("stats", "Rules editor is not available."),
error=True,
)
return
rules_dialog.new_rule()
if not self.configure_rules_dialog_for_local_user():
return
self.apply_rule_editor_defaults()
rules_dialog.dstListsCheck.setChecked(True)
rules_dialog.dstListsLine.setText(rule_dir)
if rules_dialog.ruleNameEdit.text().strip() == "":
rules_dialog.ruleNameEdit.setText(rule_name)
if rules_dialog.ruleDescEdit.toPlainText().strip() == "":
rules_dialog.ruleDescEdit.setPlainText(desc)
rules_dialog.raise_()
rules_dialog.activateWindow()
self._dialog._status_controller.set_status(
QC.translate(
"stats", "Rules Editor opened with prefilled list directory path."
),
error=False,
)
def create_global_rule(self):
lists_dir = normalize_lists_dir(
self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR
)
rule_dir = os.path.join(lists_dir, "rules.list.d", "all")
try:
os.makedirs(rule_dir, mode=0o700, exist_ok=True)
except Exception as e:
self._dialog._status_controller.set_status(
QC.translate(
"stats", "Error preparing global rule directory: {0}"
).format(str(e)),
error=True,
)
return
rules_dialog = self.ensure_rules_dialog()
if rules_dialog is None:
self._dialog._status_controller.set_status(
QC.translate("stats", "Rules editor is not available."),
error=True,
)
return
rules_dialog.new_rule()
if not self.configure_rules_dialog_for_local_user():
return
self.apply_rule_editor_defaults()
rule_name = "00-blocklist-all"
rules_dialog.dstListsCheck.setChecked(True)
rules_dialog.dstListsLine.setText(rule_dir)
if rules_dialog.ruleNameEdit.text().strip() == "":
rules_dialog.ruleNameEdit.setText(rule_name)
if rules_dialog.ruleDescEdit.toPlainText().strip() == "":
rules_dialog.ruleDescEdit.setPlainText("From list subscription : all")
rules_dialog.raise_()
rules_dialog.activateWindow()
self._dialog._status_controller.set_status(
QC.translate(
"stats", "Rules Editor opened with global list directory path."
),
error=False,
)
@@ -0,0 +1,439 @@
import os
from typing import TYPE_CHECKING, Any, cast
from opensnitch.plugins import PluginSignal
from opensnitch.plugins.list_subscriptions.list_subscriptions import ListSubscriptions
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
from opensnitch.plugins.list_subscriptions.models.events import RuntimeEventType
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class RuntimeController:
def __init__(
self,
*,
dialog: "ListSubscriptionsDialog",
status_label: QtWidgets.QLabel,
start_button: QtWidgets.QPushButton,
stop_button: QtWidgets.QPushButton,
):
self._dialog = dialog
self._status_label = status_label
self._start_button = start_button
self._stop_button = stop_button
# -- UI state -----------------------------------------------------------
def set_runtime_state(self, active: bool | None, text: str | None = None):
if text is None:
if active is True:
text = QC.translate("stats", "Runtime: active")
elif active is False:
text = QC.translate("stats", "Runtime: inactive")
else:
text = QC.translate("stats", "Runtime: pending")
if active is True:
style = "color: green;"
elif active is False:
style = "color: #666666;"
else:
style = "color: #b36b00;"
self._status_label.setStyleSheet(style)
self._status_label.setText(text)
self._start_button.setEnabled(active is not True)
self._stop_button.setEnabled(active is not False)
# -- Refresh busy state -------------------------------------------------
def set_refresh_busy(self, busy: bool):
self._dialog.refresh_state_button.setEnabled(not busy)
self._dialog.refresh_now_button.setEnabled(
not busy and len(self._dialog._selection_controller.selected_rows()) > 0
)
def track_refresh_keys(self, keys: set[str]):
if not keys:
return
self._dialog._pending_refresh_keys.update(keys)
self.set_refresh_busy(True)
def clear_refresh_key(self, key: str):
self._dialog._pending_refresh_keys.discard(key)
self._dialog._active_refresh_keys.discard(key)
if not self._dialog._pending_refresh_keys and not self._dialog._active_refresh_keys:
self.set_refresh_busy(False)
def refresh_keys_from_payload(self, payload: dict[str, Any]):
items_raw = payload.get("items")
keys: set[str] = set()
if isinstance(items_raw, list):
for item in items_raw:
if not isinstance(item, dict):
continue
key = str(item.get("key") or "").strip()
if key != "":
keys.add(key)
return keys
def runtime_event_items(self, payload: dict[str, Any]):
items_raw = payload.get("items")
if not isinstance(items_raw, list):
return []
return [item for item in items_raw if isinstance(item, dict)]
def runtime_download_message(
self,
event_name: RuntimeEventType | None,
payload: dict[str, Any],
fallback: str,
):
items = self.runtime_event_items(payload)
if not items:
return fallback
count = len(items)
first_name = str(items[0].get("name") or "").strip()
if event_name == RuntimeEventType.DOWNLOAD_STARTED:
if count == 1 and first_name != "":
return QC.translate("stats", "Refreshing subscription '{0}'.").format(
first_name
)
return QC.translate("stats", "Refreshing {0} subscriptions.").format(count)
if event_name == RuntimeEventType.DOWNLOAD_FINISHED:
if count == 1 and first_name != "":
return QC.translate("stats", "Subscription '{0}' refreshed.").format(
first_name
)
return QC.translate("stats", "Refreshed {0} subscriptions.").format(count)
if event_name == RuntimeEventType.DOWNLOAD_FAILED:
if count == 1 and first_name != "":
return QC.translate(
"stats", "Subscription '{0}' refresh failed."
).format(first_name)
return QC.translate(
"stats", "Refresh failed for {0} subscriptions."
).format(count)
return fallback
def handle_runtime_event(self, event: dict[str, Any]):
payload = event if isinstance(event, dict) else {}
message = str(payload.get("message") or "").strip()
error_detail = str(payload.get("error") or "").strip()
event_keys = self.refresh_keys_from_payload(payload)
event_value = payload.get("event")
if isinstance(event_value, int):
try:
event_name = RuntimeEventType(event_value)
except Exception:
event_name = None
else:
event_name = None
is_error = event_name in (
RuntimeEventType.RUNTIME_ERROR,
RuntimeEventType.DOWNLOAD_FAILED,
RuntimeEventType.FILE_SAVE_ERROR,
RuntimeEventType.FILE_LOAD_ERROR,
)
if event_name == RuntimeEventType.DOWNLOAD_STARTED:
for key in event_keys:
if key in self._dialog._pending_refresh_keys:
self._dialog._pending_refresh_keys.discard(key)
self._dialog._active_refresh_keys.add(key)
self.set_refresh_busy(True)
elif event_name in (
RuntimeEventType.DOWNLOAD_FINISHED,
RuntimeEventType.DOWNLOAD_FAILED,
):
for key in event_keys:
self.clear_refresh_key(key)
if event_name in (
RuntimeEventType.DOWNLOAD_FINISHED,
RuntimeEventType.DOWNLOAD_FAILED,
):
self._dialog._table_data_controller.refresh_states()
self._dialog._selection_controller.update_selected_actions_state()
if event_name == RuntimeEventType.RUNTIME_ENABLED:
self.set_runtime_state(active=True)
elif event_name in (
RuntimeEventType.RUNTIME_DISABLED,
RuntimeEventType.RUNTIME_STOPPED,
):
self.set_runtime_state(active=False)
elif self._dialog._pending_runtime_reload is not None:
self.set_runtime_state(
active=None,
text=QC.translate("stats", "Runtime: reloading"),
)
elif is_error:
self.set_runtime_state(
active=None, text=QC.translate("stats", "Runtime: error")
)
if self._dialog._pending_runtime_reload == "waiting_config_reload":
if event_name == RuntimeEventType.CONFIG_RELOADED:
self._dialog._pending_runtime_reload = None
self._dialog._action_file_controller.load_action_file()
return
if is_error:
self._dialog._pending_runtime_reload = None
if message == "":
message = QC.translate("stats", "Plugin runtime event: {0}").format(
str(event_value or "unknown")
)
if event_name in (
RuntimeEventType.DOWNLOAD_STARTED,
RuntimeEventType.DOWNLOAD_FINISHED,
RuntimeEventType.DOWNLOAD_FAILED,
):
message = self.runtime_download_message(event_name, payload, message)
if is_error and error_detail != "":
message = f"{message} {error_detail}".strip()
self._dialog._status_controller.set_status(
message,
error=is_error,
origin="backend:event",
)
# -- Lifecycle ----------------------------------------------------------
def _runtime_reload_failed_message(self):
return QC.translate(
"stats", "Config saved but runtime reload failed. Restart UI."
)
def start_runtime_clicked(self):
runtime_plugin = self.sync_runtime_binding_state()
if runtime_plugin is not None and bool(getattr(runtime_plugin, "enabled", False)):
self.bind_runtime_plugin(runtime_plugin)
self.set_runtime_state(active=True)
self._dialog._status_controller.set_status(QC.translate("stats", "Runtime is already active."))
return
if not os.path.exists(self._dialog._action_path):
self._dialog._status_controller.set_status(
QC.translate(
"stats",
"Action file not found. Create and save the configuration first.",
),
error=True,
)
return
if runtime_plugin is None:
runtime_plugin = ListSubscriptions({})
self.bind_runtime_plugin(runtime_plugin)
self.set_runtime_state(
active=None,
text=QC.translate("stats", "Runtime: starting"),
)
self._dialog._status_controller.append_log(
QC.translate("stats", "Runtime start requested."),
)
try:
runtime_plugin.signal_in.emit(
{
"plugin": runtime_plugin.get_name(),
"signal": PluginSignal.ENABLE,
"action_path": self._dialog._action_path,
}
)
except Exception:
self.set_runtime_state(active=False)
self._dialog._status_controller.set_status(
QC.translate("stats", "Failed to start runtime."),
error=True,
)
def stop_runtime_clicked(self):
runtime_plugin = self.sync_runtime_binding_state()
if runtime_plugin is None or not bool(getattr(runtime_plugin, "enabled", False)):
self.set_runtime_state(active=False)
self._dialog._status_controller.set_status(QC.translate("stats", "Runtime is already inactive."))
return
self.bind_runtime_plugin(runtime_plugin)
self.set_runtime_state(
active=None,
text=QC.translate("stats", "Runtime: stopping"),
)
self._dialog._status_controller.append_log(
QC.translate("stats", "Runtime stop requested."),
)
try:
runtime_plugin.signal_in.emit(
{
"plugin": runtime_plugin.get_name(),
"signal": PluginSignal.DISABLE,
"action_path": self._dialog._action_path,
}
)
except Exception:
self._dialog._status_controller.set_status(
QC.translate("stats", "Failed to stop runtime."),
error=True,
)
def reload_runtime_and_config(self):
runtime_plugin = self.sync_runtime_binding_state()
if runtime_plugin is None or not bool(getattr(runtime_plugin, "enabled", False)):
self._dialog._action_file_controller.load_action_file()
return
self.bind_runtime_plugin(runtime_plugin)
self._dialog._pending_runtime_reload = "waiting_config_reload"
self._dialog._status_controller.append_log(
QC.translate("stats", "Runtime config reload requested."),
)
try:
runtime_plugin.signal_in.emit(
{
"plugin": runtime_plugin.get_name(),
"signal": PluginSignal.CONFIG_UPDATE,
"action_path": self._dialog._action_path,
}
)
except Exception:
self._dialog._pending_runtime_reload = None
self._dialog._status_controller.set_status(
QC.translate("stats", "Runtime reload failed to start. Restart UI."),
error=True,
)
def apply_runtime_state(self, enabled: bool):
loaded = self.find_loaded_action()
old_key, _old_action, old_plugin = loaded if loaded is not None else (None, None, None)
runtime_plugin = ListSubscriptions.get_instance()
target_plugin = runtime_plugin if runtime_plugin is not None else old_plugin
was_enabled = bool(getattr(target_plugin, "enabled", False))
if target_plugin is not None:
self.bind_runtime_plugin(target_plugin)
try:
signal = None
if enabled:
signal = (
PluginSignal.CONFIG_UPDATE
if was_enabled
else PluginSignal.ENABLE
)
elif was_enabled:
signal = PluginSignal.DISABLE
if signal is not None:
target_plugin.signal_in.emit(
{
"plugin": target_plugin.get_name(),
"signal": signal,
"action_path": self._dialog._action_path,
}
)
except Exception:
self._dialog._status_controller.set_status(
self._runtime_reload_failed_message(),
error=True,
)
return
if not enabled and old_key is not None:
self._dialog._actions.delete(old_key)
return
if not enabled:
if old_key is not None:
self._dialog._actions.delete(old_key)
return
obj, compiled = self._dialog._actions.load(self._dialog._action_path)
if obj is None or compiled is None:
self._dialog._status_controller.set_status(
self._runtime_reload_failed_message(),
error=True,
)
return
obj = cast(dict[str, Any], obj)
compiled = cast(dict[str, Any], compiled)
action_name = obj.get("name")
if old_key is not None and old_key != action_name:
self._dialog._actions.delete(old_key)
if isinstance(action_name, str) and action_name != "":
self._dialog._actions._actions_list[action_name] = compiled
compiled_actions = cast(dict[str, Any], compiled.get("actions", {}))
plug = cast(
ListSubscriptions | None, compiled_actions.get("list_subscriptions")
)
if plug is None:
self._dialog._status_controller.set_status(
self._runtime_reload_failed_message(),
error=True,
)
return
self.bind_runtime_plugin(plug)
try:
plug.signal_in.emit(
{
"plugin": plug.get_name(),
"signal": PluginSignal.ENABLE,
"action_path": self._dialog._action_path,
}
)
except Exception:
self._dialog._status_controller.set_status(
self._runtime_reload_failed_message(),
error=True,
)
def sync_runtime_binding_state(self):
runtime_plugin = ListSubscriptions.get_instance()
if runtime_plugin is None:
loaded = self.find_loaded_action()
runtime_plugin = loaded[2] if loaded is not None else None
if runtime_plugin is not None:
self.bind_runtime_plugin(runtime_plugin)
self.set_runtime_state(
active=bool(getattr(runtime_plugin, "enabled", False)),
)
return runtime_plugin
self._dialog._runtime_plugin = None
self._dialog._status_controller.set_backend_log_sink(None)
self.set_runtime_state(active=False)
self.set_refresh_busy(False)
return None
def bind_runtime_plugin(self, plug: ListSubscriptions | None):
if plug is None:
self._dialog._status_controller.set_backend_log_sink(None)
return
try:
plug.signal_out.disconnect(self.handle_runtime_event)
except Exception:
pass
try:
plug.log_out.disconnect(self._dialog._status_controller.ingest_backend_log)
except Exception:
pass
try:
plug.signal_out.connect(self.handle_runtime_event)
plug.log_out.connect(self._dialog._status_controller.ingest_backend_log)
self._dialog._status_controller.set_backend_log_sink(plug.ingest_ui_log)
self._dialog._runtime_plugin = plug
except Exception:
self._dialog._status_controller.set_backend_log_sink(None)
self._dialog._runtime_plugin = None
def find_loaded_action(self):
for action_key, action_obj in self._dialog._actions.getAll().items():
if action_obj is None:
continue
action_obj_dict = cast(dict[str, Any], action_obj)
action_cfg = cast(dict[str, Any], action_obj_dict.get("actions", {}))
plug = cast(ListSubscriptions | None, action_cfg.get("list_subscriptions"))
if plug is not None:
return str(action_key), action_obj_dict, plug
@@ -0,0 +1,79 @@
from typing import TYPE_CHECKING
from opensnitch.plugins.list_subscriptions.ui import QC
from opensnitch.plugins.list_subscriptions._utils import normalize_groups
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class SelectionController:
def __init__(
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
):
self._dialog = dialog
self._cols = columns
def _col(self, key: str):
return self._cols[key]
def selected_rows(self):
idx = self._dialog.table.selectionModel()
if idx is None:
return []
return sorted({i.row() for i in idx.selectedRows()})
def update_selected_actions_state(self):
count = len(self.selected_rows())
has_selection = count > 0
self._dialog.edit_sub_button.setEnabled(has_selection)
self._dialog.remove_sub_button.setEnabled(has_selection)
self._dialog.refresh_now_button.setEnabled(
has_selection
and not self._dialog._pending_refresh_keys
and not self._dialog._active_refresh_keys
)
self._dialog.create_rule_button.setEnabled(has_selection)
if count == 1:
self._dialog.create_rule_button.setText(QC.translate("stats", "Rules"))
else:
self._dialog.create_rule_button.setText(
QC.translate("stats", "Create rule")
)
def open_rules_action(self):
rows = self.selected_rows()
if not rows:
self._dialog._status_controller.set_status(
QC.translate("stats", "Select a subscription row first."),
error=True,
)
return
if len(rows) == 1:
self._dialog._rules_attachment_controller.show_attached_rules_dialog()
return
self._dialog._rules_editor_controller.create_rule_from_selected()
def open_selected_inspector(self):
rows = self.selected_rows()
if not rows:
self._dialog._status_controller.set_status(
QC.translate("stats", "Select a subscription row first."),
error=True,
)
return
if hasattr(self._dialog, "_inspect_collapsed"):
self._dialog._inspect_collapsed = False
self._dialog._inspector_controller.update_inspector_panel()
def known_groups(self):
groups: set[str] = set()
for row in range(self._dialog.table.rowCount()):
for g in normalize_groups(
self._dialog._table_data_controller.cell_text(row, self._col("group"))
):
if g not in ("", "all"):
groups.add(g)
return sorted(groups)
@@ -0,0 +1,344 @@
from collections.abc import Callable
import queue
import threading
from typing import TYPE_CHECKING, Literal
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.status_log_dialog import (
StatusLogDialog,
)
EmptyButtonBehavior = Literal["hide", "show-if-logs"]
MAX_BACKEND_LOGS_PER_UI_TICK = 100
def _set_preview_label_text(
label: QtWidgets.QLabel,
*,
text: str,
preview_limit: int,
):
full_text = (text or "").strip()
if full_text == "":
label.setText("")
label.setToolTip("")
return
if len(full_text) <= preview_limit:
label.setText(full_text)
label.setToolTip(full_text)
return
preview = full_text[: preview_limit - 1].rstrip() + "..."
label.setText(preview)
label.setToolTip(full_text)
def append_log_entry(
entries: list[str],
*,
message: str,
error: bool = False,
level: str | None = None,
origin: str | None = None,
dedupe: bool = False,
last_signature: tuple[str, bool] | None = None,
timestamp_format: str = "HH:mm:ss",
limit: int = 200,
):
full_text = (message or "").strip()
if full_text == "":
return
signature = (full_text, bool(error))
if dedupe and signature == last_signature:
return
timestamp = QtCore.QDateTime.currentDateTime().toString(timestamp_format)
log_level = level or ("ERROR" if error else "INFO")
log_origin = (origin or "ui").strip()
entries.append(f"[{timestamp}] [{log_level}] [{log_origin}] {full_text}")
if len(entries) > limit:
del entries[:-limit]
def apply_status_label(
label: QtWidgets.QLabel,
*,
message: str,
error: bool,
preview_limit: int,
inspect_button: QtWidgets.QPushButton | None = None,
empty_button_behavior: Literal["hide", "show-if-logs"] = "hide",
log_entry_count: int = 0,
ok_color: str = "green",
error_color: str = "red",
):
label.setStyleSheet(f"color: {error_color if error else ok_color};")
full_text = (message or "").strip()
if full_text == "":
label.setText("")
label.setToolTip("")
if inspect_button is not None:
if empty_button_behavior == "show-if-logs":
has_logs = log_entry_count > 0
inspect_button.setVisible(has_logs)
inspect_button.setEnabled(has_logs)
else:
inspect_button.setVisible(False)
inspect_button.setEnabled(False)
return full_text
_set_preview_label_text(
label,
text=full_text,
preview_limit=preview_limit,
)
if inspect_button is not None:
inspect_button.setVisible(True)
inspect_button.setEnabled(True)
return full_text
class DialogStatusController:
def __init__(
self,
*,
label: QtWidgets.QLabel,
inspect_button: QtWidgets.QPushButton | None,
preview_limit: int,
log_limit: int,
timestamp_format: str,
ok_color: str,
error_color: str,
empty_button_behavior: EmptyButtonBehavior,
):
self._label = label
self._inspect_button = inspect_button
self._preview_limit = preview_limit
self._log_limit = log_limit
self._timestamp_format = timestamp_format
self._ok_color = ok_color
self._error_color = error_color
self._empty_button_behavior: EmptyButtonBehavior = empty_button_behavior
self._full_text = ""
self._log_entries: list[str] = []
self._last_signature: tuple[str, bool] | None = None
self._backend_log_sink: Callable[[str, str, str], None] | None = None
self._backend_sink_queue: queue.SimpleQueue[tuple[str, str, str]] = (
queue.SimpleQueue()
)
self._backend_sink_worker_started = False
self._backend_to_ui_queue: queue.SimpleQueue[tuple[str, str, str]] = (
queue.SimpleQueue()
)
self._backend_to_ui_timer = QtCore.QTimer(self._label)
self._backend_to_ui_timer.setInterval(100)
self._backend_to_ui_timer.timeout.connect(self._drain_backend_to_ui_queue)
self._backend_to_ui_timer.start()
self._log_dialog: "StatusLogDialog | None" = None
self._log_dialog_level_color: Callable[[str], str] | None = None
self._log_dialog_timestamp_color = ""
def _start_backend_sink_worker(self) -> None:
if self._backend_sink_worker_started:
return
self._backend_sink_worker_started = True
def _run() -> None:
while True:
message, level, origin = self._backend_sink_queue.get()
sink = self._backend_log_sink
if sink is None:
continue
try:
sink(message, level, origin)
except Exception:
pass
th = threading.Thread(
target=_run,
name="DialogStatusBackendSink",
daemon=True,
)
th.start()
def _drain_backend_to_ui_queue(self) -> None:
for _ in range(MAX_BACKEND_LOGS_PER_UI_TICK):
try:
message, level, origin = self._backend_to_ui_queue.get_nowait()
except queue.Empty:
return
self.append_log(
message,
level=level,
origin=origin,
forward_backend=False,
)
@property
def full_text(self):
return self._full_text
@property
def log_entries(self):
return self._log_entries
def log(self, message: str, level: str = "INFO", origin: str = "ui") -> None:
"""Generic slot: connect any pyqtSignal(str, str) directly to this."""
self.append_log(message, level=level, origin=origin)
def ingest_backend_log(
self,
message: str,
level: str = "INFO",
origin: str = "backend",
) -> None:
"""Backend -> UI path; enqueue to avoid blocking backend threads."""
full_text = (message or "").strip()
if full_text == "":
return
self._backend_to_ui_queue.put((full_text, level, origin))
def set_backend_log_sink(
self,
sink: Callable[[str, str, str], None] | None,
) -> None:
self._backend_log_sink = sink
if sink is not None:
self._start_backend_sink_worker()
def debug(self, message: str, origin: str = "ui") -> None:
self.append_log(message, level="DEBUG", origin=origin)
def info(self, message: str, origin: str = "ui") -> None:
self.append_log(message, level="INFO", origin=origin)
def warn(self, message: str, origin: str = "ui") -> None:
self.append_log(message, level="WARN", origin=origin)
def error(self, message: str, origin: str = "ui") -> None:
self.append_log(message, level="ERROR", origin=origin)
def trace(self, message: str, origin: str = "ui") -> None:
self.append_log(message, level="TRACE", origin=origin)
def append_log(
self,
message: str,
*,
error: bool = False,
level: str | None = None,
origin: str = "ui",
dedupe: bool = False,
forward_backend: bool = True,
):
full_text = (message or "").strip()
if full_text == "":
return
resolved_level = (level or ("ERROR" if error else "INFO")).upper()
append_log_entry(
self._log_entries,
message=full_text,
error=error,
level=resolved_level,
origin=origin,
dedupe=dedupe,
last_signature=self._last_signature,
timestamp_format=self._timestamp_format,
limit=self._log_limit,
)
if (
forward_backend
and self._backend_log_sink is not None
and not origin.lower().startswith("backend")
):
self._backend_sink_queue.put((full_text, resolved_level, origin))
self._refresh_log_dialog_if_open()
def _refresh_log_dialog_if_open(self) -> None:
dlg = self._log_dialog
if dlg is None or not dlg.isVisible():
return
if self._log_dialog_level_color is None:
return
try:
dlg.update_entries(
lines=self._log_entries[:],
fallback_text=self._full_text,
level_color=self._log_dialog_level_color,
timestamp_color=self._log_dialog_timestamp_color,
)
except Exception:
pass
def set_status(
self,
message: str,
*,
error: bool = False,
log: bool = True,
origin: str = "ui",
):
full_text = apply_status_label(
self._label,
message=message,
error=error,
preview_limit=self._preview_limit,
inspect_button=self._inspect_button,
empty_button_behavior=self._empty_button_behavior,
log_entry_count=len(self._log_entries),
ok_color=self._ok_color,
error_color=self._error_color,
)
self._full_text = full_text
if full_text == "":
return
if not log:
return
signature = (full_text, bool(error))
if signature != self._last_signature:
self.append_log(full_text, error=error, origin=origin)
self._last_signature = signature
def show_log_dialog(
self,
parent: "QtWidgets.QWidget",
*,
title: str,
level_color: Callable[[str], str],
timestamp_color: str,
) -> None:
from opensnitch.plugins.list_subscriptions.ui.views.status_log_dialog import (
StatusLogDialog,
)
self._log_dialog_level_color = level_color
self._log_dialog_timestamp_color = timestamp_color
dlg = self._log_dialog
if dlg is None:
dlg = StatusLogDialog(
parent,
title=title,
lines=self._log_entries[:],
fallback_text=self._full_text,
level_color=level_color,
timestamp_color=timestamp_color,
)
dlg.setWindowModality(QtCore.Qt.WindowModality.NonModal)
dlg.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, True)
dlg.destroyed.connect(lambda *_: setattr(self, "_log_dialog", None))
self._log_dialog = dlg
else:
dlg.update_entries(
lines=self._log_entries[:],
fallback_text=self._full_text,
level_color=level_color,
timestamp_color=timestamp_color,
)
dlg.show()
dlg.raise_()
dlg.activateWindow()
@@ -0,0 +1,297 @@
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtGui, QC
from opensnitch.plugins.list_subscriptions._utils import (
deslugify_filename,
derive_filename,
ensure_filename_type_suffix,
is_valid_url,
safe_filename,
)
from opensnitch.plugins.list_subscriptions.ui.workers import UrlTestWorker
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.subscription_dialog import (
SubscriptionDialog,
)
class SubscriptionDialogController:
def __init__(self, *, dialog: "SubscriptionDialog"):
self._dialog = dialog
self._refresh_signal: Any = None
self._url_worker: UrlTestWorker | None = None
self._url_thread: QtCore.QThread | None = None
self._url_worker_stopped_callbacks: list[Callable[[], None]] = []
self._shutting_down = False
dialog.destroyed.connect(self._on_dialog_destroyed)
app = QtGui.QGuiApplication.instance()
if app is not None:
app.aboutToQuit.connect(self._on_app_about_to_quit)
def _on_dialog_destroyed(self, *_args):
self.shutdown_url_worker(wait_ms=3000)
def _on_app_about_to_quit(self):
self.shutdown_url_worker(wait_ms=3000)
def has_active_url_test(self) -> bool:
thread = self._url_thread
return bool(thread is not None and thread.isRunning())
def on_url_test_stopped(self, callback: Callable[[], None]) -> None:
if not self.has_active_url_test():
callback()
return
self._url_worker_stopped_callbacks.append(callback)
def cancel_active_url_test(self):
worker = self._url_worker
thread = self._url_thread
if worker is None or thread is None or not thread.isRunning():
return
worker.stop()
thread.quit()
def wait_for_active_url_test_stop(self, wait_ms: int = 1200) -> bool:
worker = self._url_worker
thread = self._url_thread
if worker is None or thread is None:
return True
if not thread.isRunning():
self._url_worker = None
self._url_thread = None
return True
worker.stop()
thread.quit()
if wait_ms <= 0:
stopped = thread.wait()
else:
stopped = thread.wait(wait_ms)
if stopped:
self._url_worker = None
self._url_thread = None
return bool(stopped)
def shutdown_url_worker(self, wait_ms: int = 2000) -> bool:
self._shutting_down = True
worker = self._url_worker
thread = self._url_thread
if worker is None or thread is None:
self._url_worker = None
self._url_thread = None
return True
try:
worker.test_result.disconnect(self._dialog._url_test_finished.emit)
except Exception:
pass
if thread.isRunning():
worker.stop()
thread.quit()
if wait_ms <= 0:
thread.wait()
else:
thread.wait(wait_ms)
if thread.isRunning():
thread.terminate()
thread.wait(500)
if thread.isRunning():
self._url_worker = worker
self._url_thread = thread
return False
self._url_worker = None
self._url_thread = None
return True
# -- Meta refresh -------------------------------------------------------
def connect_to_refresh_signal(self, signal: Any) -> None:
self._refresh_signal = signal
signal.connect(self.on_state_refreshed)
def on_state_refreshed(self, url: str, filename: str, meta: dict[str, str]) -> None:
if url != str(self._dialog._sub.url) or filename != str(self._dialog._sub.filename):
return
self.update_meta(meta)
def update_meta(self, meta: dict[str, str]) -> None:
self._dialog.meta_file_present.setText(str(meta.get("file_present", "")))
self._dialog.meta_meta_present.setText(str(meta.get("meta_present", "")))
self._dialog.meta_state.setText(str(meta.get("state", "")))
self.apply_meta_state_color(str(meta.get("state", "")))
self._dialog.meta_last_checked.setText(str(meta.get("last_checked", "")))
self._dialog.meta_last_updated.setText(str(meta.get("last_updated", "")))
self._dialog.meta_failures.setText(str(meta.get("failures", "")))
self._dialog.meta_error.setText(str(meta.get("error", "")))
self._dialog.meta_list_path.setText(str(meta.get("list_path", "")))
self._dialog.meta_meta_path.setText(str(meta.get("meta_path", "")))
def apply_meta_state_color(self, state: str) -> None:
normalized = (state or "").strip().lower()
dark_theme = (
self._dialog.palette()
.color(QtGui.QPalette.ColorRole.Window)
.lightness()
< 128
)
if dark_theme:
healthy_color = "#7CE3A1"
pending_color = "#F5D76E"
problematic_color = "#FF8A80"
else:
healthy_color = "#0F8A4B"
pending_color = "#9A6700"
problematic_color = "#C62828"
if normalized in ("updated", "not_modified"):
color = healthy_color
elif normalized == "pending":
color = pending_color
else:
color = problematic_color
self._dialog.meta_state.setStyleSheet(f"color: {color};")
def disconnect_signal(self) -> None:
self.cancel_active_url_test()
if self._refresh_signal is not None:
try:
self._refresh_signal.disconnect(self.on_state_refreshed)
except Exception:
pass
self._refresh_signal = None
# -- Field helpers ------------------------------------------------------
def sync_optional_fields_state(self) -> None:
self._dialog.interval_units.setEnabled(self._dialog.interval_spin.value() > 0)
self._dialog.timeout_units.setEnabled(self._dialog.timeout_spin.value() > 0)
self._dialog.max_size_units.setEnabled(self._dialog.max_size_spin.value() > 0)
def clear_field_errors(self) -> None:
self.set_dialog_message("", error=False)
self._dialog.name_error_label.setText("")
self._dialog.url_error_label.setText("")
self._dialog.filename_error_label.setText("")
def set_dialog_message(self, message: str, error: bool) -> None:
self._dialog._dialog_message_controller.set_status(message, error=error)
if (message or "").strip():
self._dialog.log_message.emit(
message,
"ERROR" if error else "INFO",
str(getattr(self._dialog, "_log_origin", "ui:subscription")),
)
# -- URL test -----------------------------------------------------------
def test_url(self) -> None:
if self._shutting_down:
return
self._dialog.url_error_label.setText("")
self.set_dialog_message("", error=False)
url = (self._dialog.url_edit.text() or "").strip()
if url == "":
self._dialog.url_error_label.setText(QC.translate("stats", "URL is required."))
self.set_dialog_message(
QC.translate("stats", "Fix the highlighted fields."), error=True
)
return
if not is_valid_url(url):
self._dialog.url_error_label.setText(
QC.translate("stats", "Enter a valid http:// or https:// URL.")
)
self.set_dialog_message(
QC.translate("stats", "Fix the highlighted fields."), error=True
)
return
self._dialog.test_url_button.setEnabled(False)
self.set_dialog_message(QC.translate("stats", "Testing URL..."), error=False)
self.shutdown_url_worker(wait_ms=100)
self._shutting_down = False
list_type = (self._dialog.format_combo.currentText() or "hosts").strip().lower()
thread = QtCore.QThread(self._dialog)
thread.setObjectName("UrlTestWorkerThread")
worker = UrlTestWorker(url, list_type)
worker.setObjectName("UrlTestWorker")
worker.moveToThread(thread)
self._url_worker = worker
self._url_thread = thread
thread.started.connect(worker.run)
worker.test_result.connect(self._dialog._url_test_finished.emit)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(self._on_url_test_worker_stopped)
thread.finished.connect(thread.deleteLater)
thread.start()
def _on_url_test_worker_stopped(self) -> None:
self._url_worker = None
self._url_thread = None
callbacks = self._url_worker_stopped_callbacks[:]
self._url_worker_stopped_callbacks.clear()
for callback in callbacks:
callback()
def handle_url_test_finished(self, success: bool, message: str) -> None:
if self._shutting_down:
return
self._dialog.test_url_button.setEnabled(True)
if success:
self._dialog.url_error_label.setText("")
self.set_dialog_message(message, error=False)
return
self._dialog.url_error_label.setText(QC.translate("stats", "URL check failed."))
self.set_dialog_message(message, error=True)
# -- Validation ---------------------------------------------------------
def validate_then_accept(self) -> None:
self.clear_field_errors()
raw_url = (self._dialog.url_edit.text() or "").strip()
raw_name = (self._dialog.name_edit.text() or "").strip()
raw_filename = (self._dialog.filename_edit.text() or "").strip()
list_type = (self._dialog.format_combo.currentText() or "hosts").strip().lower()
name = raw_name
filename = safe_filename(raw_filename)
has_error = False
if raw_url == "":
self._dialog.url_error_label.setText(QC.translate("stats", "URL is required."))
has_error = True
elif not is_valid_url(raw_url):
self._dialog.url_error_label.setText(
QC.translate("stats", "Enter a valid http:// or https:// URL.")
)
has_error = True
if raw_name == "" and raw_filename == "":
self._dialog.name_error_label.setText(
QC.translate("stats", "Provide a name or filename.")
)
self._dialog.filename_error_label.setText(
QC.translate("stats", "Provide a filename or name.")
)
has_error = True
elif raw_filename != "" and filename != raw_filename:
self._dialog.filename_error_label.setText(
QC.translate("stats", "Filename must not include directory components.")
)
has_error = True
if has_error:
self.set_dialog_message(
QC.translate("stats", "Fix the highlighted fields."), error=True
)
return
if filename == "" and name != "":
filename = safe_filename(derive_filename(name, None, ""))
filename = ensure_filename_type_suffix(filename, list_type)
if name == "" and filename != "":
name = deslugify_filename(filename, list_type)
self._dialog.name_edit.setText(name)
self._dialog.filename_edit.setText(filename)
self._dialog.accept()
@@ -0,0 +1,269 @@
import os
from typing import TYPE_CHECKING
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
from opensnitch.plugins.list_subscriptions.models.subscriptions import (
MutableSubscriptionSpec,
)
from opensnitch.plugins.list_subscriptions.ui.views.subscription_dialog import (
SubscriptionDialog,
)
from opensnitch.plugins.list_subscriptions._utils import (
INTERVAL_UNITS,
SIZE_UNITS,
TIMEOUT_UNITS,
display_str,
normalize_groups,
safe_filename,
strip_or_none,
)
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class SubscriptionEditController:
def __init__(
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
):
self._dialog = dialog
self._cols = columns
def _col(self, key: str):
return self._cols[key]
def add_subscription_row(self):
dlg = SubscriptionDialog(
self._dialog,
self._dialog._global_defaults,
groups=self._dialog._selection_controller.known_groups(),
sub=MutableSubscriptionSpec.from_dict(
{"enabled": True},
defaults=self._dialog._global_defaults,
require_url=False,
ensure_suffix=False,
),
title="New subscription",
)
dlg.log_message.connect(self._dialog._status_controller.log)
if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
return
sub = dlg.subscription_spec()
with self._dialog._table_view_controller.sorting_suspended():
self._dialog._table_data_controller.append_row(sub)
row = self._dialog.table.rowCount() - 1
_, changed = self._dialog._table_data_controller.ensure_row_final_filename(row)
if changed:
self._dialog._table_data_controller.refresh_states()
if not os.path.exists(self._dialog._action_path):
self._dialog._action_file_controller.create_action_file()
self._dialog._action_file_controller.save_action_file()
self._dialog._selection_controller.update_selected_actions_state()
def edit_selected_subscription(self):
row = self._dialog.table.currentRow()
if row < 0:
self._dialog._status_controller.set_status(
QC.translate("stats", "Select a subscription row first."), error=True
)
return
with self._dialog._table_view_controller.sorting_suspended():
enabled_item = self._dialog.table.item(row, self._col("enabled"))
if enabled_item is None:
enabled_item = self._dialog._table_data_controller.new_enabled_item(False)
self._dialog.table.setItem(row, self._col("enabled"), enabled_item)
interval_ok, interval_val = self._dialog._table_data_controller.optional_int_from_text(
self._dialog._table_data_controller.cell_text(row, self._col("interval")),
"Interval",
row=row,
)
timeout_ok, timeout_val = self._dialog._table_data_controller.optional_int_from_text(
self._dialog._table_data_controller.cell_text(row, self._col("timeout")),
"Timeout",
row=row,
)
max_size_ok, max_size_val = self._dialog._table_data_controller.optional_int_from_text(
self._dialog._table_data_controller.cell_text(row, self._col("max_size")),
"Max size",
row=row,
)
if not interval_ok or not timeout_ok or not max_size_ok:
return
sub = MutableSubscriptionSpec(
enabled=enabled_item.checkState() == QtCore.Qt.CheckState.Checked,
name=self._dialog._table_data_controller.cell_text(row, self._col("name")),
url=self._dialog._table_data_controller.cell_text(row, self._col("url")),
filename=self._dialog._table_data_controller.cell_text(
row, self._col("filename")
),
format=self._dialog._table_data_controller.cell_text(
row, self._col("format")
) or "hosts",
groups=normalize_groups(
self._dialog._table_data_controller.cell_text(row, self._col("group"))
),
interval=interval_val,
interval_units=strip_or_none(
self._dialog._table_data_controller.cell_text(
row, self._col("interval_units")
)
),
timeout=timeout_val,
timeout_units=strip_or_none(
self._dialog._table_data_controller.cell_text(
row, self._col("timeout_units")
)
),
max_size=max_size_val,
max_size_units=strip_or_none(
self._dialog._table_data_controller.cell_text(
row, self._col("max_size_units")
)
),
)
meta = self._dialog._table_data_controller.row_meta_snapshot(row)
dlg = SubscriptionDialog(
self._dialog,
self._dialog._global_defaults,
groups=self._dialog._selection_controller.known_groups(),
sub=sub,
meta=meta,
title="Edit subscription",
)
dlg.log_message.connect(self._dialog._status_controller.log)
dlg._subscription_dialog_controller.connect_to_refresh_signal(
self._dialog.subscription_state_refreshed
)
if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
return
updated = dlg.subscription_spec()
with self._dialog._table_view_controller.sorting_suspended():
enabled_item = self._dialog.table.item(row, self._col("enabled"))
if enabled_item is None:
enabled_item = self._dialog._table_data_controller.new_enabled_item(False)
self._dialog.table.setItem(row, self._col("enabled"), enabled_item)
enabled_item.setCheckState(
QtCore.Qt.CheckState.Checked
if bool(updated.enabled)
else QtCore.Qt.CheckState.Unchecked
)
enabled_item.setData(
QtCore.Qt.ItemDataRole.UserRole, 1 if bool(updated.enabled) else 0
)
self._dialog._table_data_controller.set_text_item(
row, self._col("name"), updated.name
)
self._dialog._table_data_controller.set_text_item(
row, self._col("url"), updated.url
)
self._dialog._table_data_controller.set_text_item(
row,
self._col("filename"),
safe_filename(updated.filename),
)
self._dialog._table_data_controller.set_text_item(
row, self._col("format"), updated.format
)
self._dialog._table_data_controller.set_text_item(
row, self._col("group"), ", ".join(normalize_groups(updated.groups))
)
self._dialog._table_data_controller.set_text_item(
row,
self._col("interval"),
display_str(updated.interval),
)
interval_units_val = display_str(updated.interval_units)
self._dialog._table_data_controller.set_text_item(
row,
self._col("interval_units"),
interval_units_val,
)
self._dialog._table_data_controller.set_text_item(
row,
self._col("timeout"),
display_str(updated.timeout),
)
timeout_units_val = display_str(updated.timeout_units)
self._dialog._table_data_controller.set_text_item(
row,
self._col("timeout_units"),
timeout_units_val,
)
self._dialog._table_data_controller.set_text_item(
row,
self._col("max_size"),
display_str(updated.max_size),
)
max_size_units_val = display_str(updated.max_size_units)
self._dialog._table_data_controller.set_text_item(
row,
self._col("max_size_units"),
max_size_units_val,
)
self._dialog._defaults_ui_controller.set_units_combo(
row, self._col("interval_units"), INTERVAL_UNITS, interval_units_val
)
self._dialog._defaults_ui_controller.set_units_combo(
row, self._col("timeout_units"), TIMEOUT_UNITS, timeout_units_val
)
self._dialog._defaults_ui_controller.set_units_combo(
row, self._col("max_size_units"), SIZE_UNITS, max_size_units_val
)
_, changed = self._dialog._table_data_controller.ensure_row_final_filename(row)
self._dialog._table_data_controller.update_row_sort_keys(row)
self._dialog._action_file_controller.save_action_file()
self._dialog._table_data_controller.refresh_states()
if changed:
self._dialog._status_controller.set_status(
QC.translate("stats", "Subscription updated and filename normalized."),
error=False,
)
else:
self._dialog._status_controller.set_status(
QC.translate("stats", "Subscription updated."), error=False
)
def edit_action_clicked(self):
rows = self._dialog._selection_controller.selected_rows()
if len(rows) == 0:
self._dialog._status_controller.set_status(
QC.translate("stats", "Select one or more subscriptions first."),
error=True,
)
return
if len(rows) == 1:
self.edit_selected_subscription()
return
self._dialog._bulk_edit_controller.bulk_edit(rows)
def remove_selected_subscription(self):
rows = self._dialog._selection_controller.selected_rows()
if not rows:
row = self._dialog.table.currentRow()
if row >= 0:
rows = [row]
if not rows:
self._dialog._status_controller.set_status(
QC.translate("stats", "Select one or more subscription rows first."),
error=True,
)
return
for row in sorted(rows, reverse=True):
self._dialog.table.removeRow(row)
self._dialog._action_file_controller.save_action_file()
self._dialog._table_data_controller.refresh_states()
self._dialog._selection_controller.update_selected_actions_state()
self._dialog._status_controller.set_status(
QC.translate("stats", "Selected subscriptions removed."), error=False
)
def handle_table_item_double_clicked(self, item: QtWidgets.QTableWidgetItem):
if item is not None:
self._dialog.table.selectRow(item.row())
self.edit_selected_subscription()
@@ -0,0 +1,86 @@
from typing import TYPE_CHECKING
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
from opensnitch.plugins.list_subscriptions.ui.views.subscription_status_dialog import (
SubscriptionStatusDialog,
)
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class SubscriptionStatusController:
def __init__(
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
):
self._dialog = dialog
self._cols = columns
def _col(self, key: str):
return self._cols[key]
def show_selected_subscription_status(self):
rows = self._dialog._selection_controller.selected_rows()
if not rows:
row = self._dialog.table.currentRow()
if row >= 0:
rows = [row]
if not rows:
self._dialog._status_controller.set_status(
QC.translate("stats", "Select a subscription row first."),
error=True,
)
return
row = rows[0]
name = self._dialog._table_data_controller.cell_text(row, self._col("name"))
url = self._dialog._table_data_controller.cell_text(row, self._col("url"))
filename = self._dialog._table_data_controller.cell_text(
row,
self._col("filename"),
)
meta = self.meta_snapshot_by_identity(url, filename)
if meta is None:
meta = self._dialog._table_data_controller.row_meta_snapshot(row)
dlg = SubscriptionStatusDialog(
self._dialog,
name=name,
url=url,
filename=filename,
meta=meta,
)
dlg.connect_to_refresh_signal(
self._dialog.subscription_state_refreshed
)
if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted:
return
action = dlg.action()
if action == SubscriptionStatusDialog.ACTION_EDIT:
self._dialog._subscription_edit_controller.edit_selected_subscription()
elif action == SubscriptionStatusDialog.ACTION_REFRESH:
self._dialog._table_data_controller.refresh_selected_now()
def find_row_by_identity(self, url: str, filename: str):
for row in range(self._dialog.table.rowCount()):
if self._dialog._table_data_controller.cell_text(row, self._col("url")) != url:
continue
if (
self._dialog._table_data_controller.cell_text(
row,
self._col("filename"),
)
!= filename
):
continue
return row
return -1
def meta_snapshot_by_identity(self, url: str, filename: str):
row = self.find_row_by_identity(url, filename)
if row < 0:
return None
return self._dialog._table_data_controller.row_meta_snapshot(row)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,226 @@
from contextlib import contextmanager
from typing import TYPE_CHECKING
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class TableViewController:
def __init__(
self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int]
):
self._dialog = dialog
self._cols = columns
def _col(self, key: str):
return self._cols[key]
def _visible_columns(self) -> list[int]:
return [
col
for col in range(self._dialog.table.columnCount())
if not self._dialog.table.isColumnHidden(col)
]
def _expand_visible_columns_to_viewport(self, base_widths: dict[int, int]) -> None:
visible_cols = [col for col in self._visible_columns() if col in base_widths]
if not visible_cols:
return
viewport_width = self._dialog.table.viewport().width()
if viewport_width <= 0:
return
min_total = sum(max(1, int(base_widths.get(col, 1))) for col in visible_cols)
if viewport_width <= min_total:
return
extra = viewport_width - min_total
add_each = extra // len(visible_cols)
remainder = extra % len(visible_cols)
for idx, col in enumerate(visible_cols):
target = max(1, int(base_widths.get(col, 1))) + add_each
if idx < remainder:
target += 1
self._dialog.table.setColumnWidth(col, target)
@contextmanager
def sorting_suspended(self):
header = self._dialog.table.horizontalHeader()
sorting_enabled = self._dialog.table.isSortingEnabled()
sort_section = header.sortIndicatorSection() if header is not None else -1
sort_order = (
header.sortIndicatorOrder()
if header is not None
else QtCore.Qt.SortOrder.AscendingOrder
)
self._dialog.table.setSortingEnabled(False)
try:
yield
finally:
self._dialog.table.setSortingEnabled(sorting_enabled)
if sorting_enabled and header is not None and sort_section >= 0:
self._dialog.table.sortItems(sort_section, sort_order)
self.apply_table_view_mode()
def on_table_view_tab_changed(self, index: int):
monitoring = index == 0
always_hidden = {
self._col("interval"),
self._col("interval_units"),
self._col("timeout"),
self._col("timeout_units"),
self._col("max_size"),
self._col("max_size_units"),
self._col("file"),
self._col("meta"),
self._col("rule_attached"),
}
monitoring_only = {
self._col("state"),
self._col("last_checked"),
self._col("last_updated"),
}
config_only = {
self._col("enabled"),
self._col("url"),
self._col("filename"),
self._col("format"),
self._col("group"),
}
for col in range(self._dialog.table.columnCount()):
if col in always_hidden:
self._dialog.table.setColumnHidden(col, True)
elif col in monitoring_only:
self._dialog.table.setColumnHidden(col, not monitoring)
elif col in config_only:
self._dialog.table.setColumnHidden(col, monitoring)
self.apply_table_column_sizing(index)
self.apply_table_view_mode(index, set_sort=True)
self._dialog._inspector_controller.update_inspector_panel()
def apply_table_column_sizing(self, index: int | None = None):
header = self._dialog.table.horizontalHeader()
if header is None:
return
monitoring = (
index == 0
if index is not None
else self._dialog._table_tab_bar.currentIndex() == 0
)
tab_index = 0 if monitoring else 1
resized_columns = self._dialog._user_resized_columns_by_tab.get(tab_index, set())
header.setStretchLastSection(False)
self._dialog._applying_table_column_sizing = True
base_widths: dict[int, int] = {}
try:
if monitoring:
# Monitoring: all visible data columns are user-resizable.
header.setSectionResizeMode(self._col("name"), QtWidgets.QHeaderView.ResizeMode.Interactive)
header.setSectionResizeMode(self._col("state"), QtWidgets.QHeaderView.ResizeMode.Interactive)
header.setSectionResizeMode(
self._col("last_checked"), QtWidgets.QHeaderView.ResizeMode.Interactive
)
header.setSectionResizeMode(
self._col("last_updated"), QtWidgets.QHeaderView.ResizeMode.Interactive
)
if self._col("name") not in resized_columns:
self._dialog.table.setColumnWidth(self._col("name"), 260)
if self._col("state") not in resized_columns:
self._dialog.table.setColumnWidth(self._col("state"), 140)
if self._col("last_checked") not in resized_columns:
self._dialog.table.setColumnWidth(self._col("last_checked"), 260)
if self._col("last_updated") not in resized_columns:
self._dialog.table.setColumnWidth(self._col("last_updated"), 260)
for col in self._visible_columns():
base_widths[col] = self._dialog.table.columnWidth(col)
self._expand_visible_columns_to_viewport(base_widths)
return
# Config: keep URL flexible, reserve enough space for frequently edited fields.
header.setSectionResizeMode(self._col("enabled"), QtWidgets.QHeaderView.ResizeMode.Fixed)
header.setSectionResizeMode(self._col("name"), QtWidgets.QHeaderView.ResizeMode.Interactive)
header.setSectionResizeMode(self._col("url"), QtWidgets.QHeaderView.ResizeMode.Interactive)
header.setSectionResizeMode(self._col("filename"), QtWidgets.QHeaderView.ResizeMode.Interactive)
header.setSectionResizeMode(self._col("format"), QtWidgets.QHeaderView.ResizeMode.Interactive)
header.setSectionResizeMode(self._col("group"), QtWidgets.QHeaderView.ResizeMode.Interactive)
if self._col("name") not in resized_columns:
self._dialog.table.setColumnWidth(self._col("name"), 220)
if self._col("url") not in resized_columns:
self._dialog.table.setColumnWidth(self._col("url"), 380)
if self._col("filename") not in resized_columns:
self._dialog.table.setColumnWidth(self._col("filename"), 220)
if self._col("format") not in resized_columns:
self._dialog.table.setColumnWidth(self._col("format"), 120)
if self._col("group") not in resized_columns:
self._dialog.table.setColumnWidth(self._col("group"), 180)
for col in self._visible_columns():
base_widths[col] = self._dialog.table.columnWidth(col)
self._expand_visible_columns_to_viewport(base_widths)
finally:
self._dialog._applying_table_column_sizing = False
def on_table_section_resized(self, logical_index: int, _old_size: int, _new_size: int):
if self._dialog._applying_table_column_sizing:
return
if logical_index < 0 or logical_index >= self._dialog.table.columnCount():
return
if self._dialog.table.isColumnHidden(logical_index):
return
if not hasattr(self._dialog, "_table_tab_bar"):
return
tab_index = self._dialog._table_tab_bar.currentIndex()
self._dialog._user_resized_columns_by_tab.setdefault(tab_index, set()).add(logical_index)
def reset_table_column_widths_for_current_tab(self):
if not hasattr(self._dialog, "_table_tab_bar"):
return
tab_index = self._dialog._table_tab_bar.currentIndex()
self._dialog._user_resized_columns_by_tab.pop(tab_index, None)
self.apply_table_column_sizing(tab_index)
def reset_table_sort_for_current_tab(self):
if not hasattr(self._dialog, "_table_tab_bar"):
return
tab_index = self._dialog._table_tab_bar.currentIndex()
self.apply_table_view_mode(tab_index, set_sort=True)
def apply_table_view_mode(self, index: int | None = None, *, set_sort: bool = False):
if not hasattr(self._dialog, "_table_tab_bar"):
return
monitoring = (
index == 0
if index is not None
else self._dialog._table_tab_bar.currentIndex() == 0
)
for row in range(self._dialog.table.rowCount()):
enabled_item = self._dialog.table.item(row, self._col("enabled"))
enabled = enabled_item is not None and (
enabled_item.checkState() == QtCore.Qt.CheckState.Checked
)
self._dialog.table.setRowHidden(row, monitoring and not enabled)
if not set_sort:
return
header = self._dialog.table.horizontalHeader()
if header is None:
return
if monitoring:
sort_col = self._col("state")
sort_order = QtCore.Qt.SortOrder.AscendingOrder
else:
sort_col = self._col("enabled")
sort_order = QtCore.Qt.SortOrder.AscendingOrder
header.setSortIndicator(sort_col, sort_order)
if self._dialog.table.isSortingEnabled():
self._dialog.table.sortItems(sort_col, sort_order)
@@ -0,0 +1,274 @@
import os
from collections.abc import Callable
from typing import Any, TYPE_CHECKING, Final
from opensnitch.plugins.list_subscriptions.ui import (
QtCore,
QtWidgets,
QC,
load_ui_type,
)
from opensnitch.plugins.list_subscriptions._utils import RES_DIR
from opensnitch.plugins.list_subscriptions.ui.views.helpers import _configure_modal_dialog
from opensnitch.plugins.list_subscriptions.ui.widgets.table_widgets import (
SortableTableWidgetItem,
)
ATTACHED_RULES_DIALOG_UI_PATH: Final[str] = os.path.join(
RES_DIR, "attached_rules_dialog.ui"
)
AttachedRulesDialogUI: Final[Any] = load_ui_type(ATTACHED_RULES_DIALOG_UI_PATH)[0]
ATTACHED_RULE_ENTRY_ROLE = int(QtCore.Qt.ItemDataRole.UserRole) + 1
class AttachedRulesDialog(QtWidgets.QDialog, AttachedRulesDialogUI):
if TYPE_CHECKING:
rules_table: QtWidgets.QTableWidget
create_button: QtWidgets.QPushButton
edit_button: QtWidgets.QPushButton
toggle_button: QtWidgets.QPushButton
remove_button: QtWidgets.QPushButton
close_button: QtWidgets.QPushButton
def __init__(
self,
parent: QtWidgets.QWidget,
*,
get_attached_rules: Callable[[], list[dict[str, Any]]],
on_create_rule: Callable[[], None],
on_edit_rule: Callable[[dict[str, Any]], None],
on_toggle_rule: Callable[[dict[str, Any]], None],
on_remove_rule: Callable[[dict[str, Any]], None],
):
super().__init__(parent)
self._get_attached_rules = get_attached_rules
self._on_create_rule = on_create_rule
self._on_edit_rule = on_edit_rule
self._on_toggle_rule = on_toggle_rule
self._on_remove_rule = on_remove_rule
self.setupUi(self)
self._build_ui()
self._refresh_table()
def _configure_rules_table(self) -> None:
self.rules_table.setColumnCount(6)
self.rules_table.setHorizontalHeaderLabels(
[
QC.translate("stats", "Rule"),
QC.translate("stats", "Node"),
QC.translate("stats", "Status"),
QC.translate("stats", "Single sub"),
QC.translate("stats", "All"),
QC.translate("stats", "Groups"),
]
)
self.rules_table.setEditTriggers(
QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers
)
self.rules_table.setSelectionBehavior(
QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows
)
self.rules_table.setSelectionMode(
QtWidgets.QAbstractItemView.SelectionMode.SingleSelection
)
self.rules_table.setAlternatingRowColors(True)
self.rules_table.setSortingEnabled(True)
vertical_header = self.rules_table.verticalHeader()
if vertical_header is not None:
vertical_header.setVisible(False)
header = self.rules_table.horizontalHeader()
if header is not None:
header.setStretchLastSection(False)
header.setSortIndicatorShown(True)
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch)
for column in (1, 2, 3, 4):
header.setSectionResizeMode(
column,
QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
)
header.setSectionResizeMode(5, QtWidgets.QHeaderView.ResizeMode.Interactive)
header.setSortIndicator(0, QtCore.Qt.SortOrder.AscendingOrder)
self.rules_table.setColumnWidth(5, 180)
def _build_ui(self):
_configure_modal_dialog(
self,
title=QC.translate("stats", "Attached rules"),
size=(760, 420),
)
self._configure_rules_table()
self.rules_table.itemDoubleClicked.connect(lambda _item: self._edit_selected())
self.rules_table.itemSelectionChanged.connect(self._update_action_buttons)
self.create_button.setText(QC.translate("stats", "Create rule"))
self.edit_button.setText(QC.translate("stats", "Edit selected"))
self.toggle_button.setText(QC.translate("stats", "Disable"))
self.remove_button.setText(QC.translate("stats", "Remove"))
self.close_button.setText(QC.translate("stats", "Close"))
self.create_button.clicked.connect(self._create_rule)
self.edit_button.clicked.connect(self._edit_selected)
self.toggle_button.clicked.connect(self._toggle_selected)
self.remove_button.clicked.connect(self._remove_selected)
self.close_button.clicked.connect(self.accept)
def _create_rule(self):
self.accept()
self._on_create_rule()
def _selected_entry(self):
row = self.rules_table.currentRow()
if row < 0:
return None
item = self.rules_table.item(row, 0)
if item is None:
return None
entry = item.data(ATTACHED_RULE_ENTRY_ROLE)
if not isinstance(entry, dict):
return None
addr = str(entry.get("addr", "")).strip()
name = str(entry.get("name", "")).strip()
if addr == "" or name == "":
return None
return {
"addr": addr,
"name": name,
"enabled": bool(entry.get("enabled", True)),
}
def _populate_table(self, aggregated_rules: list[dict[str, Any]]):
header = self.rules_table.horizontalHeader()
sort_column = 0
sort_order = QtCore.Qt.SortOrder.AscendingOrder
if header is not None:
current_sort_column = header.sortIndicatorSection()
if 0 <= current_sort_column < self.rules_table.columnCount():
sort_column = current_sort_column
sort_order = header.sortIndicatorOrder()
selected_entry = self._selected_entry()
self.rules_table.setSortingEnabled(False)
self.rules_table.clearContents()
self.rules_table.setRowCount(len(aggregated_rules))
for row, entry in enumerate(aggregated_rules):
state_text = (
QC.translate("stats", "enabled")
if bool(entry.get("enabled", True))
else QC.translate("stats", "disabled")
)
groups_text = ", ".join(entry.get("groups", [])) or "-"
row_values = [
(
QtWidgets.QTableWidgetItem(str(entry.get("name", "")).strip()),
None,
),
(
SortableTableWidgetItem(str(entry.get("addr", "")).strip()),
str(entry.get("addr", "")).strip().lower(),
),
(
SortableTableWidgetItem(state_text),
0 if bool(entry.get("enabled", True)) else 1,
),
(
SortableTableWidgetItem(
QC.translate("stats", "yes") if bool(entry.get("single")) else "-"
),
0 if bool(entry.get("single")) else 1,
),
(
SortableTableWidgetItem(
QC.translate("stats", "yes") if bool(entry.get("all")) else "-"
),
0 if bool(entry.get("all")) else 1,
),
(
SortableTableWidgetItem(groups_text),
[group.lower() for group in entry.get("groups", [])],
),
]
for column, (item, sort_key) in enumerate(row_values):
if column == 0:
item.setData(ATTACHED_RULE_ENTRY_ROLE, entry)
if sort_key is not None:
item.setData(QtCore.Qt.ItemDataRole.UserRole, sort_key)
item.setFlags(item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable)
self.rules_table.setItem(row, column, item)
self.rules_table.setSortingEnabled(True)
self.rules_table.sortItems(sort_column, sort_order)
if self.rules_table.rowCount() <= 0:
self._update_action_buttons()
return
if selected_entry is not None:
selected_addr = str(selected_entry.get("addr", "")).strip()
selected_name = str(selected_entry.get("name", "")).strip()
for row in range(self.rules_table.rowCount()):
item = self.rules_table.item(row, 0)
if item is None:
continue
entry = item.data(ATTACHED_RULE_ENTRY_ROLE)
if not isinstance(entry, dict):
continue
row_addr = str(entry.get("addr", "")).strip()
row_name = str(entry.get("name", "")).strip()
if row_addr == selected_addr and row_name == selected_name:
self.rules_table.selectRow(row)
self._update_action_buttons()
return
self.rules_table.selectRow(0)
self._update_action_buttons()
def _refresh_table(self):
self._populate_table(self._get_attached_rules())
def _update_toggle_button(self):
entry = self._selected_entry()
if entry is None:
self.toggle_button.setEnabled(False)
self.toggle_button.setText(QC.translate("stats", "Disable"))
return
self.toggle_button.setEnabled(True)
if bool(entry.get("enabled", True)):
self.toggle_button.setText(QC.translate("stats", "Disable"))
else:
self.toggle_button.setText(QC.translate("stats", "Enable"))
def _update_action_buttons(self):
entry = self._selected_entry()
has_selection = entry is not None
self.edit_button.setEnabled(has_selection)
self.remove_button.setEnabled(has_selection)
self._update_toggle_button()
def _edit_selected(self):
entry = self._selected_entry()
if entry is None:
return
self._on_edit_rule(entry)
def _toggle_selected(self):
entry = self._selected_entry()
if entry is None:
return
self._on_toggle_rule(entry)
self._refresh_table()
def _remove_selected(self):
entry = self._selected_entry()
if entry is None:
return
self._on_remove_rule(entry)
self._refresh_table()
@@ -0,0 +1,361 @@
import logging
import os
from typing import Any, TYPE_CHECKING, Final
from opensnitch.plugins.list_subscriptions.ui import (
QtCore,
QtWidgets,
QC,
load_ui_type,
)
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
_apply_section_bar_style,
_apply_footer_separator_style,
)
from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import (
_configure_spin_and_units,
_set_optional_field_tooltips,
)
from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import (
ToggleSwitch,
)
from opensnitch.plugins.list_subscriptions.models.global_defaults import (
GlobalDefaults,
)
from opensnitch.plugins.list_subscriptions._utils import (
RES_DIR,
INTERVAL_UNITS,
TIMEOUT_UNITS,
SIZE_UNITS,
normalize_group,
normalize_groups,
)
BULK_EDIT_DIALOG_UI_PATH: Final[str] = os.path.join(RES_DIR, "bulk_edit_dialog.ui")
BulkEditDialogUI: Final[Any] = load_ui_type(BULK_EDIT_DIALOG_UI_PATH)[0]
logger: Final[logging.Logger] = logging.getLogger(__name__)
class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI):
if TYPE_CHECKING:
rootLayout: QtWidgets.QVBoxLayout
buttons_layout: QtWidgets.QHBoxLayout
changes_section_bar: QtWidgets.QFrame
changes_section_label: QtWidgets.QLabel
selection_hint_label: QtWidgets.QLabel
changes_tree: QtWidgets.QTreeWidget
enabled_value: QtWidgets.QCheckBox
group_value: QtWidgets.QComboBox
format_value: QtWidgets.QComboBox
interval_spin: QtWidgets.QSpinBox
interval_units: QtWidgets.QComboBox
timeout_spin: QtWidgets.QSpinBox
timeout_units: QtWidgets.QComboBox
max_size_spin: QtWidgets.QSpinBox
max_size_units: QtWidgets.QComboBox
error_label: QtWidgets.QLabel
footer_separator_line: QtWidgets.QFrame
cancel_button: QtWidgets.QPushButton
save_button: QtWidgets.QPushButton
_defaults: GlobalDefaults
_groups: list[str]
def __init__(
self,
parent: QtWidgets.QWidget | None,
defaults: GlobalDefaults,
groups: list[str] | None = None,
selected_count: int | None = None,
):
super().__init__(parent)
self.setWindowTitle(QC.translate("stats", "Edit selected subscriptions"))
self._defaults = defaults
self._groups = groups or []
self._selected_count = selected_count
self._field_items: dict[str, QtWidgets.QTreeWidgetItem] = {}
self._build_ui()
def _build_ui(self):
self.setupUi(self)
self.error_label.setStyleSheet("color: red;")
self.rootLayout.setContentsMargins(0, 0, 0, 0)
self.rootLayout.setSpacing(0)
self.selection_hint_label.setContentsMargins(12, 10, 12, 8)
self.changes_tree.setContentsMargins(0, 0, 0, 0)
self.buttons_layout.setContentsMargins(12, 10, 12, 12)
self.buttons_layout.setSpacing(8)
_apply_section_bar_style(
self,
self.changes_section_bar,
self.changes_section_label,
)
_apply_footer_separator_style(self, self.footer_separator_line)
self.error_label.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Preferred,
)
self.error_label.setAlignment(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
)
if self._selected_count is not None:
self.selection_hint_label.setText(
QC.translate(
"stats",
"Choose which changes to apply to {0} selected subscriptions.",
).format(self._selected_count)
)
self.changes_tree.setRootIsDecorated(False)
self.changes_tree.setUniformRowHeights(False)
self.changes_tree.setItemsExpandable(False)
self.changes_tree.setAllColumnsShowFocus(False)
self.changes_tree.setIndentation(0)
self.changes_tree.setSelectionMode(
QtWidgets.QAbstractItemView.SelectionMode.NoSelection
)
header = self.changes_tree.header()
if header is not None:
header.setStretchLastSection(True)
header.setSectionResizeMode(
0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents
)
header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch)
expanding = QtWidgets.QSizePolicy.Policy.Expanding
fixed = QtWidgets.QSizePolicy.Policy.Fixed
self.enabled_value = ToggleSwitch(QC.translate("stats", "Enabled"))
self.enabled_value.setSizePolicy(expanding, fixed)
self.group_value = QtWidgets.QComboBox()
self.group_value.setEditable(True)
self.group_value.setSizePolicy(expanding, fixed)
self.group_value.setEditable(True)
self.group_value.setToolTip(
QC.translate(
"stats",
"Optional explicit groups. Every subscription is always included in the global 'all' rules directory.",
)
)
self.format_value = QtWidgets.QComboBox()
self.format_value.setSizePolicy(expanding, fixed)
self.interval_spin = QtWidgets.QSpinBox()
self.interval_spin.setSizePolicy(expanding, fixed)
self.interval_units = QtWidgets.QComboBox()
self.interval_units.setSizePolicy(fixed, fixed)
self.timeout_spin = QtWidgets.QSpinBox()
self.timeout_spin.setSizePolicy(expanding, fixed)
self.timeout_units = QtWidgets.QComboBox()
self.timeout_units.setSizePolicy(fixed, fixed)
self.max_size_spin = QtWidgets.QSpinBox()
self.max_size_spin.setSizePolicy(expanding, fixed)
self.max_size_units = QtWidgets.QComboBox()
self.max_size_units.setSizePolicy(fixed, fixed)
unit_combo_width = 132
self.interval_units.setMinimumWidth(unit_combo_width)
self.timeout_units.setMinimumWidth(unit_combo_width)
self.max_size_units.setMinimumWidth(unit_combo_width)
self.cancel_button.clicked.connect(self.reject)
self.save_button.clicked.connect(self.validate_then_accept)
self.enabled_value.setChecked(True)
self.group_value.clear()
for g in self._groups:
ng = normalize_group(g)
if ng not in ("", "all"):
self.group_value.addItem(ng)
self.group_value.setCurrentText("")
self.format_value.clear()
self.format_value.addItems(("hosts",))
_configure_spin_and_units(
self.interval_spin,
self.interval_units,
value=0,
unit_value=self._defaults.interval_units,
allowed_units=INTERVAL_UNITS,
fallback_unit="hours",
special_value_text=QC.translate(
"stats", "Use global default ({0} {1})"
).format(
self._defaults.interval,
self._defaults.interval_units,
),
)
_configure_spin_and_units(
self.timeout_spin,
self.timeout_units,
value=0,
unit_value=self._defaults.timeout_units,
allowed_units=TIMEOUT_UNITS,
fallback_unit="seconds",
special_value_text=QC.translate(
"stats", "Use global default ({0} {1})"
).format(
self._defaults.timeout,
self._defaults.timeout_units,
),
)
_configure_spin_and_units(
self.max_size_spin,
self.max_size_units,
value=0,
unit_value=self._defaults.max_size_units,
allowed_units=SIZE_UNITS,
fallback_unit="MB",
special_value_text=QC.translate(
"stats", "Use global default ({0} {1})"
).format(
self._defaults.max_size,
self._defaults.max_size_units,
),
)
self._add_change_row(
"enabled", QC.translate("stats", "Enabled"), self.enabled_value
)
self._add_change_row(
"groups", QC.translate("stats", "Groups"), self.group_value
)
self._add_change_row(
"format", QC.translate("stats", "Format"), self.format_value
)
self._add_change_row(
"interval",
QC.translate("stats", "Interval"),
self._build_compound_editor(self.interval_spin, self.interval_units),
)
self._add_change_row(
"timeout",
QC.translate("stats", "Timeout"),
self._build_compound_editor(self.timeout_spin, self.timeout_units),
)
self._add_change_row(
"max_size",
QC.translate("stats", "Max size"),
self._build_compound_editor(self.max_size_spin, self.max_size_units),
)
self.changes_tree.itemChanged.connect(self.handle_item_changed)
self.interval_spin.valueChanged.connect(self.sync_optional_fields_state)
self.timeout_spin.valueChanged.connect(self.sync_optional_fields_state)
self.max_size_spin.valueChanged.connect(self.sync_optional_fields_state)
_set_optional_field_tooltips(
self.interval_spin,
self.interval_units,
self.timeout_spin,
self.timeout_units,
self.max_size_spin,
self.max_size_units,
inherit_wording=False,
)
self.sync_apply_fields_state()
self.sync_optional_fields_state()
self.resize(760, 420)
def _build_compound_editor(
self, primary: QtWidgets.QWidget, secondary: QtWidgets.QWidget
):
container = QtWidgets.QWidget(self.changes_tree)
layout = QtWidgets.QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
layout.addWidget(primary, 1)
layout.addWidget(secondary, 0)
return container
def _add_change_row(self, key: str, label: str, editor: QtWidgets.QWidget):
item = QtWidgets.QTreeWidgetItem(self.changes_tree)
item.setText(0, label)
item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(0, QtCore.Qt.CheckState.Unchecked)
self.changes_tree.setItemWidget(item, 1, editor)
self._field_items[key] = item
# -- Field apply state --------------------------------------------------
def is_field_applied(self, key: str) -> bool:
item = self._field_items.get(key)
if item is None:
return False
return item.checkState(0) == QtCore.Qt.CheckState.Checked
def handle_item_changed(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None:
if column != 0:
return
self.sync_apply_fields_state()
def sync_optional_fields_state(self) -> None:
self.interval_units.setEnabled(
self.is_field_applied("interval") and self.interval_spin.value() > 0
)
self.timeout_units.setEnabled(
self.is_field_applied("timeout") and self.timeout_spin.value() > 0
)
self.max_size_units.setEnabled(
self.is_field_applied("max_size") and self.max_size_spin.value() > 0
)
def sync_apply_fields_state(self) -> None:
self.enabled_value.setEnabled(self.is_field_applied("enabled"))
self.group_value.setEnabled(self.is_field_applied("groups"))
self.format_value.setEnabled(self.is_field_applied("format"))
self.interval_spin.setEnabled(self.is_field_applied("interval"))
self.timeout_spin.setEnabled(self.is_field_applied("timeout"))
self.max_size_spin.setEnabled(self.is_field_applied("max_size"))
self.sync_optional_fields_state()
# -- Validation ---------------------------------------------------------
def validate_then_accept(self) -> None:
if not any(self.is_field_applied(key) for key in self._field_items):
self.error_label.setText(
QC.translate("stats", "Select at least one field to apply.")
)
return
self.error_label.setText("")
self.accept()
# -- Result extraction --------------------------------------------------
def values(self) -> dict[str, Any]:
return {
"enabled": (
self.enabled_value.isChecked()
if self.is_field_applied("enabled")
else None
),
"groups": (
normalize_groups(self.group_value.currentText())
if self.is_field_applied("groups")
else None
),
"format": (
(self.format_value.currentText() or "hosts").strip().lower()
if self.is_field_applied("format")
else None
),
"apply_interval": self.is_field_applied("interval"),
"interval": int(self.interval_spin.value()) or None,
"interval_units": (
self.interval_units.currentText()
if self.interval_spin.value() > 0
else None
),
"apply_timeout": self.is_field_applied("timeout"),
"timeout": int(self.timeout_spin.value()) or None,
"timeout_units": (
self.timeout_units.currentText()
if self.timeout_spin.value() > 0
else None
),
"apply_max_size": self.is_field_applied("max_size"),
"max_size": int(self.max_size_spin.value()) or None,
"max_size_units": (
self.max_size_units.currentText()
if self.max_size_spin.value() > 0
else None
),
}
@@ -0,0 +1,116 @@
from typing import Any
from opensnitch.plugins.list_subscriptions.ui import (
QtCore,
QtGui,
QtWidgets,
QC,
)
def _section_border_color_name(widget: QtWidgets.QWidget):
dark_palette = (
widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
)
border_role = (
QtGui.QPalette.ColorRole.Midlight
if dark_palette
else QtGui.QPalette.ColorRole.Mid
)
return widget.palette().color(border_role).name()
def _apply_section_bar_style(
widget: QtWidgets.QWidget,
container: QtWidgets.QFrame,
label: QtWidgets.QLabel,
*,
right_border: bool = False,
expanding_label: bool = False,
):
dark_palette = (
widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
)
bg_role = (
QtGui.QPalette.ColorRole.AlternateBase
if dark_palette
else QtGui.QPalette.ColorRole.Button
)
bg = widget.palette().color(bg_role).name()
border = _section_border_color_name(widget)
text = widget.palette().color(QtGui.QPalette.ColorRole.WindowText).name()
font = label.font()
font.setPointSizeF(font.pointSizeF() + 1.0)
label.setFont(font)
container.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Fixed,
)
label.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding
if expanding_label
else QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Fixed,
)
border_right = f"border-right: 1px solid {border};" if right_border else ""
container.setStyleSheet(
"QFrame {"
f"background-color: {bg};"
f"border-top: 1px solid {border};"
f"border-bottom: 1px solid {border};"
f"{border_right}"
"}"
)
label.setStyleSheet(
"QLabel {"
f"color: {text};"
"background: transparent;"
"padding: 3px 10px;"
"border: 0;"
"}"
)
def _apply_footer_separator_style(widget: QtWidgets.QWidget, separator: QtWidgets.QFrame):
dark_palette = (
widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
)
footer_role = (
QtGui.QPalette.ColorRole.Midlight
if dark_palette
else QtGui.QPalette.ColorRole.Dark
)
footer_color = widget.palette().color(footer_role).name()
separator.setFixedHeight(1)
separator.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
separator.setStyleSheet(
f"QFrame {{ color: {footer_color}; background-color: {footer_color}; }}"
)
def _configure_modal_dialog(
dialog: QtWidgets.QDialog,
*,
title: str | None = None,
size: tuple[int, int] | None = None,
):
if title is not None:
dialog.setWindowTitle(title)
dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
if size is not None:
dialog.resize(size[0], size[1])
def _wire_copy_close_buttons(
dialog: QtWidgets.QDialog,
copy_button: QtWidgets.QPushButton,
close_button: QtWidgets.QPushButton,
text_view: Any,
):
copy_button.setText(QC.translate("stats", "Copy"))
close_button.setText(QC.translate("stats", "Close"))
copy_button.clicked.connect(lambda: text_view.selectAll())
copy_button.clicked.connect(lambda: text_view.copy())
close_button.clicked.connect(dialog.accept)
@@ -0,0 +1,247 @@
from typing import TYPE_CHECKING, cast
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets, QC
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
_apply_section_bar_style,
_section_border_color_name,
)
if TYPE_CHECKING:
from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import (
ListSubscriptionsDialog,
)
class InspectorPanel(QtWidgets.QFrame):
def __init__(self, *, dialog: "ListSubscriptionsDialog") -> None:
super().__init__(dialog)
self._dialog: "ListSubscriptionsDialog" = dialog
def build(self) -> None:
dialog: "ListSubscriptionsDialog" = self._dialog
dialog._inspect_panel = cast("InspectorPanel", self)
dialog._inspect_collapsed = False
dialog._inspect_default_width = 420
dialog._inspect_has_selection = False
dialog.tableContentLayout.removeWidget(dialog._table_tab_bar)
dialog.tableContentLayout.removeWidget(dialog.table)
dialog._table_inspect_splitter = QtWidgets.QSplitter(
QtCore.Qt.Orientation.Horizontal, dialog
)
dialog._table_inspect_splitter.setChildrenCollapsible(False)
left_container: QtWidgets.QWidget = QtWidgets.QWidget(
dialog._table_inspect_splitter
)
left_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(left_container)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(0)
dialog._table_tab_bar.setParent(left_container)
dialog.table.setParent(left_container)
left_layout.addWidget(dialog._table_tab_bar)
left_layout.addWidget(dialog.table, 1)
self.setParent(dialog._table_inspect_splitter)
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
inspect_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(self)
inspect_layout.setContentsMargins(0, 0, 0, 0)
inspect_layout.setSpacing(0)
dialog._inspect_header = QtWidgets.QFrame(self)
tab_row_height = max(28, dialog._table_tab_bar.sizeHint().height())
dialog._inspect_header.setMinimumHeight(tab_row_height)
dialog._inspect_header.setMaximumHeight(tab_row_height)
header: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout(dialog._inspect_header)
header.setContentsMargins(12, 0, 12, 0)
header.setSpacing(4)
dialog._inspect_title_label = QtWidgets.QLabel(QC.translate("stats", "Inspect"))
dialog._inspect_title_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
_apply_section_bar_style(
dialog,
dialog._inspect_header,
dialog._inspect_title_label,
expanding_label=True,
)
header.addWidget(dialog._inspect_title_label)
header.addStretch(1)
dialog._inspect_toggle_button = QtWidgets.QToolButton(self)
dialog._inspect_toggle_button.setAutoRaise(True)
dialog._inspect_toggle_button.clicked.connect(
dialog._inspector_controller.toggle_inspector_collapsed
)
header.setAlignment(dialog._inspect_toggle_button, QtCore.Qt.AlignmentFlag.AlignVCenter)
header.addWidget(dialog._inspect_toggle_button)
inspect_layout.addWidget(dialog._inspect_header)
dialog._inspect_header_separator = QtWidgets.QFrame(self)
dialog._inspect_header_separator.setFrameShape(QtWidgets.QFrame.Shape.HLine)
dialog._inspect_header_separator.setFixedHeight(1)
dialog._inspect_header_separator.setStyleSheet(
f"background-color: {_section_border_color_name(dialog)}; border: 0;"
)
inspect_layout.addWidget(dialog._inspect_header_separator)
dialog._inspect_scroll = QtWidgets.QScrollArea(self)
dialog._inspect_scroll.setWidgetResizable(True)
dialog._inspect_scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
dialog._inspect_scroll.setHorizontalScrollBarPolicy(
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
dialog._inspect_scroll.setVerticalScrollBarPolicy(
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
dialog._inspect_body = QtWidgets.QWidget(dialog._inspect_scroll)
body_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(
dialog._inspect_body
)
body_layout.setContentsMargins(8, 6, 8, 8)
body_layout.setSpacing(0)
body_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
dialog._inspect_details_widget = QtWidgets.QWidget(dialog._inspect_body)
details_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(
dialog._inspect_details_widget
)
details_layout.setContentsMargins(0, 0, 0, 0)
details_layout.setSpacing(0)
form: QtWidgets.QFormLayout = QtWidgets.QFormLayout()
form.setLabelAlignment(
QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop
)
form.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
form.setHorizontalSpacing(10)
form.setVerticalSpacing(4)
form.setFieldGrowthPolicy(
QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow
)
dialog._inspect_value_labels = {}
dialog._inspect_error_button = None
dialog._inspect_error_full_text = ""
for key, label in (
("name", QC.translate("stats", "Name")),
("enabled", QC.translate("stats", "Enabled")),
("state", QC.translate("stats", "State")),
("last_checked", QC.translate("stats", "Last checked")),
("last_updated", QC.translate("stats", "Last updated")),
("failures", QC.translate("stats", "Failures")),
("error", QC.translate("stats", "Error")),
("url", QC.translate("stats", "URL")),
("filename", QC.translate("stats", "Filename")),
("format", QC.translate("stats", "Format")),
("groups", QC.translate("stats", "Groups")),
("interval", QC.translate("stats", "Interval")),
("timeout", QC.translate("stats", "Timeout")),
("max_size", QC.translate("stats", "Max size")),
("list_path", QC.translate("stats", "List path")),
("meta_path", QC.translate("stats", "Meta path")),
):
key_label: QtWidgets.QLabel = QtWidgets.QLabel(label + ":")
key_label.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop
)
key_label.setMinimumWidth(112)
value_label: QtWidgets.QLabel = QtWidgets.QLabel("-")
value_label.setWordWrap(True)
value_label.setAlignment(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop
)
value_label.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Preferred,
)
value_label.setTextInteractionFlags(
QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
)
dialog._inspect_value_labels[key] = value_label
if key == "error":
field_widget: QtWidgets.QWidget = QtWidgets.QWidget(
dialog._inspect_details_widget
)
field_layout: QtWidgets.QHBoxLayout = QtWidgets.QHBoxLayout(
field_widget
)
field_layout.setContentsMargins(0, 0, 0, 0)
field_layout.setSpacing(6)
field_layout.addWidget(value_label, 1)
if key == "error":
inspect_button: QtWidgets.QPushButton = QtWidgets.QPushButton(
QC.translate("stats", "Inspect"), field_widget
)
inspect_button.setVisible(False)
inspect_button.clicked.connect(
dialog._inspector_controller.show_error_inspect_dialog
)
dialog._inspect_error_button = inspect_button
field_layout.addWidget(inspect_button, 0)
form.addRow(key_label, field_widget)
else:
form.addRow(key_label, value_label)
details_layout.addLayout(form)
dialog._inspect_summary_widget = QtWidgets.QWidget(dialog._inspect_body)
summary_layout: QtWidgets.QVBoxLayout = QtWidgets.QVBoxLayout(
dialog._inspect_summary_widget
)
summary_layout.setContentsMargins(0, 0, 0, 0)
summary_layout.setSpacing(6)
summary_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
summary_form: QtWidgets.QFormLayout = QtWidgets.QFormLayout()
summary_form.setLabelAlignment(
QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop
)
summary_form.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
summary_form.setHorizontalSpacing(10)
summary_form.setVerticalSpacing(4)
summary_form.setFieldGrowthPolicy(
QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow
)
dialog._inspect_summary_labels = {}
for key, label in (
("selected", QC.translate("stats", "Selected")),
("enabled", QC.translate("stats", "Enabled")),
("healthy", QC.translate("stats", "Healthy")),
("pending", QC.translate("stats", "Pending")),
("problematic", QC.translate("stats", "Problematic")),
("failures", QC.translate("stats", "Total failures")),
("with_errors", QC.translate("stats", "With errors")),
("newest_checked", QC.translate("stats", "Newest checked")),
("oldest_checked", QC.translate("stats", "Oldest checked")),
):
key_label: QtWidgets.QLabel = QtWidgets.QLabel(label + ":")
key_label.setAlignment(
QtCore.Qt.AlignmentFlag.AlignRight | QtCore.Qt.AlignmentFlag.AlignTop
)
key_label.setMinimumWidth(112)
value_label: QtWidgets.QLabel = QtWidgets.QLabel("-")
value_label.setWordWrap(True)
value_label.setAlignment(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop
)
value_label.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Preferred,
)
value_label.setTextInteractionFlags(
QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
)
dialog._inspect_summary_labels[key] = value_label
summary_form.addRow(key_label, value_label)
summary_layout.addLayout(summary_form)
body_layout.addWidget(dialog._inspect_details_widget)
body_layout.addWidget(dialog._inspect_summary_widget)
dialog._inspector_controller.set_inspector_multi_selection_mode(False)
dialog._inspect_scroll.setWidget(dialog._inspect_body)
inspect_layout.addWidget(dialog._inspect_scroll, 1)
dialog.tableContentLayout.addWidget(dialog._table_inspect_splitter)
dialog._table_inspect_splitter.setStretchFactor(0, 1)
dialog._table_inspect_splitter.setStretchFactor(1, 0)
dialog._inspector_controller.set_inspector_visible(False)
@@ -0,0 +1,850 @@
import logging
import os
from typing import Any, TYPE_CHECKING, Final
from opensnitch.plugins.list_subscriptions.ui import (
QtCore,
QtGui,
QtWidgets,
QC,
load_ui_type,
)
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
from opensnitch.actions import Actions
from opensnitch.nodes import Nodes
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
_section_border_color_name,
_apply_section_bar_style,
)
from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import (
_configure_spin_and_units,
)
from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import (
_replace_checkbox_with_toggle,
)
from opensnitch.plugins.list_subscriptions.ui.widgets.table_widgets import (
CenteredCheckDelegate,
KeepForegroundOnSelectionDelegate,
)
from opensnitch.plugins.list_subscriptions.ui.views.inspector_panel import (
InspectorPanel,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.status_controller import (
DialogStatusController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.runtime_controller import (
RuntimeController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.inspector_controller import (
InspectorController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.defaults_ui_controller import (
DefaultsUiController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.selection_controller import (
SelectionController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.context_menu_controller import (
ContextMenuController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.bulk_edit_controller import (
BulkEditController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.subscription_status_controller import (
SubscriptionStatusController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.action_file_controller import (
ActionFileController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.table_data_controller import (
TableDataController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.table_view_controller import (
TableViewController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.rules_attachment_controller import (
RulesAttachmentController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.rules_editor_controller import (
RulesEditorController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.subscription_edit_controller import (
SubscriptionEditController,
)
from opensnitch.plugins.list_subscriptions._utils import (
ACTION_FILE,
DEFAULT_LISTS_DIR,
RES_DIR,
INTERVAL_UNITS,
TIMEOUT_UNITS,
SIZE_UNITS,
)
from opensnitch.plugins.list_subscriptions._annotations import RulesEditorDialogProto
from opensnitch.plugins.list_subscriptions.list_subscriptions import ListSubscriptions
LIST_SUBSCRIPTIONS_DIALOG_UI_PATH: Final[str] = os.path.join(
RES_DIR, "list_subscriptions_dialog.ui"
)
ListSubscriptionsDialogUI: Final[Any] = load_ui_type(LIST_SUBSCRIPTIONS_DIALOG_UI_PATH)[
0
]
COL_ENABLED: Final[int] = 0
COL_NAME: Final[int] = 1
COL_URL: Final[int] = 2
COL_FILENAME: Final[int] = 3
COL_FORMAT: Final[int] = 4
COL_GROUP: Final[int] = 5
COL_INTERVAL: Final[int] = 6
COL_INTERVAL_UNITS: Final[int] = 7
COL_TIMEOUT: Final[int] = 8
COL_TIMEOUT_UNITS: Final[int] = 9
COL_MAX_SIZE: Final[int] = 10
COL_MAX_SIZE_UNITS: Final[int] = 11
COL_FILE: Final[int] = 12
COL_META: Final[int] = 13
COL_STATE: Final[int] = 14
COL_RULE_ATTACHED: Final[int] = 15
COL_LAST_CHECKED: Final[int] = 16
COL_LAST_UPDATED: Final[int] = 17
INSPECT_ERROR_PREVIEW_LIMIT: Final[int] = 48
STATUS_MESSAGE_PREVIEW_LIMIT: Final[int] = 48
STATUS_LOG_LIMIT: Final[int] = 200
logger: Final[logging.Logger] = logging.getLogger(__name__)
class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI):
subscription_state_refreshed = QtCore.pyqtSignal(str, str, dict)
if TYPE_CHECKING:
rootLayout: QtWidgets.QVBoxLayout
topRowLayout: QtWidgets.QHBoxLayout
defaultsSectionLayout: QtWidgets.QVBoxLayout
defaultsGridLayout: QtWidgets.QGridLayout
tableSectionLayout: QtWidgets.QVBoxLayout
table_section_bar: QtWidgets.QFrame
table_section_label: QtWidgets.QLabel
tableContentLayout: QtWidgets.QVBoxLayout
actionsRowLayout: QtWidgets.QHBoxLayout
actionsSeparatorLayout: QtWidgets.QVBoxLayout
globalActionsLayout: QtWidgets.QHBoxLayout
ruleActionsLayout: QtWidgets.QHBoxLayout
enable_plugin_check: QtWidgets.QCheckBox
create_file_button: QtWidgets.QPushButton
save_button: QtWidgets.QPushButton
reload_button: QtWidgets.QPushButton
start_runtime_button: QtWidgets.QPushButton
stop_runtime_button: QtWidgets.QPushButton
runtime_status_title_label: QtWidgets.QLabel
runtime_status_label: QtWidgets.QLabel
defaults_section_bar: QtWidgets.QFrame
defaults_section_label: QtWidgets.QLabel
lists_dir_label: QtWidgets.QLabel
lists_dir_edit: QtWidgets.QLineEdit
default_interval_label: QtWidgets.QLabel
default_interval_spin: QtWidgets.QSpinBox
default_interval_units: QtWidgets.QComboBox
default_timeout_label: QtWidgets.QLabel
default_timeout_spin: QtWidgets.QSpinBox
default_timeout_units: QtWidgets.QComboBox
default_max_size_label: QtWidgets.QLabel
default_max_size_spin: QtWidgets.QSpinBox
default_max_size_units: QtWidgets.QComboBox
default_user_agent_label: QtWidgets.QLabel
default_user_agent: QtWidgets.QLineEdit
node_label: QtWidgets.QLabel
nodes_combo: QtWidgets.QComboBox
table: QtWidgets.QTableWidget
global_actions_bar: QtWidgets.QFrame
global_actions_label: QtWidgets.QLabel
actions_vertical_separator: QtWidgets.QFrame
add_sub_button: QtWidgets.QPushButton
refresh_state_button: QtWidgets.QPushButton
create_global_rule_button: QtWidgets.QPushButton
selected_actions_bar: QtWidgets.QFrame
selected_actions_label: QtWidgets.QLabel
edit_sub_button: QtWidgets.QPushButton
remove_sub_button: QtWidgets.QPushButton
refresh_now_button: QtWidgets.QPushButton
create_rule_button: QtWidgets.QPushButton
status_separator_line: QtWidgets.QFrame
status_label: QtWidgets.QLabel
_status_inspect_button: QtWidgets.QPushButton
_nodes: Nodes
_actions: Actions
_action_path: str
_loading: bool
_global_defaults: GlobalDefaults
_rules_dialog: RulesEditorDialogProto | None
_runtime_plugin: ListSubscriptions | None
_pending_runtime_reload: str | None
_pending_refresh_keys: set[str]
_active_refresh_keys: set[str]
_status_controller: DialogStatusController
_runtime_controller: RuntimeController
_defaults_ui_controller: DefaultsUiController
_selection_controller: SelectionController
_context_menu_controller: ContextMenuController
_bulk_edit_controller: BulkEditController
_subscription_status_controller: SubscriptionStatusController
_action_file_controller: ActionFileController
_inspector_controller: InspectorController
_table_data_controller: TableDataController
_rules_attachment_controller: RulesAttachmentController
_rules_editor_controller: RulesEditorController
_table_tab_bar: QtWidgets.QTabBar
_table_inspect_splitter: QtWidgets.QSplitter
_inspect_panel: QtWidgets.QFrame
_inspect_header: QtWidgets.QFrame
_inspect_title_label: QtWidgets.QLabel
_inspect_toggle_button: QtWidgets.QToolButton
_inspect_header_separator: QtWidgets.QFrame
_inspect_scroll: QtWidgets.QScrollArea
_inspect_body: QtWidgets.QWidget
_inspect_details_widget: QtWidgets.QWidget
_inspect_summary_widget: QtWidgets.QWidget
_inspect_value_labels: dict[str, QtWidgets.QLabel]
_inspect_summary_labels: dict[str, QtWidgets.QLabel]
_inspect_error_button: QtWidgets.QPushButton | None
_inspect_error_full_text: str
_inspect_collapsed: bool
_inspect_default_width: int
_inspect_has_selection: bool
_user_resized_columns_by_tab: dict[int, set[int]]
_applying_table_column_sizing: bool
def __init__(
self,
parent: QtWidgets.QWidget | None = None,
appicon: QtGui.QIcon | None = None,
):
dlg_parent = parent if isinstance(parent, QtWidgets.QWidget) else None
super().__init__(dlg_parent)
self.setWindowTitle(QC.translate("stats", "List subscriptions"))
if appicon is not None:
self.setWindowIcon(appicon)
self._nodes = Nodes.instance()
self._actions = Actions.instance()
self._action_path = ACTION_FILE
self._loading = False
self._global_defaults: GlobalDefaults = GlobalDefaults.from_dict(
{}, lists_dir=DEFAULT_LISTS_DIR
)
self._rules_dialog: RulesEditorDialogProto | None = None
self._runtime_plugin: ListSubscriptions | None = None
self._pending_runtime_reload: str | None = None
self._pending_refresh_keys: set[str] = set()
self._active_refresh_keys: set[str] = set()
self._deferred_close_pending = False
self._user_resized_columns_by_tab: dict[int, set[int]] = {}
self._applying_table_column_sizing = False
self._resize_fill_timer = QtCore.QTimer(self)
self._resize_fill_timer.setSingleShot(True)
self._resize_fill_timer.setInterval(140)
self._resize_fill_timer.timeout.connect(self._apply_table_fill_after_resize)
self._build_ui()
def showEvent(self, event: QtGui.QShowEvent | None): # type: ignore[override]
super().showEvent(event)
self._action_file_controller.load_action_file()
self._table_data_controller.start_poll()
# Ensure equal-fill sizing runs after the first layout pass when viewport width is valid.
QtCore.QTimer.singleShot(
0,
lambda: self._table_view_controller.apply_table_column_sizing(
self._table_tab_bar.currentIndex()
),
)
def _pause_background_workers_for_focus_loss(self) -> None:
pause_poll = getattr(self._table_data_controller, "pause_for_focus_loss", None)
if callable(pause_poll):
pause_poll()
else:
self._table_data_controller.stop_poll()
self._table_data_controller.cancel_active_refresh()
cancel_snapshot = getattr(
self._rules_attachment_controller,
"cancel_active_snapshot",
None,
)
if callable(cancel_snapshot):
cancel_snapshot()
def _resume_background_workers_for_focus_gain(self) -> None:
resume_poll = getattr(self._table_data_controller, "resume_for_focus_gain", None)
if callable(resume_poll):
resume_poll()
elif self.isVisible() and not self._loading:
self._table_data_controller.start_poll()
def changeEvent(self, event: QtCore.QEvent | None): # type: ignore[override]
if (
event is not None
and event.type() == QtCore.QEvent.Type.ActivationChange
):
if self.isVisible() and self.isActiveWindow():
self._resume_background_workers_for_focus_gain()
else:
self._pause_background_workers_for_focus_loss()
super().changeEvent(event)
def hideEvent(self, event: QtGui.QHideEvent | None): # type: ignore[override]
self._pause_background_workers_for_focus_loss()
super().hideEvent(event)
def resizeEvent(self, event: QtGui.QResizeEvent | None): # type: ignore[override]
super().resizeEvent(event)
if hasattr(self, "_resize_fill_timer"):
self._resize_fill_timer.start()
def _apply_table_fill_after_resize(self) -> None:
if not self.isVisible():
return
if hasattr(self, "_table_view_controller") and hasattr(self, "_table_tab_bar"):
self._table_view_controller.apply_table_column_sizing(
self._table_tab_bar.currentIndex()
)
def _complete_deferred_close(self) -> None:
self._deferred_close_pending = False
self.setEnabled(True)
self._status_controller.set_status("", error=False, log=False)
self.close()
def closeEvent(self, event: QtGui.QCloseEvent | None): # type: ignore[override]
self._table_data_controller.stop_poll()
self._pause_background_workers_for_focus_loss()
if self._table_data_controller.has_active_refresh():
if not self._deferred_close_pending:
self._deferred_close_pending = True
self.setEnabled(False)
self._status_controller.set_status(
QC.translate("stats", "Stopping background tasks..."),
error=False,
log=False,
)
self._table_data_controller.cancel_active_refresh()
self._table_data_controller.on_refresh_stopped(
self._complete_deferred_close
)
if event is not None:
event.ignore()
return
self._status_controller.set_status("", error=False, log=False)
super().closeEvent(event)
def _show_log_dialog(self) -> None:
color = self.palette().color(QtGui.QPalette.ColorRole.WindowText)
self._status_controller.show_log_dialog(
self,
title=QC.translate("stats", "Status log"),
level_color=self._table_data_controller.status_log_level_color,
timestamp_color=f"rgba({color.red()}, {color.green()}, {color.blue()}, 0.55)",
)
def _build_ui(self):
self.setupUi(self)
self.enable_plugin_check = _replace_checkbox_with_toggle(
self.enable_plugin_check
)
self.enable_plugin_check.setChecked(True)
self.enable_plugin_check.setEnabled(False)
self.enable_plugin_check.setToolTip(
QC.translate(
"stats",
"The plugin action must remain enabled. OpenSnitch only loads enabled action files on startup.",
)
)
self.setWindowTitle(QC.translate("stats", "List subscriptions"))
self.resize(1180, 680)
self.rootLayout.setContentsMargins(0, 0, 0, 0)
self.rootLayout.setSpacing(0)
self.rootLayout.setStretch(0, 0)
self.rootLayout.setStretch(1, 0)
self.rootLayout.setStretch(2, 1)
self.rootLayout.setStretch(3, 0)
self.rootLayout.setStretch(4, 0)
self.topRowLayout.setContentsMargins(12, 10, 12, 4)
self.topRowLayout.setSpacing(8)
self.topRowLayout.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
self.defaultsSectionLayout.setContentsMargins(0, 8, 0, 0)
self.defaultsGridLayout.setContentsMargins(12, 10, 12, 10)
self.tableSectionLayout.setContentsMargins(0, 8, 0, 0)
self.tableContentLayout.setContentsMargins(0, 0, 0, 0)
self.actionsRowLayout.setContentsMargins(0, 0, 0, 0)
self.actionsRowLayout.setSpacing(0)
self.globalActionsLayout.setContentsMargins(12, 10, 12, 10)
self.ruleActionsLayout.setContentsMargins(12, 10, 12, 10)
self.status_label.setContentsMargins(12, 8, 12, 8)
self.status_label.setAlignment(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
)
_apply_section_bar_style(
self,
self.defaults_section_bar,
self.defaults_section_label,
expanding_label=True,
)
_apply_section_bar_style(
self,
self.table_section_bar,
self.table_section_label,
expanding_label=True,
)
_apply_section_bar_style(
self,
self.global_actions_bar,
self.global_actions_label,
expanding_label=True,
)
_apply_section_bar_style(
self,
self.selected_actions_bar,
self.selected_actions_label,
expanding_label=True,
)
self.actionsRowLayout.setStretch(0, 1)
self.actionsRowLayout.setStretch(2, 1)
self.actions_vertical_separator.hide()
background_role = (
QtGui.QPalette.ColorRole.AlternateBase
if self.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
else QtGui.QPalette.ColorRole.Button
)
section_border_color = _section_border_color_name(self)
self.global_actions_bar.setStyleSheet(
"QFrame {"
f"background-color: {self.palette().color(background_role).name()};"
f"border-top: 1px solid {section_border_color};"
f"border-bottom: 1px solid {section_border_color};"
f"border-right: 1px solid {section_border_color};"
"}"
)
self.runtime_status_label.setContentsMargins(12, 0, 0, 0)
self.runtime_status_label.setAlignment(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.runtime_status_title_label.setContentsMargins(12, 0, 0, 0)
self.runtime_status_title_label.setAlignment(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
)
runtime_status_title_font = self.runtime_status_title_label.font()
runtime_status_title_font.setBold(True)
self.runtime_status_title_label.setFont(runtime_status_title_font)
footer_role = (
QtGui.QPalette.ColorRole.Midlight
if self.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
else QtGui.QPalette.ColorRole.Dark
)
footer_border = self.palette().color(footer_role).name()
self.status_separator_line.setStyleSheet(f"color: {footer_border};")
self.status_label.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Fixed,
)
self.status_label.setStyleSheet(
f"QLabel {{ background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()}; padding: 8px 12px 8px 12px; }}"
)
self._status_inspect_button = QtWidgets.QPushButton(
QC.translate("stats", "Log"),
self,
)
self._status_inspect_button.setVisible(False)
self._status_controller = DialogStatusController(
label=self.status_label,
inspect_button=self._status_inspect_button,
preview_limit=STATUS_MESSAGE_PREVIEW_LIMIT,
log_limit=STATUS_LOG_LIMIT,
timestamp_format="HH:mm:ss",
ok_color="green",
error_color="red",
empty_button_behavior="show-if-logs",
)
self._status_inspect_button.clicked.connect(self._show_log_dialog)
self._runtime_controller = RuntimeController(
dialog=self,
status_label=self.runtime_status_label,
start_button=self.start_runtime_button,
stop_button=self.stop_runtime_button,
)
self._defaults_ui_controller = DefaultsUiController(dialog=self)
self._selection_controller = SelectionController(
dialog=self,
columns={
"group": COL_GROUP,
},
)
self._context_menu_controller = ContextMenuController(dialog=self)
self._bulk_edit_controller = BulkEditController(
dialog=self,
columns={
"enabled": COL_ENABLED,
"group": COL_GROUP,
"format": COL_FORMAT,
"interval": COL_INTERVAL,
"interval_units": COL_INTERVAL_UNITS,
"timeout": COL_TIMEOUT,
"timeout_units": COL_TIMEOUT_UNITS,
"max_size": COL_MAX_SIZE,
"max_size_units": COL_MAX_SIZE_UNITS,
},
)
self._subscription_status_controller = SubscriptionStatusController(
dialog=self,
columns={
"name": COL_NAME,
"url": COL_URL,
"filename": COL_FILENAME,
},
)
self._action_file_controller = ActionFileController(
dialog=self,
columns={
"name": COL_NAME,
"filename": COL_FILENAME,
},
)
self._rules_attachment_controller = RulesAttachmentController(dialog=self)
self._rules_editor_controller = RulesEditorController(
dialog=self,
columns={
"url": COL_URL,
"format": COL_FORMAT,
"group": COL_GROUP,
},
)
self._table_data_controller = TableDataController(
dialog=self,
columns={
"enabled": COL_ENABLED,
"name": COL_NAME,
"url": COL_URL,
"filename": COL_FILENAME,
"format": COL_FORMAT,
"group": COL_GROUP,
"interval": COL_INTERVAL,
"interval_units": COL_INTERVAL_UNITS,
"timeout": COL_TIMEOUT,
"timeout_units": COL_TIMEOUT_UNITS,
"max_size": COL_MAX_SIZE,
"max_size_units": COL_MAX_SIZE_UNITS,
"file": COL_FILE,
"meta": COL_META,
"state": COL_STATE,
"rule_attached": COL_RULE_ATTACHED,
"last_checked": COL_LAST_CHECKED,
"last_updated": COL_LAST_UPDATED,
},
)
status_row = QtWidgets.QWidget(self)
status_row.setStyleSheet(
"QWidget {"
f"border-top: 1px solid {footer_border};"
f"background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()};"
"}"
)
status_row_layout = QtWidgets.QHBoxLayout(status_row)
status_row_layout.setContentsMargins(12, 0, 12, 0)
status_row_layout.setSpacing(8)
self.rootLayout.removeWidget(self.status_label)
self.status_label.setParent(status_row)
status_row_layout.addWidget(
self._status_inspect_button,
0,
QtCore.Qt.AlignmentFlag.AlignVCenter,
)
status_row_layout.addWidget(self.status_label, 1)
self.rootLayout.insertWidget(4, status_row)
for widget in (
self.enable_plugin_check,
self.create_file_button,
self.save_button,
self.reload_button,
self.start_runtime_button,
self.stop_runtime_button,
self.runtime_status_title_label,
self.runtime_status_label,
):
self.topRowLayout.setAlignment(widget, QtCore.Qt.AlignmentFlag.AlignVCenter)
self.table.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
self.table.setLineWidth(0)
self.table.setContentsMargins(0, 0, 0, 0)
self.table.setMinimumHeight(0)
self.table.setMaximumHeight(16777215)
defaults_label_width = 120
for label in (
self.lists_dir_label,
self.default_interval_label,
self.default_timeout_label,
self.default_max_size_label,
self.default_user_agent_label,
self.node_label,
):
label.setMinimumWidth(defaults_label_width)
self.node_label.hide()
self.nodes_combo.hide()
_configure_spin_and_units(
self.default_interval_spin,
self.default_interval_units,
value=1,
unit_value="hours",
allowed_units=INTERVAL_UNITS,
fallback_unit="hours",
min_value=1,
)
_configure_spin_and_units(
self.default_timeout_spin,
self.default_timeout_units,
value=1,
unit_value="seconds",
allowed_units=TIMEOUT_UNITS,
fallback_unit="seconds",
min_value=1,
)
_configure_spin_and_units(
self.default_max_size_spin,
self.default_max_size_units,
value=1,
unit_value="MB",
allowed_units=SIZE_UNITS,
fallback_unit="MB",
min_value=1,
)
self.table.setColumnCount(18)
self.table.setHorizontalHeaderLabels(
[
"",
QC.translate("stats", "Name"),
QC.translate("stats", "URL"),
QC.translate("stats", "Filename"),
QC.translate("stats", "Format"),
QC.translate("stats", "Groups"),
QC.translate("stats", "Interval"),
QC.translate("stats", "Interval units"),
QC.translate("stats", "Timeout"),
QC.translate("stats", "Timeout units"),
QC.translate("stats", "Max size"),
QC.translate("stats", "Max size units"),
QC.translate("stats", "List file present"),
QC.translate("stats", "List meta present"),
QC.translate("stats", "State"),
QC.translate("stats", "Rule attached"),
QC.translate("stats", "Last checked"),
QC.translate("stats", "Last updated"),
]
)
for col in (
COL_INTERVAL,
COL_INTERVAL_UNITS,
COL_TIMEOUT,
COL_TIMEOUT_UNITS,
COL_MAX_SIZE,
COL_MAX_SIZE_UNITS,
):
header_item = self.table.horizontalHeaderItem(col)
if header_item is not None:
header_item.setToolTip(
QC.translate(
"stats",
"Leave blank to inherit the global default for this subscription.",
)
)
self.table.setEditTriggers(
QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers
)
self.table.setSelectionBehavior(
QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows
)
self.table.setSelectionMode(
QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection
)
self.table.setItemDelegateForColumn(
COL_ENABLED, CenteredCheckDelegate(self.table)
)
state_delegate = KeepForegroundOnSelectionDelegate(self.table)
for col in (
COL_STATE,
COL_RULE_ATTACHED,
COL_LAST_CHECKED,
COL_LAST_UPDATED,
):
self.table.setItemDelegateForColumn(col, state_delegate)
self._table_view_controller = TableViewController(
dialog=self,
columns={
"enabled": COL_ENABLED,
"name": COL_NAME,
"url": COL_URL,
"filename": COL_FILENAME,
"format": COL_FORMAT,
"group": COL_GROUP,
"interval": COL_INTERVAL,
"interval_units": COL_INTERVAL_UNITS,
"timeout": COL_TIMEOUT,
"timeout_units": COL_TIMEOUT_UNITS,
"max_size": COL_MAX_SIZE,
"max_size_units": COL_MAX_SIZE_UNITS,
"file": COL_FILE,
"meta": COL_META,
"state": COL_STATE,
"rule_attached": COL_RULE_ATTACHED,
"last_checked": COL_LAST_CHECKED,
"last_updated": COL_LAST_UPDATED,
},
)
header = self.table.horizontalHeader()
if header is not None:
header.setStretchLastSection(False)
header.setSortIndicatorShown(True)
header.setSortIndicator(COL_ENABLED, QtCore.Qt.SortOrder.AscendingOrder)
header.setSectionResizeMode(
COL_ENABLED, QtWidgets.QHeaderView.ResizeMode.Fixed
)
style = self.table.style()
if style is not None:
indicator_w = style.pixelMetric(
QtWidgets.QStyle.PixelMetric.PM_IndicatorWidth,
None,
self.table,
)
indicator_h = style.pixelMetric(
QtWidgets.QStyle.PixelMetric.PM_IndicatorHeight,
None,
self.table,
)
self.table.setColumnWidth(
COL_ENABLED, max(indicator_w, indicator_h) + 18
)
header.setSectionResizeMode(COL_URL, QtWidgets.QHeaderView.ResizeMode.Interactive)
header.sectionResized.connect(
self._table_view_controller.on_table_section_resized
)
self.table.setSortingEnabled(True)
self.table.sortItems(COL_ENABLED, QtCore.Qt.SortOrder.AscendingOrder)
# Keep advanced tuning + verbose metadata available internally but
# reduce visible table complexity; edit dialog exposes full details.
# Initial column visibility is controlled by TableViewController.
for col in (
COL_INTERVAL,
COL_INTERVAL_UNITS,
COL_TIMEOUT,
COL_TIMEOUT_UNITS,
COL_MAX_SIZE,
COL_MAX_SIZE_UNITS,
COL_FILE,
COL_META,
):
self.table.setColumnHidden(col, True)
# Switch between Config and Monitoring table views.
self._table_tab_bar = QtWidgets.QTabBar(self)
self._table_tab_bar.addTab(QC.translate("stats", "Monitoring"))
self._table_tab_bar.addTab(QC.translate("stats", "Config"))
self._table_tab_bar.setContentsMargins(12, 4, 12, 0)
self._table_tab_bar.currentChanged.connect(
self._table_view_controller.on_table_view_tab_changed
)
self.tableContentLayout.insertWidget(0, self._table_tab_bar)
self._inspector_controller = InspectorController(
dialog=self,
columns={
"enabled": COL_ENABLED,
"name": COL_NAME,
"url": COL_URL,
"filename": COL_FILENAME,
"format": COL_FORMAT,
"group": COL_GROUP,
"interval": COL_INTERVAL,
"interval_units": COL_INTERVAL_UNITS,
"timeout": COL_TIMEOUT,
"timeout_units": COL_TIMEOUT_UNITS,
"max_size": COL_MAX_SIZE,
"max_size_units": COL_MAX_SIZE_UNITS,
},
error_preview_limit=INSPECT_ERROR_PREVIEW_LIMIT,
)
self._table_view_controller.on_table_view_tab_changed(0)
InspectorPanel(dialog=self).build()
self._subscription_edit_controller = SubscriptionEditController(
dialog=self,
columns={
"enabled": COL_ENABLED,
"name": COL_NAME,
"url": COL_URL,
"filename": COL_FILENAME,
"format": COL_FORMAT,
"group": COL_GROUP,
"interval": COL_INTERVAL,
"interval_units": COL_INTERVAL_UNITS,
"timeout": COL_TIMEOUT,
"timeout_units": COL_TIMEOUT_UNITS,
"max_size": COL_MAX_SIZE,
"max_size_units": COL_MAX_SIZE_UNITS,
},
)
self.create_file_button.clicked.connect(
self._action_file_controller.create_action_file
)
self.save_button.clicked.connect(self._action_file_controller.save_action_file)
self.reload_button.clicked.connect(
self._runtime_controller.reload_runtime_and_config
)
self.start_runtime_button.clicked.connect(
self._runtime_controller.start_runtime_clicked
)
self.stop_runtime_button.clicked.connect(
self._runtime_controller.stop_runtime_clicked
)
self.add_sub_button.clicked.connect(
self._subscription_edit_controller.add_subscription_row
)
self.create_global_rule_button.clicked.connect(
self._rules_editor_controller.create_global_rule
)
self.edit_sub_button.clicked.connect(
self._subscription_edit_controller.edit_action_clicked
)
self.remove_sub_button.clicked.connect(
self._subscription_edit_controller.remove_selected_subscription
)
self.refresh_state_button.clicked.connect(
self._table_data_controller.refresh_all_now
)
self.refresh_now_button.clicked.connect(
self._table_data_controller.refresh_selected_now
)
self.create_rule_button.clicked.connect(
self._selection_controller.open_rules_action
)
self.table.itemDoubleClicked.connect(
self._subscription_edit_controller.handle_table_item_double_clicked
)
self.table.clicked.connect(self._table_data_controller.handle_table_clicked)
self.table.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
self.table.customContextMenuRequested.connect(
self._context_menu_controller.open_table_context_menu
)
sel_model = self.table.selectionModel()
if sel_model is not None:
sel_model.selectionChanged.connect(
self._inspector_controller.handle_table_selection_changed
)
self.subscription_state_refreshed.connect(
self._inspector_controller.on_subscription_state_refreshed
)
self._runtime_controller.set_runtime_state(active=False)
self._selection_controller.update_selected_actions_state()
self._inspector_controller.update_inspector_panel()
@@ -0,0 +1,158 @@
import html
import os
from collections.abc import Callable
from typing import Any, TYPE_CHECKING, Final
from opensnitch.plugins.list_subscriptions.ui import (
QtWidgets,
load_ui_type,
)
from opensnitch.plugins.list_subscriptions._utils import RES_DIR
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
_configure_modal_dialog,
_wire_copy_close_buttons,
)
STATUS_LOG_DIALOG_UI_PATH: Final[str] = os.path.join(RES_DIR, "status_log_dialog.ui")
StatusLogDialogUI: Final[Any] = load_ui_type(STATUS_LOG_DIALOG_UI_PATH)[0]
class StatusLogDialog(QtWidgets.QDialog, StatusLogDialogUI):
if TYPE_CHECKING:
text_view: QtWidgets.QTextEdit
copy_button: QtWidgets.QPushButton
close_button: QtWidgets.QPushButton
def __init__(
self,
parent: QtWidgets.QWidget,
*,
title: str,
lines: list[str],
fallback_text: str,
level_color: Callable[[str], str],
timestamp_color: str,
):
super().__init__(parent)
self.setupUi(self)
_configure_modal_dialog(self, title=title)
self.text_view.setReadOnly(True)
self.text_view.setLineWrapMode(QtWidgets.QTextEdit.LineWrapMode.NoWrap)
self.text_view.setFontFamily("monospace")
self.update_entries(
lines=lines,
fallback_text=fallback_text,
level_color=level_color,
timestamp_color=timestamp_color,
)
# Scroll to the last entry when dialog is shown
scrollbar = self.text_view.verticalScrollBar()
if scrollbar is not None:
scrollbar.setValue(scrollbar.maximum())
_wire_copy_close_buttons(
self,
self.copy_button,
self.close_button,
self.text_view,
)
@staticmethod
def _is_near_bottom(scrollbar: QtWidgets.QScrollBar) -> bool:
return scrollbar.value() >= (scrollbar.maximum() - 2)
def update_entries(
self,
*,
lines: list[str],
fallback_text: str,
level_color: Callable[[str], str],
timestamp_color: str,
) -> None:
scrollbar = self.text_view.verticalScrollBar()
if scrollbar is None:
display_lines = lines[:]
if not display_lines and (fallback_text or "").strip() != "":
display_lines = [fallback_text]
html_text = self._entries_html(display_lines, level_color, timestamp_color)
self.text_view.setHtml(html_text)
return
prev_value = scrollbar.value()
follow_tail = self._is_near_bottom(scrollbar)
display_lines = lines[:]
if not display_lines and (fallback_text or "").strip() != "":
display_lines = [fallback_text]
html_text = self._entries_html(display_lines, level_color, timestamp_color)
self.text_view.setHtml(html_text)
if follow_tail:
scrollbar.setValue(scrollbar.maximum())
return
scrollbar.setValue(min(prev_value, scrollbar.maximum()))
@staticmethod
def _entries_html(
lines: list[str],
level_color: Callable[[str], str],
timestamp_color: str,
) -> str:
html_lines: list[str] = []
for line in lines:
text = str(line or "").rstrip("\n")
if text == "":
html_lines.append("<span>&nbsp;</span>")
continue
level = "INFO"
timestamp = ""
remainder = text
if text.startswith("["):
timestamp_end = text.find("]")
if timestamp_end > 0:
timestamp = text[1:timestamp_end].strip()
remainder = text[timestamp_end + 1 :].lstrip()
if remainder.startswith("["):
level_end = remainder.find("]")
if level_end > 0:
level = remainder[1:level_end].strip() or "INFO"
remainder = remainder[level_end + 1 :].lstrip()
level_html = html.escape(level)
timestamp_html = html.escape(timestamp)
message_html = html.escape(remainder.lstrip())
color = level_color(level)
timestamp_prefix = ""
if timestamp_html != "":
timestamp_prefix = (
f"<span style=\"color: {timestamp_color};\">[{timestamp_html}]</span> "
)
html_lines.append(
"<span>"
f"{timestamp_prefix}"
f"<span style=\"color: {color}; font-weight: 600;\">[{level_html}]</span> "
f"{message_html}"
"</span>"
)
body = "<br/>".join(html_lines)
return (
"<html><body "
'style="white-space: pre-wrap; font-family: monospace;">'
f"{body}"
"</body></html>"
)
def exec(self) -> int:
return int(super().exec())
def show(self) -> None:
super().show()
# Scroll to the last entry when dialog is shown
scrollbar = self.text_view.verticalScrollBar()
if scrollbar is not None:
scrollbar.setValue(scrollbar.maximum())
@@ -0,0 +1,535 @@
import logging
import os
from typing import Any, TYPE_CHECKING, Final
from opensnitch.plugins.list_subscriptions.ui import (
QtCore,
QtGui,
QtWidgets,
QC,
load_ui_type,
)
from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults
from opensnitch.plugins.list_subscriptions.models.subscriptions import (
MutableSubscriptionSpec,
)
from opensnitch.plugins.list_subscriptions._utils import (
RES_DIR,
INTERVAL_UNITS,
TIMEOUT_UNITS,
SIZE_UNITS,
normalize_group,
normalize_groups,
)
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
_apply_footer_separator_style,
_apply_section_bar_style,
)
from opensnitch.plugins.list_subscriptions.ui.widgets.helpers import (
_configure_spin_and_units,
_set_optional_field_tooltips,
)
from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import (
_replace_checkbox_with_toggle,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.status_controller import (
DialogStatusController,
)
from opensnitch.plugins.list_subscriptions.ui.controllers.subscription_dialog_controller import (
SubscriptionDialogController,
)
SUBSCRIPTION_DIALOG_UI_PATH: Final[str] = os.path.join(
RES_DIR, "subscription_dialog.ui"
)
SubscriptionDialogUI: Final[Any] = load_ui_type(SUBSCRIPTION_DIALOG_UI_PATH)[0]
DIALOG_MESSAGE_PREVIEW_LIMIT: Final[int] = 48
DIALOG_MESSAGE_LOG_LIMIT: Final[int] = 200
logger: Final[logging.Logger] = logging.getLogger(__name__)
def _origin_slug_from_title(title: str) -> str:
slug = "-".join((title or "subscription").strip().lower().split())
return slug or "subscription"
class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI):
_url_test_finished = QtCore.pyqtSignal(bool, str)
log_message = QtCore.pyqtSignal(str, str, str) # (message, level, origin)
if TYPE_CHECKING:
rootLayout: QtWidgets.QVBoxLayout
bodyLayout: QtWidgets.QHBoxLayout
settings_group_layout: QtWidgets.QVBoxLayout
settings_section_bar: QtWidgets.QFrame
settings_section_label: QtWidgets.QLabel
settings_group: QtWidgets.QGroupBox
meta_grid: QtWidgets.QGridLayout
enabled_check: QtWidgets.QCheckBox
buttons_layout: QtWidgets.QHBoxLayout
settings_form: QtWidgets.QFormLayout
name_label: QtWidgets.QLabel
name_edit: QtWidgets.QLineEdit
name_error_label: QtWidgets.QLabel
url_label: QtWidgets.QLabel
url_edit: QtWidgets.QLineEdit
url_error_label: QtWidgets.QLabel
filename_label: QtWidgets.QLabel
filename_edit: QtWidgets.QLineEdit
filename_error_label: QtWidgets.QLabel
format_label: QtWidgets.QLabel
format_combo: QtWidgets.QComboBox
groups_label: QtWidgets.QLabel
group_combo: QtWidgets.QComboBox
interval_label: QtWidgets.QLabel
interval_layout: QtWidgets.QHBoxLayout
interval_spin: QtWidgets.QSpinBox
interval_units: QtWidgets.QComboBox
timeout_label: QtWidgets.QLabel
timeout_layout: QtWidgets.QHBoxLayout
timeout_spin: QtWidgets.QSpinBox
timeout_units: QtWidgets.QComboBox
max_size_label: QtWidgets.QLabel
max_size_layout: QtWidgets.QHBoxLayout
max_size_spin: QtWidgets.QSpinBox
max_size_units: QtWidgets.QComboBox
meta_group_layout: QtWidgets.QVBoxLayout
meta_section_bar: QtWidgets.QFrame
meta_section_label: QtWidgets.QLabel
meta_group: QtWidgets.QGroupBox
meta_separator: QtWidgets.QFrame
meta_file_present_label: QtWidgets.QLabel
meta_file_present: QtWidgets.QLabel
meta_meta_present_label: QtWidgets.QLabel
meta_meta_present: QtWidgets.QLabel
meta_state_label: QtWidgets.QLabel
meta_state: QtWidgets.QLabel
meta_last_checked_label: QtWidgets.QLabel
meta_last_checked: QtWidgets.QLabel
meta_last_updated_label: QtWidgets.QLabel
meta_last_updated: QtWidgets.QLabel
meta_failures_label: QtWidgets.QLabel
meta_failures: QtWidgets.QLabel
meta_error_label: QtWidgets.QLabel
meta_error: QtWidgets.QLabel
meta_list_path_label: QtWidgets.QLabel
meta_list_path: QtWidgets.QLabel
meta_meta_path_label: QtWidgets.QLabel
meta_meta_path: QtWidgets.QLabel
error_label: QtWidgets.QLabel
footer_separator_line: QtWidgets.QFrame
test_url_button: QtWidgets.QPushButton
cancel_button: QtWidgets.QPushButton
add_button: QtWidgets.QPushButton
_title: str
_defaults: GlobalDefaults
_groups: list[str]
_sub: MutableSubscriptionSpec
_meta: dict[str, str]
def __init__(
self,
parent: QtWidgets.QWidget | None,
defaults: GlobalDefaults,
groups: list[str] | None = None,
sub: MutableSubscriptionSpec | dict[str, Any] | None = None,
meta: dict[str, str] | None = None,
title: str = "Subscription",
):
super().__init__(parent)
self.setWindowTitle(QC.translate("stats", title))
self.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
self._title = title
self._log_origin = f"ui:{_origin_slug_from_title(title)}"
self._defaults = defaults
self._groups = groups or []
try:
if isinstance(sub, MutableSubscriptionSpec):
self._sub = sub
elif sub is None:
parsed_sub = MutableSubscriptionSpec.from_dict(
{"enabled": True},
defaults=self._defaults,
require_url=False,
ensure_suffix=False,
)
if parsed_sub is None:
raise ValueError(
"default subscription state could not be initialized"
)
self._sub = parsed_sub
else:
parsed_sub = MutableSubscriptionSpec.from_dict(
sub,
defaults=self._defaults,
require_url=False,
ensure_suffix=False,
)
if parsed_sub is None:
raise ValueError("subscription data could not be initialized")
self._sub = parsed_sub
except Exception as exc:
QtWidgets.QMessageBox.critical(
parent,
QC.translate("stats", "Subscription Error"),
QC.translate(
"stats", "Failed to initialize subscription data: {0}"
).format(str(exc)),
)
raise
self._meta = meta or {}
self._dialog_message_inspect_button: QtWidgets.QPushButton | None = None
self._deferred_dialog_result: int | None = None
self._build_ui()
self.finished.connect(lambda _: self._subscription_dialog_controller.disconnect_signal())
def hideEvent(self, event: QtGui.QHideEvent | None): # type: ignore[override]
self._subscription_dialog_controller.cancel_active_url_test()
super().hideEvent(event)
def _defer_dialog_close(self, result: int) -> bool:
if not self._subscription_dialog_controller.has_active_url_test():
return False
if self._deferred_dialog_result is None:
self._deferred_dialog_result = result
self.setEnabled(False)
self._dialog_message_controller.set_status(
QC.translate("stats", "Stopping background tasks..."),
error=False,
log=False,
)
self._subscription_dialog_controller.cancel_active_url_test()
self._subscription_dialog_controller.on_url_test_stopped(
self._complete_deferred_dialog_close
)
return True
def _complete_deferred_dialog_close(self) -> None:
result = self._deferred_dialog_result
if result is None:
return
self._deferred_dialog_result = None
self.setEnabled(True)
self._dialog_message_controller.set_status("", error=False, log=False)
if result == int(QtWidgets.QDialog.DialogCode.Accepted):
super().accept()
return
super().reject()
def accept(self) -> None:
if self._defer_dialog_close(int(QtWidgets.QDialog.DialogCode.Accepted)):
return
super().accept()
def reject(self) -> None:
if self._defer_dialog_close(int(QtWidgets.QDialog.DialogCode.Rejected)):
return
super().reject()
def closeEvent(self, event: QtGui.QCloseEvent | None): # type: ignore[override]
if self._defer_dialog_close(int(QtWidgets.QDialog.DialogCode.Rejected)):
if event is not None:
event.ignore()
return
self._dialog_message_controller.set_status("", error=False, log=False)
super().closeEvent(event)
def _build_ui(self):
self.setupUi(self)
self.enabled_check = _replace_checkbox_with_toggle(self.enabled_check)
self.rootLayout.setContentsMargins(0, 0, 0, 0)
self.rootLayout.setSpacing(0)
self.bodyLayout.setContentsMargins(0, 0, 0, 0)
self.bodyLayout.setSpacing(0)
self.settings_group.setStyleSheet(
"QGroupBox { border: 0; margin: 0; padding: 0; }"
)
self.meta_group.setStyleSheet("QGroupBox { border: 0; margin: 0; padding: 0; }")
self.settings_group_layout.setContentsMargins(0, 0, 0, 0)
self.settings_group_layout.setSpacing(0)
self.settings_form.setContentsMargins(12, 10, 12, 10)
self.settings_form.setVerticalSpacing(14)
self.meta_group_layout.setContentsMargins(0, 0, 0, 0)
self.meta_group_layout.setSpacing(0)
self.meta_grid.setContentsMargins(12, 10, 12, 10)
self.buttons_layout.setContentsMargins(12, 10, 12, 12)
self.buttons_layout.setSpacing(8)
self.bodyLayout.setStretch(0, 1)
self.bodyLayout.setStretch(1, 1)
_apply_section_bar_style(
self, self.settings_section_bar, self.settings_section_label
)
_apply_section_bar_style(
self, self.meta_section_bar, self.meta_section_label
)
_apply_section_bar_style(
self,
self.settings_section_bar,
self.settings_section_label,
right_border=True,
)
_apply_footer_separator_style(self, self.footer_separator_line)
footer_role = (
QtGui.QPalette.ColorRole.Midlight
if self.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128
else QtGui.QPalette.ColorRole.Dark
)
footer_border = self.palette().color(footer_role).name()
self.footer_separator_line.setStyleSheet(f"color: {footer_border};")
self.error_label.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Expanding,
QtWidgets.QSizePolicy.Policy.Fixed,
)
self.error_label.setStyleSheet(
f"QLabel {{ background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()}; padding: 8px 12px 8px 12px; }}"
)
self.error_label.setAlignment(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
)
self.error_label.setWordWrap(False)
self.error_label.setTextFormat(QtCore.Qt.TextFormat.PlainText)
self._dialog_message_controller = DialogStatusController(
label=self.error_label,
inspect_button=None,
preview_limit=DIALOG_MESSAGE_PREVIEW_LIMIT,
log_limit=DIALOG_MESSAGE_LOG_LIMIT,
timestamp_format="yyyy-MM-ddTHH:mm:ss.zzz",
ok_color="#2e7d32",
error_color="red",
empty_button_behavior="hide",
)
self._subscription_dialog_controller = SubscriptionDialogController(dialog=self)
self._dialog_message_controller.set_status("", error=False)
footer_index = self.rootLayout.indexOf(self.footer_separator_line)
error_index = self.rootLayout.indexOf(self.error_label)
if error_index >= 0:
status_row = QtWidgets.QWidget(self)
status_row.setStyleSheet(
"QWidget {"
f"border-top: 1px solid {footer_border};"
f"background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()};"
"}"
)
status_row_layout = QtWidgets.QHBoxLayout(status_row)
status_row_layout.setContentsMargins(12, 0, 12, 0)
status_row_layout.setSpacing(8)
self.buttons_layout.removeWidget(self.error_label)
self.error_label.setParent(status_row)
status_row_layout.addWidget(self.error_label, 1)
insert_index = footer_index + 1 if footer_index >= 0 else error_index
self.rootLayout.insertWidget(insert_index, status_row)
self.settings_form.setFieldGrowthPolicy(
QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow
)
settings_label_width = 96
for label in (
self.name_label,
self.url_label,
self.filename_label,
self.format_label,
self.groups_label,
self.interval_label,
self.timeout_label,
self.max_size_label,
):
label.setMinimumWidth(settings_label_width)
self.enabled_check.setContentsMargins(0, 0, 0, 8)
unit_combo_width = 132
expanding = QtWidgets.QSizePolicy.Policy.Expanding
fixed = QtWidgets.QSizePolicy.Policy.Fixed
self.format_combo.setSizePolicy(expanding, fixed)
self.group_combo.setSizePolicy(expanding, fixed)
self.interval_spin.setSizePolicy(expanding, fixed)
self.timeout_spin.setSizePolicy(expanding, fixed)
self.max_size_spin.setSizePolicy(expanding, fixed)
self.interval_units.setSizePolicy(fixed, fixed)
self.timeout_units.setSizePolicy(fixed, fixed)
self.max_size_units.setSizePolicy(fixed, fixed)
self.format_combo.setMinimumWidth(0)
self.group_combo.setMinimumWidth(0)
self.interval_units.setMinimumWidth(unit_combo_width)
self.timeout_units.setMinimumWidth(unit_combo_width)
section_font = self.settings_section_label.font()
section_font.setPointSize(13)
section_font.setBold(True)
self.settings_section_label.setFont(section_font)
self.meta_section_label.setFont(section_font)
self.settings_section_label.setMinimumHeight(32)
self.meta_section_label.setMinimumHeight(32)
self.max_size_units.setMinimumWidth(unit_combo_width)
self.interval_layout.setStretch(0, 1)
self.interval_layout.setStretch(1, 0)
self.timeout_layout.setStretch(0, 1)
self.timeout_layout.setStretch(1, 0)
self.max_size_layout.setStretch(0, 1)
self.max_size_layout.setStretch(1, 0)
meta_label_width = 150
window_color = self.palette().color(QtGui.QPalette.ColorRole.Window)
is_dark_theme = window_color.lightness() < 128
separator_role = (
QtGui.QPalette.ColorRole.Midlight
if is_dark_theme
else QtGui.QPalette.ColorRole.Dark
)
separator_color = self.palette().color(separator_role)
separator_css = separator_color.name()
self.meta_separator.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
self.meta_separator.setFixedWidth(1)
self.meta_separator.setStyleSheet(
f"background-color: {separator_css}; border: 0; margin-left: 4px; margin-right: 10px;"
)
meta_label_style = "padding-right: 6px;"
for label in (
self.meta_file_present_label,
self.meta_meta_present_label,
self.meta_state_label,
self.meta_last_checked_label,
self.meta_last_updated_label,
self.meta_failures_label,
self.meta_error_label,
self.meta_list_path_label,
self.meta_meta_path_label,
):
label.setMinimumWidth(meta_label_width)
label.setStyleSheet(meta_label_style)
for label in (
self.name_error_label,
self.url_error_label,
self.filename_error_label,
):
label.setStyleSheet("color: red;")
label.setText("")
self.group_combo.setEditable(True)
self.group_combo.setToolTip(
QC.translate(
"stats",
"Optional explicit groups. Every subscription is always included in the global 'all' rules directory.",
)
)
self._url_test_finished.connect(self._subscription_dialog_controller.handle_url_test_finished)
self.add_button.clicked.connect(self._subscription_dialog_controller.validate_then_accept)
self.test_url_button.clicked.connect(self._subscription_dialog_controller.test_url)
self.cancel_button.clicked.connect(self.reject)
self.enabled_check.setChecked(bool(self._sub.enabled))
self.name_edit.setText(str(self._sub.name))
self.url_edit.setText(str(self._sub.url))
self.filename_edit.setText(str(self._sub.filename))
self.format_combo.clear()
self.format_combo.addItems(("hosts",))
self.format_combo.setCurrentText(str(self._sub.format or "hosts"))
for g in self._groups:
ng = normalize_group(g)
if ng not in ("", "all"):
self.group_combo.addItem(ng)
current_groups = normalize_groups(self._sub.groups)
current_group_text = ", ".join(current_groups)
if (
current_group_text != ""
and self.group_combo.findText(current_group_text) < 0
):
self.group_combo.addItem(current_group_text)
self.group_combo.setCurrentText(current_group_text)
_configure_spin_and_units(
self.interval_spin,
self.interval_units,
value=int(self._sub.interval or 0),
unit_value=str(self._sub.interval_units or self._defaults.interval_units),
allowed_units=INTERVAL_UNITS,
fallback_unit="hours",
special_value_text=QC.translate(
"stats", "Use global default ({0} {1})"
).format(
self._defaults.interval,
self._defaults.interval_units,
),
)
_configure_spin_and_units(
self.timeout_spin,
self.timeout_units,
value=int(self._sub.timeout or 0),
unit_value=str(self._sub.timeout_units or self._defaults.timeout_units),
allowed_units=TIMEOUT_UNITS,
fallback_unit="seconds",
special_value_text=QC.translate(
"stats", "Use global default ({0} {1})"
).format(
self._defaults.timeout,
self._defaults.timeout_units,
),
)
_configure_spin_and_units(
self.max_size_spin,
self.max_size_units,
value=int(self._sub.max_size or 0),
unit_value=str(self._sub.max_size_units or self._defaults.max_size_units),
allowed_units=SIZE_UNITS,
fallback_unit="MB",
special_value_text=QC.translate(
"stats", "Use global default ({0} {1})"
).format(
self._defaults.max_size,
self._defaults.max_size_units,
),
)
self.interval_spin.valueChanged.connect(self._subscription_dialog_controller.sync_optional_fields_state)
self.timeout_spin.valueChanged.connect(self._subscription_dialog_controller.sync_optional_fields_state)
self.max_size_spin.valueChanged.connect(self._subscription_dialog_controller.sync_optional_fields_state)
_set_optional_field_tooltips(
self.interval_spin,
self.interval_units,
self.timeout_spin,
self.timeout_units,
self.max_size_spin,
self.max_size_units,
inherit_wording=True,
)
self._subscription_dialog_controller.sync_optional_fields_state()
self.meta_file_present.setText(str(self._meta.get("file_present", "")))
self.meta_meta_present.setText(str(self._meta.get("meta_present", "")))
self.meta_state.setText(str(self._meta.get("state", "")))
self._subscription_dialog_controller.apply_meta_state_color(str(self._meta.get("state", "")))
self.meta_last_checked.setText(str(self._meta.get("last_checked", "")))
self.meta_last_updated.setText(str(self._meta.get("last_updated", "")))
self.meta_failures.setText(str(self._meta.get("failures", "")))
self.meta_error.setText(str(self._meta.get("error", "")))
self.meta_list_path.setText(str(self._meta.get("list_path", "")))
self.meta_meta_path.setText(str(self._meta.get("meta_path", "")))
if "new" in (self._title or "").strip().lower():
self.meta_group.setVisible(False)
self.resize(920, 420)
def subscription_spec(self):
groups = normalize_groups((self.group_combo.currentText() or "").strip())
return MutableSubscriptionSpec(
enabled=self.enabled_check.isChecked(),
name=(self.name_edit.text() or "").strip(),
url=(self.url_edit.text() or "").strip(),
filename=(self.filename_edit.text() or "").strip(),
format=(self.format_combo.currentText() or "hosts").strip().lower(),
groups=groups,
interval=int(self.interval_spin.value()) or None,
interval_units=(
self.interval_units.currentText()
if self.interval_spin.value() > 0
else None
),
timeout=int(self.timeout_spin.value()) or None,
timeout_units=(
self.timeout_units.currentText()
if self.timeout_spin.value() > 0
else None
),
max_size=int(self.max_size_spin.value()) or None,
max_size_units=(
self.max_size_units.currentText()
if self.max_size_spin.value() > 0
else None
),
)
@@ -0,0 +1,192 @@
import os
from typing import Any, Final, TYPE_CHECKING
from opensnitch.plugins.list_subscriptions.ui import (
QtCore,
QtWidgets,
QC,
load_ui_type,
)
from opensnitch.plugins.list_subscriptions._utils import RES_DIR
from opensnitch.plugins.list_subscriptions.ui.views.helpers import _configure_modal_dialog
SUBSCRIPTION_STATUS_DIALOG_UI_PATH: Final[str] = os.path.join(
RES_DIR, "subscription_status_dialog.ui"
)
SubscriptionStatusDialogUI: Final[Any] = load_ui_type(
SUBSCRIPTION_STATUS_DIALOG_UI_PATH
)[0]
class SubscriptionStatusDialog(QtWidgets.QDialog, SubscriptionStatusDialogUI):
ACTION_NONE: Final[str] = "none"
ACTION_EDIT: Final[str] = "edit"
ACTION_REFRESH: Final[str] = "refresh"
if TYPE_CHECKING:
title_label: QtWidgets.QLabel
details_scroll: QtWidgets.QScrollArea
details_container: QtWidgets.QWidget
buttons_layout: QtWidgets.QHBoxLayout
refresh_button: QtWidgets.QPushButton
edit_button: QtWidgets.QPushButton
close_button: QtWidgets.QPushButton
def __init__(
self,
parent: QtWidgets.QWidget | None,
name: str,
url: str,
filename: str,
meta: dict[str, str],
):
super().__init__(parent)
self.setupUi(self)
self._action = self.ACTION_NONE
self._url = url
self._filename = filename
self._value_labels: dict[str, QtWidgets.QLabel] = {}
self._refresh_signal: Any = None
_configure_modal_dialog(
self,
title=QC.translate("stats", "Subscription status"),
size=(700, 440),
)
self.title_label.setText(name or filename or url)
title_font = self.title_label.font()
title_font.setBold(True)
self.title_label.setFont(title_font)
details = QtWidgets.QFormLayout(self.details_container)
details.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignRight)
details.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
details.setHorizontalSpacing(10)
details.setVerticalSpacing(6)
self._add_value_row(details, "url", QC.translate("stats", "URL"), url)
self._add_value_row(
details,
"filename",
QC.translate("stats", "Filename"),
filename,
)
self._add_value_row(
details,
"file_present",
QC.translate("stats", "List file present"),
meta.get("file_present", ""),
)
self._add_value_row(
details,
"meta_present",
QC.translate("stats", "List meta present"),
meta.get("meta_present", ""),
)
self._add_value_row(
details,
"state",
QC.translate("stats", "State"),
meta.get("state", ""),
)
self._add_value_row(
details,
"last_checked",
QC.translate("stats", "Last checked"),
meta.get("last_checked", ""),
)
self._add_value_row(
details,
"last_updated",
QC.translate("stats", "Last updated"),
meta.get("last_updated", ""),
)
self._add_value_row(
details,
"failures",
QC.translate("stats", "Failures"),
meta.get("failures", ""),
)
self._add_value_row(
details,
"error",
QC.translate("stats", "Error"),
meta.get("error", ""),
)
self._add_value_row(
details,
"list_path",
QC.translate("stats", "List path"),
meta.get("list_path", ""),
)
self._add_value_row(
details,
"meta_path",
QC.translate("stats", "Meta path"),
meta.get("meta_path", ""),
)
self.refresh_button.setText(QC.translate("stats", "Refresh"))
self.edit_button.setText(QC.translate("stats", "Edit"))
self.close_button.setText(QC.translate("stats", "Close"))
self.refresh_button.clicked.connect(self.on_refresh)
self.edit_button.clicked.connect(self.on_edit)
self.close_button.clicked.connect(self.reject)
def reject(self):
self.disconnect_signal()
super().reject()
def action(self):
return self._action
# -- Meta refresh -------------------------------------------------------
def connect_to_refresh_signal(self, signal: Any) -> None:
self._refresh_signal = signal
signal.connect(self.on_state_refreshed)
def on_state_refreshed(self, url: str, filename: str, meta: dict[str, str]) -> None:
if url != self._url or filename != self._filename:
return
self.update_meta(meta)
def update_meta(self, meta: dict[str, str]) -> None:
fields = (
"file_present",
"meta_present",
"state",
"last_checked",
"last_updated",
"failures",
"error",
"list_path",
"meta_path",
)
for key in fields:
label = self._value_labels.get(key)
if label is None:
continue
label.setText((meta.get(key, "") or "-").strip() or "-")
def disconnect_signal(self) -> None:
if self._refresh_signal is not None:
try:
self._refresh_signal.disconnect(self.on_state_refreshed)
except Exception:
pass
self._refresh_signal = None
# -- Actions ------------------------------------------------------------
def on_refresh(self) -> None:
self.disconnect_signal()
self._action = self.ACTION_REFRESH
self.accept()
def on_edit(self) -> None:
self.disconnect_signal()
self._action = self.ACTION_EDIT
self.accept()
@@ -0,0 +1,54 @@
import os
from typing import Any, TYPE_CHECKING, Final
from opensnitch.plugins.list_subscriptions.ui import (
QtWidgets,
load_ui_type,
)
from opensnitch.plugins.list_subscriptions._utils import RES_DIR
from opensnitch.plugins.list_subscriptions.ui.views.helpers import (
_configure_modal_dialog,
_wire_copy_close_buttons,
)
TEXT_INSPECT_DIALOG_UI_PATH: Final[str] = os.path.join(RES_DIR, "text_inspect_dialog.ui")
TextInspectDialogUI: Final[Any] = load_ui_type(TEXT_INSPECT_DIALOG_UI_PATH)[0]
class TextInspectDialog(QtWidgets.QDialog, TextInspectDialogUI):
if TYPE_CHECKING:
text_view: QtWidgets.QPlainTextEdit
copy_button: QtWidgets.QPushButton
close_button: QtWidgets.QPushButton
def __init__(
self,
parent: QtWidgets.QWidget,
*,
title: str,
text: str,
):
super().__init__(parent)
self._has_content = (text or "").strip() != ""
if not self._has_content:
return
self.setupUi(self)
_configure_modal_dialog(self, title=title)
self.text_view.setReadOnly(True)
self.text_view.setLineWrapMode(QtWidgets.QPlainTextEdit.LineWrapMode.NoWrap)
self.text_view.setPlainText(text)
_wire_copy_close_buttons(
self,
self.copy_button,
self.close_button,
self.text_view,
)
def exec(self) -> int:
if not self._has_content:
return int(QtWidgets.QDialog.DialogCode.Rejected)
return int(super().exec())
@@ -0,0 +1,91 @@
from collections.abc import Sequence
from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC
def _set_optional_field_tooltips(
interval_spin: QtWidgets.QWidget,
interval_units: QtWidgets.QWidget,
timeout_spin: QtWidgets.QWidget,
timeout_units: QtWidgets.QWidget,
max_size_spin: QtWidgets.QWidget,
max_size_units: QtWidgets.QWidget,
*,
inherit_wording: bool,
):
if inherit_wording:
interval_spin.setToolTip(
QC.translate("stats", "Set to 0 to inherit the global interval.")
)
interval_units.setToolTip(
QC.translate("stats", "Used only when the interval override is set.")
)
timeout_spin.setToolTip(
QC.translate("stats", "Set to 0 to inherit the global timeout.")
)
timeout_units.setToolTip(
QC.translate("stats", "Used only when the timeout override is set.")
)
max_size_spin.setToolTip(
QC.translate("stats", "Set to 0 to inherit the global max size.")
)
max_size_units.setToolTip(
QC.translate("stats", "Used only when the max size override is set.")
)
return
interval_spin.setToolTip(
QC.translate(
"stats",
"Set to 0 to clear the interval override and use the global default.",
)
)
interval_units.setToolTip(
QC.translate("stats", "Used only when an interval override is applied.")
)
timeout_spin.setToolTip(
QC.translate(
"stats",
"Set to 0 to clear the timeout override and use the global default.",
)
)
timeout_units.setToolTip(
QC.translate("stats", "Used only when a timeout override is applied.")
)
max_size_spin.setToolTip(
QC.translate(
"stats",
"Set to 0 to clear the max size override and use the global default.",
)
)
max_size_units.setToolTip(
QC.translate("stats", "Used only when a max size override is applied.")
)
def _configure_spin_and_units(
spin: QtWidgets.QSpinBox,
units_combo: QtWidgets.QComboBox,
*,
value: int,
unit_value: str | None,
allowed_units: Sequence[str],
fallback_unit: str,
min_value: int = 0,
max_value: int = 999999,
special_value_text: str | None = None,
):
spin.setRange(min_value, max_value)
if special_value_text is not None:
spin.setSpecialValueText(special_value_text)
spin.setValue(max(min_value, int(value)))
units_combo.clear()
units_combo.addItems(tuple(allowed_units))
normalized = (unit_value or "").strip().lower()
current_unit = fallback_unit
for unit in allowed_units:
if unit.lower() == normalized:
current_unit = unit
break
units_combo.setCurrentText(
current_unit
)
@@ -0,0 +1,139 @@
from opensnitch.plugins.list_subscriptions.ui import QtCore, QtGui, QtWidgets
class KeepForegroundOnSelectionDelegate(QtWidgets.QStyledItemDelegate):
def initStyleOption(
self,
option: QtWidgets.QStyleOptionViewItem | None,
index: QtCore.QModelIndex,
):
super().initStyleOption(option, index)
if option is None or index is None:
return
foreground = index.data(QtCore.Qt.ItemDataRole.ForegroundRole)
if foreground is None:
return
brush = (
foreground
if isinstance(foreground, QtGui.QBrush)
else QtGui.QBrush(foreground)
)
option.palette.setBrush(
QtGui.QPalette.ColorRole.Text,
brush,
)
option.palette.setBrush(
QtGui.QPalette.ColorRole.HighlightedText,
brush,
)
class CenteredCheckDelegate(QtWidgets.QStyledItemDelegate):
def _indicator_rect(
self,
option: QtWidgets.QStyleOptionViewItem,
) -> QtCore.QRect:
style = (
option.widget.style()
if option.widget is not None
else QtWidgets.QApplication.style()
)
if style is None:
return option.rect
indicator_rect = style.subElementRect(
QtWidgets.QStyle.SubElement.SE_ItemViewItemCheckIndicator,
option,
option.widget,
)
return QtCore.QRect(
option.rect.x() + (option.rect.width() - indicator_rect.width()) // 2,
option.rect.y() + (option.rect.height() - indicator_rect.height()) // 2,
indicator_rect.width(),
indicator_rect.height(),
)
def initStyleOption(
self,
option: QtWidgets.QStyleOptionViewItem | None,
index: QtCore.QModelIndex,
) -> None:
super().initStyleOption(option, index)
if option is None:
return
option.displayAlignment = QtCore.Qt.AlignmentFlag.AlignCenter
def paint(
self,
painter: QtGui.QPainter | None,
option: QtWidgets.QStyleOptionViewItem,
index: QtCore.QModelIndex,
) -> None:
if painter is None:
return
opt = QtWidgets.QStyleOptionViewItem(option)
self.initStyleOption(opt, index)
if not (
opt.features
& QtWidgets.QStyleOptionViewItem.ViewItemFeature.HasCheckIndicator
):
super().paint(painter, option, index)
return
style = (
opt.widget.style()
if opt.widget is not None
else QtWidgets.QApplication.style()
)
if style is None:
return
draw_opt = QtWidgets.QStyleOptionViewItem(opt)
draw_opt.features &= (
~QtWidgets.QStyleOptionViewItem.ViewItemFeature.HasCheckIndicator
)
draw_opt.text = ""
draw_opt.checkState = QtCore.Qt.CheckState.Unchecked
style.drawControl(
QtWidgets.QStyle.ControlElement.CE_ItemViewItem,
draw_opt,
painter,
draw_opt.widget,
)
indicator_opt = QtWidgets.QStyleOptionViewItem(opt)
indicator_opt.rect = self._indicator_rect(opt)
indicator_opt.state &= ~(
QtWidgets.QStyle.StateFlag.State_On
| QtWidgets.QStyle.StateFlag.State_Off
| QtWidgets.QStyle.StateFlag.State_NoChange
)
check_state_raw = index.data(QtCore.Qt.ItemDataRole.CheckStateRole)
check_state = (
int(check_state_raw.value)
if isinstance(check_state_raw, QtCore.Qt.CheckState)
else int(check_state_raw or 0)
)
if check_state == int(QtCore.Qt.CheckState.Checked.value):
indicator_opt.state |= QtWidgets.QStyle.StateFlag.State_On
indicator_opt.checkState = QtCore.Qt.CheckState.Checked
elif check_state == int(QtCore.Qt.CheckState.PartiallyChecked.value):
indicator_opt.state |= QtWidgets.QStyle.StateFlag.State_NoChange
indicator_opt.checkState = QtCore.Qt.CheckState.PartiallyChecked
else:
indicator_opt.state |= QtWidgets.QStyle.StateFlag.State_Off
indicator_opt.checkState = QtCore.Qt.CheckState.Unchecked
style.drawPrimitive(
QtWidgets.QStyle.PrimitiveElement.PE_IndicatorItemViewItemCheck,
indicator_opt,
painter,
opt.widget,
)
class SortableTableWidgetItem(QtWidgets.QTableWidgetItem):
def __lt__(self, other: QtWidgets.QTableWidgetItem) -> bool:
left = self.data(QtCore.Qt.ItemDataRole.UserRole)
right = other.data(QtCore.Qt.ItemDataRole.UserRole)
if left is not None or right is not None:
return (left, self.text().lower()) < (right, other.text().lower())
return super().__lt__(other)
@@ -0,0 +1,249 @@
from opensnitch.plugins.list_subscriptions.ui import (
QtCore,
QtGui,
QtWidgets,
QC,
)
class ToggleSwitch(QtWidgets.QCheckBox):
def __init__(
self,
text: str = "",
parent: QtWidgets.QWidget | None = None,
):
super().__init__(text, parent)
self._base_text = text
self._track_width = 38
self._track_height = 22
self._thumb_diameter = 16
self._label_gap = 8
self._outer_padding = 4
self._paint_margin = 1.5
self._focus_margin = 2.0
self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus)
self.setSizePolicy(
QtWidgets.QSizePolicy.Policy.Preferred,
QtWidgets.QSizePolicy.Policy.Fixed,
)
self.setContentsMargins(
self._outer_padding,
self._outer_padding,
self._outer_padding,
self._outer_padding,
)
self.toggled.connect(self._refresh_geometry)
font = self.font()
font.setBold(True)
self.setFont(font)
self._refresh_geometry(self.isChecked())
def sizeHint(self) -> QtCore.QSize:
metrics = self.fontMetrics()
label_text = self._display_text()
text_width = metrics.horizontalAdvance(label_text) if label_text else 0
width = self._track_width + int(self._focus_margin * 2) + text_width
if text_width:
width += self._label_gap
margins = self.contentsMargins()
width += margins.left() + margins.right()
height = max(
metrics.height(),
self._track_height + int(self._focus_margin * 2),
)
height += margins.top() + margins.bottom()
return QtCore.QSize(width, height)
def minimumSizeHint(self) -> QtCore.QSize:
return self.sizeHint()
def hitButton(self, pos: QtCore.QPoint) -> bool:
return self.rect().contains(pos)
def _refresh_geometry(self, _checked: bool):
self.setMinimumHeight(self.sizeHint().height())
self.updateGeometry()
self.update()
def _display_text(self) -> str:
base = (self._base_text or "").strip()
if base.lower() == "enable list subscriptions plugin":
state = QC.translate(
"stats",
"enabled" if self.isChecked() else "disabled",
)
return QC.translate(
"stats",
"List subscriptions plugin {0}",
).format(state)
if base.lower() in {"enabled", "disabled"}:
return QC.translate(
"stats",
"Enabled" if self.isChecked() else "Disabled",
)
return base
def paintEvent(self, event: QtGui.QPaintEvent): # type: ignore[override]
del event
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
margins = self.contentsMargins()
content_rect = self.rect().adjusted(
margins.left(),
margins.top(),
-margins.right(),
-margins.bottom(),
)
_draw_toggle_switch(
painter,
self.palette(),
QtCore.QRectF(content_rect),
checked=self.isChecked(),
enabled=self.isEnabled(),
text=self._display_text(),
bold_text=True,
focused=self.hasFocus(),
track_width=float(self._track_width),
track_height=float(self._track_height),
thumb_diameter=float(self._thumb_diameter),
label_gap=float(self._label_gap),
paint_margin=float(self._paint_margin),
focus_margin=float(self._focus_margin),
)
def _draw_toggle_switch(
painter: QtGui.QPainter,
palette: QtGui.QPalette,
rect: QtCore.QRectF,
*,
checked: bool,
enabled: bool,
text: str = "",
bold_text: bool = False,
focused: bool = False,
track_width: float = 38.0,
track_height: float = 22.0,
thumb_diameter: float = 16.0,
label_gap: float = 8.0,
paint_margin: float = 1.5,
focus_margin: float = 2.0,
):
is_dark = palette.color(QtGui.QPalette.ColorRole.Window).lightness() < 128
if enabled:
off_role = (
QtGui.QPalette.ColorRole.Midlight
if is_dark
else QtGui.QPalette.ColorRole.Mid
)
border_role = (
QtGui.QPalette.ColorRole.Light if is_dark else QtGui.QPalette.ColorRole.Dark
)
track_color = (
palette.color(QtGui.QPalette.ColorRole.Highlight)
if checked
else palette.color(off_role)
)
thumb_color = palette.color(QtGui.QPalette.ColorRole.Base)
text_color = palette.color(QtGui.QPalette.ColorRole.WindowText)
border_color = palette.color(border_role)
else:
track_color = palette.color(QtGui.QPalette.ColorRole.Dark)
thumb_color = palette.color(QtGui.QPalette.ColorRole.Mid)
text_color = palette.color(QtGui.QPalette.ColorRole.Mid)
border_color = palette.color(QtGui.QPalette.ColorRole.Mid)
track_x = rect.left() + paint_margin + focus_margin
track_y = rect.top() + (rect.height() - track_height) / 2.0 + paint_margin
track_rect = QtCore.QRectF(
track_x,
track_y,
track_width - (paint_margin * 2.0),
track_height - (paint_margin * 2.0),
)
thumb_margin = (track_rect.height() - thumb_diameter) / 2.0
thumb_left_off = track_rect.left() + thumb_margin
thumb_left_on = track_rect.right() - thumb_margin - thumb_diameter
thumb_rect = QtCore.QRectF(
thumb_left_on if checked else thumb_left_off,
track_rect.top() + thumb_margin,
thumb_diameter,
thumb_diameter,
)
radius = track_rect.height() / 2.0
border_pen = QtGui.QPen(border_color)
border_pen.setWidth(1)
painter.setPen(border_pen)
painter.setBrush(track_color)
painter.drawRoundedRect(track_rect, radius, radius)
painter.setBrush(thumb_color)
painter.drawEllipse(thumb_rect)
if text:
text_rect = QtCore.QRectF(
track_rect.right() + label_gap,
rect.top(),
max(0.0, rect.right() - track_rect.right() - label_gap),
rect.height(),
)
painter.setPen(text_color)
font = painter.font()
font.setBold(bold_text)
painter.setFont(font)
painter.drawText(
text_rect,
int(
QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter
),
text,
)
if focused:
focus_pen = QtGui.QPen(palette.color(QtGui.QPalette.ColorRole.Highlight))
focus_pen.setWidth(1)
painter.setPen(focus_pen)
painter.setBrush(QtCore.Qt.BrushStyle.NoBrush)
painter.drawRoundedRect(
track_rect.adjusted(
-focus_margin,
-focus_margin,
focus_margin,
focus_margin,
),
radius + focus_margin,
radius + focus_margin,
)
def _replace_checkbox_with_toggle(
checkbox: QtWidgets.QCheckBox,
) -> ToggleSwitch:
toggle = ToggleSwitch(checkbox.text(), checkbox.parentWidget())
toggle.setObjectName(checkbox.objectName())
toggle.setChecked(checkbox.isChecked())
toggle.setEnabled(checkbox.isEnabled())
toggle.setToolTip(checkbox.toolTip())
toggle.setStatusTip(checkbox.statusTip())
toggle.setWhatsThis(checkbox.whatsThis())
toggle.setAccessibleName(checkbox.accessibleName())
toggle.setAccessibleDescription(checkbox.accessibleDescription())
toggle.setSizePolicy(checkbox.sizePolicy())
toggle.setMinimumSize(checkbox.minimumSize())
toggle.setMaximumSize(checkbox.maximumSize())
margins = checkbox.contentsMargins()
toggle.setContentsMargins(
max(toggle.contentsMargins().left(), margins.left()),
max(toggle.contentsMargins().top(), margins.top()),
max(toggle.contentsMargins().right(), margins.right()),
max(toggle.contentsMargins().bottom(), margins.bottom()),
)
parent = checkbox.parentWidget()
layout = parent.layout() if parent is not None else None
if layout is not None:
layout.replaceWidget(checkbox, toggle)
checkbox.hide()
checkbox.deleteLater()
return toggle
@@ -0,0 +1,17 @@
from .attached_rules_snapshot_worker import (
AttachedRulesCountWorker,
AttachedRulesFetchWorker,
AttachedRulesProcessWorker,
AttachedRulesSnapshotWorker,
)
from .state_refresh_worker import SubscriptionStateRefreshWorker
from .url_test_worker import UrlTestWorker
__all__ = [
"AttachedRulesCountWorker",
"AttachedRulesFetchWorker",
"AttachedRulesProcessWorker",
"AttachedRulesSnapshotWorker",
"SubscriptionStateRefreshWorker",
"UrlTestWorker",
]
@@ -0,0 +1,308 @@
import json
import time
from typing import Any
from PyQt6.QtSql import QSqlDatabase, QSqlQuery
from opensnitch.plugins.list_subscriptions.ui import QtCore
from opensnitch.config import Config
def _is_sqlite_uri(db_file: str) -> bool:
return str(db_file or "").strip().lower().startswith("file:")
def _worker_connection_name(prefix: str) -> str:
thread_id = int(QtCore.QThread.currentThreadId())
return f"{prefix}_{thread_id}_{time.time_ns()}"
def _open_worker_db(*, db_file: str, busy_timeout_ms: int, conn_name: str) -> QSqlDatabase | None:
db = QSqlDatabase.addDatabase("QSQLITE", conn_name)
db.setDatabaseName(db_file)
options: list[str] = [f"QSQLITE_BUSY_TIMEOUT={busy_timeout_ms}"]
if _is_sqlite_uri(db_file):
options.extend(["QSQLITE_OPEN_URI", "QSQLITE_ENABLE_SHARED_CACHE"])
db.setConnectOptions(";".join(options))
if not db.open():
return None
return db
def _close_worker_db(*, conn_name: str, db: QSqlDatabase | None) -> None:
if db is not None:
try:
if db.isOpen():
db.close()
except Exception:
pass
QSqlDatabase.removeDatabase(conn_name)
class AttachedRulesCountWorker(QtCore.QObject):
count_done = QtCore.pyqtSignal(object)
finished = QtCore.pyqtSignal()
def __init__(self, *, db_file: str, local_nodes: list[str]):
super().__init__()
self._db_file = db_file
self._local_nodes = local_nodes
self._stop_requested = False
def stop(self) -> None:
self._stop_requested = True
def _should_stop(self) -> bool:
return self._stop_requested
@QtCore.pyqtSlot()
def run(self):
count: int | None = None
started = time.monotonic()
conn_name = _worker_connection_name("attached_rules_count")
db: QSqlDatabase | None = None
query: QSqlQuery | None = None
try:
if self._should_stop() or not self._local_nodes:
count = 0
return
db = _open_worker_db(
db_file=self._db_file,
busy_timeout_ms=800,
conn_name=conn_name,
)
if db is None:
count = None
return
placeholders = ",".join("?" for _ in self._local_nodes)
query = QSqlQuery(db)
query.prepare(f"SELECT COUNT(*) FROM rules WHERE node IN ({placeholders})")
for node in self._local_nodes:
query.addBindValue(node)
if not query.exec():
count = None
return
if query.next():
count = int((query.value(0) or 0) or 0)
else:
count = 0
except Exception:
count = None
finally:
if query is not None:
try:
query.finish()
except Exception:
pass
query = None
if db is not None:
try:
if db.isOpen():
db.close()
except Exception:
pass
# Drop Python refs before removeDatabase to satisfy Qt lifetime rules.
db = QSqlDatabase()
_close_worker_db(conn_name=conn_name, db=None)
elapsed_ms = max(0, int((time.monotonic() - started) * 1000))
if not self._should_stop():
self.count_done.emit(
{
"count": count,
"over_limit": False,
"is_estimate": False,
"elapsed_ms": elapsed_ms,
}
)
self.finished.emit()
class AttachedRulesFetchWorker(QtCore.QObject):
rows_done = QtCore.pyqtSignal(object)
finished = QtCore.pyqtSignal()
def __init__(self, *, db_file: str, local_nodes: list[str]):
super().__init__()
self._db_file = db_file
self._local_nodes = local_nodes
self._stop_requested = False
def stop(self) -> None:
self._stop_requested = True
def _should_stop(self) -> bool:
return self._stop_requested
@QtCore.pyqtSlot()
def run(self):
rows: list[tuple[str, str, bool, str, str, str]] = []
started = time.monotonic()
conn_name = _worker_connection_name("attached_rules_fetch")
db: QSqlDatabase | None = None
query: QSqlQuery | None = None
try:
if self._should_stop() or not self._local_nodes:
return
db = _open_worker_db(
db_file=self._db_file,
busy_timeout_ms=1000,
conn_name=conn_name,
)
if db is None:
rows = []
return
placeholders = ",".join("?" for _ in self._local_nodes)
sql = (
"SELECT node, name, enabled, operator_type, operator_operand, operator_data "
f"FROM rules WHERE node IN ({placeholders})"
)
query = QSqlQuery(db)
query.prepare(sql)
for node in self._local_nodes:
query.addBindValue(node)
if not query.exec():
rows = []
return
while query.next():
if self._should_stop():
break
addr = str(query.value(0) or "").strip()
rule_name = str(query.value(1) or "").strip()
enabled_raw = str(query.value(2) or "").strip().lower()
op_type = str(query.value(3) or "").strip()
op_operand = str(query.value(4) or "").strip()
op_data = str(query.value(5) or "").strip()
if addr == "" or rule_name == "":
continue
rows.append(
(
addr,
rule_name,
enabled_raw == "true",
op_type,
op_operand,
op_data,
)
)
except Exception:
rows = []
finally:
if query is not None:
try:
query.finish()
except Exception:
pass
query = None
if db is not None:
try:
if db.isOpen():
db.close()
except Exception:
pass
# Drop Python refs before removeDatabase to satisfy Qt lifetime rules.
db = QSqlDatabase()
_close_worker_db(conn_name=conn_name, db=None)
elapsed_ms = max(0, int((time.monotonic() - started) * 1000))
if not self._should_stop():
self.rows_done.emit(
{
"rows": rows,
"elapsed_ms": elapsed_ms,
"row_count": len(rows),
}
)
self.finished.emit()
class AttachedRulesProcessWorker(QtCore.QObject):
snapshot_done = QtCore.pyqtSignal(object)
finished = QtCore.pyqtSignal()
def __init__(self, *, rows: list[tuple[str, str, bool, str, str, str]]):
super().__init__()
self._rows = rows
self._stop_requested = False
def stop(self) -> None:
self._stop_requested = True
def _should_stop(self) -> bool:
return self._stop_requested
@QtCore.pyqtSlot()
def run(self):
snapshot: dict[str, list[dict[str, Any]]] = {}
started = time.monotonic()
try:
if self._should_stop() or not self._rows:
return
seen_entries: set[tuple[str, str, str]] = set()
for row in self._rows:
if self._should_stop():
break
addr, rule_name, rule_enabled, op_type, op_operand, op_data = row
if op_operand == Config.OPERAND_LIST_DOMAINS and op_data != "":
direct = op_data.strip()
entry_key = (direct, addr, rule_name)
if entry_key not in seen_entries:
seen_entries.add(entry_key)
snapshot.setdefault(direct, []).append(
{
"addr": addr,
"name": rule_name,
"enabled": rule_enabled,
}
)
if op_type != Config.RULE_TYPE_LIST:
continue
try:
operators = json.loads(op_data)
except Exception:
continue
if not isinstance(operators, list):
continue
for op in operators:
if self._should_stop():
break
if not isinstance(op, dict):
continue
operand = str(op.get("operand") or "").strip()
data = str(op.get("data") or "").strip()
if operand != Config.OPERAND_LIST_DOMAINS or data == "":
continue
entry_key = (data, addr, rule_name)
if entry_key in seen_entries:
continue
seen_entries.add(entry_key)
snapshot.setdefault(data, []).append(
{
"addr": addr,
"name": rule_name,
"enabled": rule_enabled,
}
)
except Exception:
snapshot = {}
finally:
elapsed_ms = max(0, int((time.monotonic() - started) * 1000))
if not self._should_stop():
self.snapshot_done.emit(
{
"snapshot": snapshot,
"elapsed_ms": elapsed_ms,
"row_count": len(self._rows),
}
)
self.finished.emit()
class AttachedRulesSnapshotWorker(AttachedRulesProcessWorker):
"""Backward-compatible alias for snapshot processing worker."""
@@ -0,0 +1,177 @@
import json
import os
from typing import Any
from opensnitch.plugins.list_subscriptions.ui import QtCore
from opensnitch.plugins.list_subscriptions._utils import (
list_file_path,
subscription_rule_dir,
)
class SubscriptionStateRefreshWorker(QtCore.QObject):
refresh_done = QtCore.pyqtSignal(int, object)
finished = QtCore.pyqtSignal()
def __init__(
self,
*,
generation: int,
lists_dir: str,
rows: list[dict[str, Any]],
attached_rules_by_dir: dict[str, list[dict[str, Any]]],
):
super().__init__()
self._generation = generation
self._lists_dir = lists_dir
self._rows = rows
self._attached_rules_by_dir = attached_rules_by_dir
self._stop_requested = False
def stop(self) -> None:
self._stop_requested = True
def _should_stop(self) -> bool:
return self._stop_requested
@staticmethod
def _rule_attachment_matches(
*,
lists_dir: str,
filename: str,
list_type: str,
groups: list[str],
attached_rules_by_dir: dict[str, list[dict[str, Any]]],
) -> list[dict[str, Any]]:
rules_root = os.path.join(lists_dir, "rules.list.d")
candidate_dirs = [
(
"subscription",
os.path.normpath(subscription_rule_dir(lists_dir, filename, list_type)),
),
("all", os.path.normpath(os.path.join(rules_root, "all"))),
]
candidate_dirs.extend(
(f"group:{group}", os.path.normpath(os.path.join(rules_root, group)))
for group in groups
)
matches: list[dict[str, Any]] = []
seen_match: set[tuple[str, str, str]] = set()
for source, directory in candidate_dirs:
for rule_entry in attached_rules_by_dir.get(directory, []):
addr = str(rule_entry.get("addr", "")).strip()
name = str(rule_entry.get("name", "")).strip()
enabled = bool(rule_entry.get("enabled", True))
if addr == "" or name == "" or not enabled:
continue
key = (addr, name, source)
if key in seen_match:
continue
seen_match.add(key)
matches.append(
{
"addr": addr,
"name": name,
"enabled": enabled,
"source": source,
"directory": directory,
}
)
matches.sort(
key=lambda item: (item["name"].lower(), item["addr"], item["source"])
)
return matches
@QtCore.pyqtSlot()
def run(self):
results: list[dict[str, Any]] = []
try:
for row_data in self._rows:
if self._should_stop():
return
row = int(row_data.get("row", -1))
url = str(row_data.get("url", "") or "")
filename = str(row_data.get("filename", "") or "")
list_type = str(row_data.get("list_type", "hosts") or "hosts")
enabled = bool(row_data.get("enabled", True))
groups = list(row_data.get("groups", []))
list_path = list_file_path(self._lists_dir, filename, list_type)
meta_path = list_path + ".meta.json"
file_exists = os.path.exists(list_path)
meta_exists = os.path.exists(meta_path)
meta: dict[str, Any] = {}
if meta_exists:
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
except Exception:
meta = {}
last_result = str(meta.get("last_result", "never")) if meta else "never"
last_checked = str(meta.get("last_checked", "")) if meta else ""
last_updated = str(meta.get("last_updated", "")) if meta else ""
fail_count = str(meta.get("fail_count", 0)) if meta else "0"
last_error = str(meta.get("last_error", "")) if meta else ""
attachment_matches = self._rule_attachment_matches(
lists_dir=self._lists_dir,
filename=filename,
list_type=list_type,
groups=groups,
attached_rules_by_dir=self._attached_rules_by_dir,
)
rule_attached = "yes" if attachment_matches else "no"
if not enabled:
state = "disabled"
elif not file_exists:
if not meta_exists or last_result in ("never", "", "busy"):
state = "pending"
else:
state = "missing"
elif last_result in ("updated", "not_modified"):
state = last_result
elif last_result in (
"error",
"write_error",
"request_error",
"unexpected_error",
"bad_format",
"too_large",
):
state = last_result
elif last_result == "busy":
state = "busy"
else:
state = last_result
results.append(
{
"row": row,
"url": url,
"filename": filename,
"enabled": enabled,
"file_present": "yes" if file_exists else "no",
"meta_present": "yes" if meta_exists else "no",
"state": state,
"rule_attached": rule_attached,
"attachment_matches": attachment_matches,
"last_checked": last_checked,
"last_updated": last_updated,
"failures": fail_count,
"error": last_error,
"list_path": list_path,
"meta_path": meta_path,
}
)
if not self._should_stop():
self.refresh_done.emit(self._generation, results)
finally:
self.finished.emit()
@@ -0,0 +1,108 @@
from typing import Any
from opensnitch.plugins.list_subscriptions.ui import QtCore, QC
from opensnitch.plugins.list_subscriptions._utils import is_hosts_file_like
class UrlTestWorker(QtCore.QObject):
test_result = QtCore.pyqtSignal(bool, str)
finished = QtCore.pyqtSignal()
def __init__(self, url: str, list_type: str = "hosts"):
super().__init__()
self.url = url
self.list_type = (list_type or "hosts").strip().lower()
self._stop_requested = False
self._active_response: Any = None
def stop(self) -> None:
self._stop_requested = True
resp = self._active_response
if resp is not None:
try:
resp.close()
except Exception:
pass
def _should_stop(self) -> bool:
return self._stop_requested
@QtCore.pyqtSlot()
def run(self):
import requests
try:
if self._should_stop():
return
# HEAD has no interruptible response object; keep timeout short.
response = requests.head(self.url, allow_redirects=True, timeout=3)
if response.status_code >= 400 and response.status_code not in (403, 405):
raise requests.HTTPError(f"HTTP {response.status_code}")
final_url = response.url or self.url
response.close()
if self._should_stop():
return
if response.status_code in (403, 405):
response = requests.get(
self.url, allow_redirects=True, timeout=3, stream=True
)
self._active_response = response
if response.status_code >= 400:
raise requests.HTTPError(f"HTTP {response.status_code}")
final_url = response.url or final_url
response.close()
self._active_response = None
if self._should_stop():
return
message = QC.translate("stats", "URL reachable.")
if self.list_type == "hosts":
sample_lines: list[str] = []
response = requests.get(
self.url,
allow_redirects=True,
timeout=5,
stream=True,
)
self._active_response = response
if response.status_code >= 400:
raise requests.HTTPError(f"HTTP {response.status_code}")
for chunk in response.iter_content(chunk_size=32 * 1024):
if self._should_stop():
return
if not chunk:
continue
txt = chunk.decode("utf-8", errors="ignore")
for line in txt.splitlines():
if len(sample_lines) < 200:
sample_lines.append(line)
else:
break
if len(sample_lines) >= 200:
break
response.close()
self._active_response = None
if not is_hosts_file_like(sample_lines):
self.test_result.emit(
False,
QC.translate(
"stats",
"URL is reachable but content is not valid hosts format.",
),
)
return
if final_url != self.url:
message = QC.translate("stats", "URL reachable via redirect.")
if final_url:
message = QC.translate("stats", "URL reachable via redirect.")
self.test_result.emit(True, f"{message} {final_url}")
return
self.test_result.emit(True, message)
except requests.RequestException as exc:
if not self._should_stop():
self.test_result.emit(False, str(exc))
finally:
self._active_response = None
self.finished.emit()