From 6710e368b9717fbac73becdc729f062ab1f50c38 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Wed, 4 Mar 2026 12:11:12 +0100 Subject: [PATCH 01/13] list subs init --- .../plugins/list_subscriptions/__init__.py | 0 .../list_subscriptions/list_subscriptions.py | 762 ++++++++++++++++++ 2 files changed, 762 insertions(+) create mode 100644 ui/opensnitch/plugins/list_subscriptions/__init__.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py diff --git a/ui/opensnitch/plugins/list_subscriptions/__init__.py b/ui/opensnitch/plugins/list_subscriptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py new file mode 100644 index 0000000000..a53963a0a7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -0,0 +1,762 @@ +import os +import json +import errno +import hashlib +import threading +from dataclasses import dataclass, field, asdict +from typing import Any +from datetime import datetime, timedelta +import time +from queue import Queue + +import requests + +from opensnitch.utils import GenericTimer +from opensnitch.utils.duration import duration +from opensnitch.utils.logger import logger +from opensnitch.utils.xdg import xdg_config_home + + +# -------------------- constants -------------------- + +DEFAULT_UA = ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0 Safari/537.36" +) + +UNIT_TO_DUR = { + "seconds": "s", + "minutes": "m", + "hours": "h", + "days": "d", + "weeks": "w", +} + +SIZE_MULT = { + "bytes": 1, + "kb": 1024, + "mb": 1024 * 1024, + "gb": 1024 * 1024 * 1024, +} + + +# -------------------- time helpers (ISO 8601) -------------------- + +def now_iso() -> str: + return datetime.now().astimezone().isoformat() + + +def parse_iso(ts: str): + try: + return datetime.fromisoformat(ts) + except Exception: + return None + + +def to_seconds(value, units, default_seconds: int) -> int: + try: + if value is None: + return default_seconds + u = (units or "seconds").lower() + suf = UNIT_TO_DUR.get(u, "s") + sec = duration.to_seconds(f"{int(value)}{suf}") + return sec if sec > 0 else default_seconds + except Exception: + return default_seconds + + +def to_max_bytes(value, units, default_bytes: int) -> 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 + + +# -------------------- JSON IO -------------------- + +def read_json(path: str) -> dict: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def write_json_atomic(path: str, obj: dict): + 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) + + +# -------------------- lock + atomic swap -------------------- + +class FileLock: + def __init__(self, lock_path: str): + self.lock_path = lock_path + self.fd = None + + def acquire(self) -> bool: + try: + self.fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.write(self.fd, str(os.getpid()).encode("utf-8")) + return True + except OSError as e: + if e.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 + + +def looks_like_hosts_file(sample_lines: list[str]) -> bool: + 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 + + +# -------------------- dataclasses: schema -------------------- + +@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], computed_lists_dir: str) -> "GlobalDefaults": + # lists_dir: prefer config value, fallback to computed + lists_dir = str(d.get("lists_dir") or computed_lists_dir) + + def _int(v: int | float | str | None, default: int) -> int: + try: + return int(v) if v is not None else default + except Exception: + return default + + def _str(v: str | None, default: str) -> 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), + ) + + +@dataclass(frozen=True) +class SubscriptionSpec: + name: str + url: str + filename: str + enabled: bool = True + format: str = "hosts" + + # optional overrides + 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 + user_agent: str | None = None + + @staticmethod + def from_dict(d: dict[str, Any]) -> "SubscriptionSpec | None": + if not isinstance(d, dict): + return None + + name = (d.get("name") or "").strip() + url = (d.get("url") or "").strip() + filename = (d.get("filename") or "").strip() + if not url or not filename: + return None + if not name: + name = filename + + def _opt_int(x): + try: + return int(x) if x is not None else None + except Exception: + return None + + def _opt_str(x): + x = (x or "").strip() + return x or None + + return SubscriptionSpec( + name=name, + url=url, + filename=filename, + enabled=bool(d.get("enabled", True)), + format=str(d.get("format", "hosts") or "hosts"), + + interval=_opt_int(d.get("interval")), + interval_units=_opt_str(d.get("interval_units")), + timeout=_opt_int(d.get("timeout")), + timeout_units=_opt_str(d.get("timeout_units")), + max_size=_opt_int(d.get("max_size")), + max_size_units=_opt_str(d.get("max_size_units")), + user_agent=_opt_str(d.get("user_agent")), + ) + + +@dataclass(frozen=True) +class PluginConfig: + defaults: GlobalDefaults + subscriptions: list[SubscriptionSpec] = field(default_factory=list) + + @staticmethod + def from_actions_config(raw_cfg: dict[str, Any], computed_lists_dir: str) -> "PluginConfig": + raw_cfg = raw_cfg or {} + defaults = GlobalDefaults.from_dict(raw_cfg, computed_lists_dir) + + subs: list[SubscriptionSpec] = [] + for item in (raw_cfg.get("subscriptions") or []): + sub = SubscriptionSpec.from_dict(item) + if sub is not None: + subs.append(sub) + + return PluginConfig(defaults=defaults, subscriptions=subs) + + +@dataclass +class ListMetadata: + version: int = 1 + url: str = "" + format: str = "hosts" + + etag: str = "" + last_modified: str = "" # HTTP header value, not ISO + + last_checked: str = "" # ISO + last_updated: str = "" # ISO + backoff_until: str = "" # ISO + + last_result: str = "never" + last_error: str = "" + + fail_count: int = 0 + bytes: int = 0 + + @staticmethod + def from_dict(d: dict[str, Any]) -> "ListMetadata": + m = ListMetadata() + if not isinstance(d, dict): + return m + + def _int(v, default): + try: + return int(v) if v is not None else default + except Exception: + return default + + def _str(v, default=""): + 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) -> dict[str, Any]: + return asdict(self) + + +# -------------------- config resolution -------------------- + +def effective_user_agent(sub: SubscriptionSpec, defaults: GlobalDefaults) -> str: + return sub.user_agent or defaults.user_agent or DEFAULT_UA + +def effective_interval_seconds(sub: SubscriptionSpec, defaults: GlobalDefaults) -> int: + v = sub.interval if sub.interval is not None else defaults.interval + u = sub.interval_units or defaults.interval_units + return to_seconds(v, u, default_seconds=24 * 3600) + +def effective_timeout_seconds(sub: SubscriptionSpec, defaults: GlobalDefaults) -> int: + v = sub.timeout if sub.timeout is not None else defaults.timeout + u = sub.timeout_units or defaults.timeout_units + return to_seconds(v, u, default_seconds=60) + +def effective_max_bytes(sub: SubscriptionSpec, defaults: GlobalDefaults) -> int: + v = sub.max_size if sub.max_size is not None else defaults.max_size + u = sub.max_size_units or defaults.max_size_units + # default 20MB + return to_max_bytes(v, u, default_bytes=20 * 1024 * 1024) + + +# -------------------- inotify watcher -------------------- + +class ConfigWatcher(threading.Thread): + """ + Watches a single config file via inotify and calls callback() on changes. + Uses a debounce window to avoid duplicate reloads on atomic-save patterns. + """ + def __init__(self, config_path: str, callback, debounce_ms: int = 250): + try: + from inotify_simple import INotify, flags + except ImportError: + raise RuntimeError("inotify_simple is required for ConfigWatcher") + super().__init__(daemon=True) + self.config_path = config_path + self.callback = callback + self.debounce_ms = debounce_ms + self._stop = threading.Event() + + self._dir = os.path.dirname(config_path) + self._base = os.path.basename(config_path) + + def stop(self): + self._stop.set() + + def run(self): + try: + ino = INotify() + wd = ino.add_watch( + self._dir, + flags.CLOSE_WRITE | flags.MOVED_TO | flags.CREATE | flags.DELETE | flags.MODIFY + ) + + last_fire = 0.0 + + while not self._stop.is_set(): + events = ino.read(timeout=int(self.debounce_ms)) + if not events: + continue + + # If any relevant event touches our file, debounce+fire + touched = False + for e in events: + name = e.name or "" + if name == self._base: + touched = True + break + + if not touched: + continue + + now = time.time() + if (now - last_fire) * 1000.0 < self.debounce_ms: + continue + last_fire = now + + try: + self.callback() + except Exception: + # callback must not kill watcher + pass + + except Exception: + # if inotify fails, we silently do nothing (or log if desired) + return + + +# -------------------- plugin core -------------------- + +class ListSubscriptions: + """ + - Single main config file (array of subscriptions). + - Sidecar metadata per list file. + - Per-subscription timers. + - inotify reload of main config on changes. + """ + + def __init__(self, config_path: str | None = None): + logger.new("list_subscriptions") + self._log = logger.get("list_subscriptions") + + # Where the actions JSON lives (example default) + self.config_path = config_path or os.path.join( + xdg_config_home, "opensnitch", "actions", "listSubscriptionsActions.json" + ) + + # Typed config + self.cfg_typed: PluginConfig | None = None + + # Runtime + self._resultsQueue = Queue() + self.scheduled_tasks: dict[str, GenericTimer] = {} # key -> timer + self._threads: dict[str, threading.Thread] = {} # key -> thread + self._watcher: ConfigWatcher | None = None + + # requests defaults except UA + self._session = requests.Session() + self._session.headers.update({"User-Agent": DEFAULT_UA}) + + # initial load + self.reload_config() + + # -------- config + reload -------- + + def reload_config(self): + """ + Reload main json config file, rebuild timers. + """ + computed_lists_dir = os.path.join(xdg_config_home, "opensnitch", "blocklists", "hosts") + + try: + raw = read_json(self.config_path) + # navigate to actions.list_subscriptions.config + cfg = ( + raw.get("actions", {}) + .get("list_subscriptions", {}) + .get("config", {}) + ) + self.cfg_typed = PluginConfig.from_actions_config(cfg, computed_lists_dir) + except Exception as e: + self._log.warning("Failed to load config %s: %s", self.config_path, repr(e)) + # Keep last good cfg_typed if available + if self.cfg_typed is None: + # minimal fallback config so plugin doesn't crash + defaults = GlobalDefaults.from_dict({"lists_dir": computed_lists_dir}, computed_lists_dir) + self.cfg_typed = PluginConfig(defaults=defaults, subscriptions=[]) + return + + # update session UA default (sub-level UA will still override per request) + self._session.headers.update({"User-Agent": self.cfg_typed.defaults.user_agent or DEFAULT_UA}) + + # rebuild timers + self.compile() + + # -------- metadata sidecar -------- + + def _paths(self, sub: SubscriptionSpec) -> tuple[str, str]: + if self.cfg_typed is None: + raise RuntimeError("PluginConfig is not loaded") + list_path = os.path.join(self.cfg_typed.defaults.lists_dir, sub.filename) + meta_path = list_path + ".meta.json" + return list_path, meta_path + + def _load_meta(self, meta_path: str) -> ListMetadata: + try: + return ListMetadata.from_dict(read_json(meta_path)) + except Exception: + return ListMetadata() + + def _save_meta(self, meta_path: str, meta: ListMetadata): + write_json_atomic(meta_path, meta.to_dict()) + + # -------- timer lifecycle -------- + + def _sub_key(self, sub: SubscriptionSpec) -> str: + base = f"{sub.url}|{sub.filename}" + return hashlib.sha1(base.encode("utf-8")).hexdigest()[:16] + + def compile(self): + """ + Build one GenericTimer per subscription. + Stops timers removed from config. + """ + if not self.cfg_typed: + return + + latest_keys = set() + + for sub in self.cfg_typed.subscriptions: + if not sub.enabled: + continue + + key = self._sub_key(sub) + latest_keys.add(key) + + if self.cfg_typed is None: + continue + interval_s = effective_interval_seconds(sub, self.cfg_typed.defaults) + + # recreate timer (simple, applies interval changes) + if key in self.scheduled_tasks: + try: + self.scheduled_tasks[key].stop() + except Exception: + pass + + self.scheduled_tasks[key] = GenericTimer( + interval_s, True, self.cb_run_tasks, (key, sub) + ) + + # stop removed timers + for key in list(self.scheduled_tasks.keys()): + if key not in latest_keys: + try: + self.scheduled_tasks[key].stop() + except Exception: + pass + self.scheduled_tasks.pop(key, None) + self._threads.pop(key, None) + + def run(self): + """ + Start timers + start inotify watcher. + """ + self.compile() + + for t in self.scheduled_tasks.values(): + try: + t.start() + except Exception: + pass + + # start watcher (reload on config edit) + if self._watcher is None: + self._watcher = ConfigWatcher(self.config_path, self.reload_config, debounce_ms=250) + self._watcher.start() + + def stop(self): + """ + Stop timers + watcher. + """ + for t in self.scheduled_tasks.values(): + try: + t.stop() + except Exception: + pass + self.scheduled_tasks.clear() + + if self._watcher is not None: + self._watcher.stop() + self._watcher = None + + # -------- scheduled execution -------- + + def cb_run_tasks(self, args): + """ + Timer callback for ONE subscription; spawns thread for network work. + """ + key, sub = args + + th = self._threads.get(key) + if th is not None and th.is_alive(): + return + + # due/backoff gate via sidecar meta + list_path, meta_path = self._paths(sub) + meta = self._load_meta(meta_path) + + if self._in_backoff(meta): + return + if not self._is_due(meta, sub): + return + + th = threading.Thread(target=self.download, args=(key, sub), daemon=True) + th.start() + self._threads[key] = th + + def _in_backoff(self, meta: ListMetadata) -> bool: + if not meta.backoff_until: + return False + dt = parse_iso(meta.backoff_until) + if not dt: + return False + return datetime.now().astimezone() < dt + + def _is_due(self, meta: ListMetadata, sub: SubscriptionSpec) -> bool: + if not meta.last_checked: + return True + lc = parse_iso(meta.last_checked) + if not lc: + return True + if self.cfg_typed is None: + # fallback to 24 hours if config is not loaded + interval_s = 24 * 3600 + else: + interval_s = effective_interval_seconds(sub, self.cfg_typed.defaults) + return (datetime.now().astimezone() - lc).total_seconds() >= interval_s + + # -------- worker: download + update metadata -------- + + def _mark_failure(self, meta: ListMetadata, err: str): + meta.fail_count = int(meta.fail_count or 0) + 1 + meta.last_error = err + meta.last_result = "error" + + seconds = min((2 ** max(0, meta.fail_count)) * 60, 6 * 3600) + meta.backoff_until = (datetime.now().astimezone() + timedelta(seconds=seconds)).isoformat() + + def download(self, key: str, sub: SubscriptionSpec): + ok, status = self._download_one(sub) + self._resultsQueue.put((key, ok, status)) + + def _download_one(self, sub: SubscriptionSpec) -> tuple[bool, str]: + list_path, meta_path = self._paths(sub) + os.makedirs(os.path.dirname(list_path), exist_ok=True) + + meta = self._load_meta(meta_path) + + # keep meta aligned + meta.version = 1 + meta.url = sub.url + meta.format = sub.format + + meta.last_checked = now_iso() + meta.last_error = "" + + timeout_s = effective_timeout_seconds(sub, self.cfg_typed.defaults) + max_bytes = effective_max_bytes(sub, self.cfg_typed.defaults) + + # conditional headers + headers = {} + if meta.etag: + headers["If-None-Match"] = meta.etag + if meta.last_modified: + headers["If-Modified-Since"] = meta.last_modified + + headers["User-Agent"] = effective_user_agent(sub, self.cfg_typed.defaults) + + lock = FileLock(list_path + ".lock") + if not lock.acquire(): + meta.last_result = "busy" + self._save_meta(meta_path, meta) + return True, "busy" + + try: + # requests defaults except UA; timeout is used + try: + r = self._session.get(sub.url, headers=headers, timeout=timeout_s, stream=True) + except Exception as e: + self._mark_failure(meta, repr(e)) + self._save_meta(meta_path, meta) + return False, "request_error" + + if r.status_code == 304: + meta.fail_count = 0 + meta.backoff_until = "" + meta.last_result = "not_modified" + self._save_meta(meta_path, meta) + return True, "not_modified" + + if r.status_code != 200: + self._mark_failure(meta, f"http_{r.status_code}") + self._save_meta(meta_path, meta) + return False, f"http_{r.status_code}" + + cl = r.headers.get("Content-Length") + if cl: + try: + if int(cl) > max_bytes: + self._mark_failure(meta, f"too_large:{cl}") + self._save_meta(meta_path, meta) + return False, "too_large" + except Exception: + pass + + tmp = list_path + ".tmp" + downloaded = 0 + sample_lines: list[str] = [] + + try: + with open(tmp, "wb") as f: + for chunk in r.iter_content(chunk_size=32 * 1024): + if not chunk: + continue + downloaded += len(chunk) + if downloaded > max_bytes: + raise RuntimeError("too_large_streamed") + f.write(chunk) + + if sub.format.lower() == "hosts" and len(sample_lines) < 200: + txt = chunk.decode("utf-8", errors="ignore") + for ln in txt.splitlines(): + if len(sample_lines) < 200: + sample_lines.append(ln) + else: + break + + f.flush() + os.fsync(f.fileno()) + + if sub.format.lower() == "hosts" and not looks_like_hosts_file(sample_lines): + try: + os.remove(tmp) + except Exception: + pass + self._mark_failure(meta, "bad_format_hosts") + self._save_meta(meta_path, meta) + return False, "bad_format" + + os.replace(tmp, list_path) + + except Exception as e: + try: + if os.path.exists(tmp): + os.remove(tmp) + except Exception: + pass + self._mark_failure(meta, repr(e)) + self._save_meta(meta_path, meta) + return False, "write_error" + + # update cache validators + et = r.headers.get("ETag") + lm = r.headers.get("Last-Modified") + if et: + meta.etag = et + if lm: + meta.last_modified = lm + + meta.bytes = downloaded + meta.last_updated = now_iso() + meta.fail_count = 0 + meta.backoff_until = "" + meta.last_result = "updated" + self._save_meta(meta_path, meta) + return True, "updated" + + finally: + lock.release() From 124cd492248a0d8b4ced1c2c2729a860cf2b41bb Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Wed, 4 Mar 2026 13:22:09 +0100 Subject: [PATCH 02/13] Fix list_subscriptions runtime reload and HTTP stream lifecycle issues --- .../list_subscriptions/list_subscriptions.py | 161 ++++++++++-------- 1 file changed, 86 insertions(+), 75 deletions(-) diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index a53963a0a7..77cf43819f 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -358,13 +358,13 @@ def __init__(self, config_path: str, callback, debounce_ms: int = 250): self.config_path = config_path self.callback = callback self.debounce_ms = debounce_ms - self._stop = threading.Event() + self._stop_event = threading.Event() self._dir = os.path.dirname(config_path) self._base = os.path.basename(config_path) def stop(self): - self._stop.set() + self._stop_event.set() def run(self): try: @@ -376,7 +376,7 @@ def run(self): last_fire = 0.0 - while not self._stop.is_set(): + while not self._stop_event.is_set(): events = ino.read(timeout=int(self.debounce_ms)) if not events: continue @@ -435,6 +435,7 @@ def __init__(self, config_path: str | None = None): self.scheduled_tasks: dict[str, GenericTimer] = {} # key -> timer self._threads: dict[str, threading.Thread] = {} # key -> thread self._watcher: ConfigWatcher | None = None + self._running = False # requests defaults except UA self._session = requests.Session() @@ -530,6 +531,11 @@ def compile(self): self.scheduled_tasks[key] = GenericTimer( interval_s, True, self.cb_run_tasks, (key, sub) ) + if self._running: + try: + self.scheduled_tasks[key].start() + except Exception: + pass # stop removed timers for key in list(self.scheduled_tasks.keys()): @@ -546,6 +552,7 @@ def run(self): Start timers + start inotify watcher. """ self.compile() + self._running = True for t in self.scheduled_tasks.values(): try: @@ -568,6 +575,7 @@ def stop(self): except Exception: pass self.scheduled_tasks.clear() + self._running = False if self._watcher is not None: self._watcher.stop() @@ -674,89 +682,92 @@ def _download_one(self, sub: SubscriptionSpec) -> tuple[bool, str]: self._save_meta(meta_path, meta) return False, "request_error" - if r.status_code == 304: - meta.fail_count = 0 - meta.backoff_until = "" - meta.last_result = "not_modified" - self._save_meta(meta_path, meta) - return True, "not_modified" + try: + if r.status_code == 304: + meta.fail_count = 0 + meta.backoff_until = "" + meta.last_result = "not_modified" + self._save_meta(meta_path, meta) + return True, "not_modified" - if r.status_code != 200: - self._mark_failure(meta, f"http_{r.status_code}") - self._save_meta(meta_path, meta) - return False, f"http_{r.status_code}" + if r.status_code != 200: + self._mark_failure(meta, f"http_{r.status_code}") + self._save_meta(meta_path, meta) + return False, f"http_{r.status_code}" + + cl = r.headers.get("Content-Length") + if cl: + try: + if int(cl) > max_bytes: + self._mark_failure(meta, f"too_large:{cl}") + self._save_meta(meta_path, meta) + return False, "too_large" + except Exception: + pass + + tmp = list_path + ".tmp" + downloaded = 0 + sample_lines: list[str] = [] - cl = r.headers.get("Content-Length") - if cl: try: - if int(cl) > max_bytes: - self._mark_failure(meta, f"too_large:{cl}") + with open(tmp, "wb") as f: + for chunk in r.iter_content(chunk_size=32 * 1024): + if not chunk: + continue + downloaded += len(chunk) + if downloaded > max_bytes: + raise RuntimeError("too_large_streamed") + f.write(chunk) + + if sub.format.lower() == "hosts" and len(sample_lines) < 200: + txt = chunk.decode("utf-8", errors="ignore") + for ln in txt.splitlines(): + if len(sample_lines) < 200: + sample_lines.append(ln) + else: + break + + f.flush() + os.fsync(f.fileno()) + + if sub.format.lower() == "hosts" and not looks_like_hosts_file(sample_lines): + try: + os.remove(tmp) + except Exception: + pass + self._mark_failure(meta, "bad_format_hosts") self._save_meta(meta_path, meta) - return False, "too_large" - except Exception: - pass + return False, "bad_format" - tmp = list_path + ".tmp" - downloaded = 0 - sample_lines: list[str] = [] + os.replace(tmp, list_path) - try: - with open(tmp, "wb") as f: - for chunk in r.iter_content(chunk_size=32 * 1024): - if not chunk: - continue - downloaded += len(chunk) - if downloaded > max_bytes: - raise RuntimeError("too_large_streamed") - f.write(chunk) - - if sub.format.lower() == "hosts" and len(sample_lines) < 200: - txt = chunk.decode("utf-8", errors="ignore") - for ln in txt.splitlines(): - if len(sample_lines) < 200: - sample_lines.append(ln) - else: - break - - f.flush() - os.fsync(f.fileno()) - - if sub.format.lower() == "hosts" and not looks_like_hosts_file(sample_lines): + except Exception as e: try: - os.remove(tmp) + if os.path.exists(tmp): + os.remove(tmp) except Exception: pass - self._mark_failure(meta, "bad_format_hosts") + self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) - return False, "bad_format" - - os.replace(tmp, list_path) - - except Exception as e: - try: - if os.path.exists(tmp): - os.remove(tmp) - except Exception: - pass - self._mark_failure(meta, repr(e)) + return False, "write_error" + + # update cache validators + et = r.headers.get("ETag") + lm = r.headers.get("Last-Modified") + if et: + meta.etag = et + if lm: + meta.last_modified = lm + + meta.bytes = downloaded + meta.last_updated = now_iso() + meta.fail_count = 0 + meta.backoff_until = "" + meta.last_result = "updated" self._save_meta(meta_path, meta) - return False, "write_error" - - # update cache validators - et = r.headers.get("ETag") - lm = r.headers.get("Last-Modified") - if et: - meta.etag = et - if lm: - meta.last_modified = lm - - meta.bytes = downloaded - meta.last_updated = now_iso() - meta.fail_count = 0 - meta.backoff_until = "" - meta.last_result = "updated" - self._save_meta(meta_path, meta) - return True, "updated" + return True, "updated" + finally: + r.close() finally: lock.release() From 6062ebebf462d548a16e09606383f67db05c7726 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Sat, 7 Mar 2026 11:56:34 +0100 Subject: [PATCH 03/13] align with opensnitch API's config handling --- .../example/hagezi-light.txt.meta.json | 19 + .../example/list_subscriptions.json | 46 ++ .../list_subscriptions/list_subscriptions.py | 588 ++++++++++-------- 3 files changed, 382 insertions(+), 271 deletions(-) create mode 100644 ui/opensnitch/plugins/list_subscriptions/example/hagezi-light.txt.meta.json create mode 100644 ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json diff --git a/ui/opensnitch/plugins/list_subscriptions/example/hagezi-light.txt.meta.json b/ui/opensnitch/plugins/list_subscriptions/example/hagezi-light.txt.meta.json new file mode 100644 index 0000000000..ad8dc5ffef --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/example/hagezi-light.txt.meta.json @@ -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 +} diff --git a/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json b/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json new file mode 100644 index 0000000000..84ef5d5419 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json @@ -0,0 +1,46 @@ +{ + "name": "listSubscriptionsActions", + "created": "", + "updated": "", + "description": "Manage and auto-update blocklist subscriptions (hosts format)", + "type": ["global"], + "actions": { + "list_subscriptions": { + "enabled": true, + "config": { + "lists_dir": "~/.config/opensnitch/blocklists/hosts", + + "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" + } + } + } + } + } +} diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index 77cf43819f..b0412b346d 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -1,21 +1,30 @@ import os +import logging import json import errno import hashlib import threading +import re from dataclasses import dataclass, field, asdict from typing import Any from datetime import datetime, timedelta -import time from queue import Queue import requests +from opensnitch.dialogs.stats import StatsDialog +from opensnitch.notifications import DesktopNotifications +from opensnitch.plugins import PluginBase, PluginSignal from opensnitch.utils import GenericTimer -from opensnitch.utils.duration import duration -from opensnitch.utils.logger import logger from opensnitch.utils.xdg import xdg_config_home +ch = logging.StreamHandler() +#ch.setLevel(logging.ERROR) +formatter = logging.Formatter('%(asctime)s - %(name)s - [%(levelname)s] %(message)s') +ch.setFormatter(formatter) +logger = logging.getLogger(__name__) +logger.addHandler(ch) +logger.setLevel(logging.WARNING) # -------------------- constants -------------------- @@ -25,12 +34,24 @@ "Chrome/120.0 Safari/537.36" ) -UNIT_TO_DUR = { - "seconds": "s", - "minutes": "m", - "hours": "h", - "days": "d", - "weeks": "w", +TIME_MULT = { + "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 = { + "s": TIME_MULT["seconds"], + "m": TIME_MULT["minutes"], + "h": TIME_MULT["hours"], + "d": TIME_MULT["days"], + "w": TIME_MULT["weeks"], } SIZE_MULT = { @@ -43,7 +64,7 @@ # -------------------- time helpers (ISO 8601) -------------------- -def now_iso() -> str: +def now_iso(): return datetime.now().astimezone().isoformat() @@ -54,19 +75,40 @@ def parse_iso(ts: str): return None -def to_seconds(value, units, default_seconds: int) -> int: +def to_seconds(value: Any, units: str | None, default_seconds: int): try: if value is None: return default_seconds u = (units or "seconds").lower() - suf = UNIT_TO_DUR.get(u, "s") - sec = duration.to_seconds(f"{int(value)}{suf}") + 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 to_max_bytes(value, units, default_bytes: int) -> int: +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 @@ -82,12 +124,12 @@ def to_max_bytes(value, units, default_bytes: int) -> int: # -------------------- JSON IO -------------------- -def read_json(path: str) -> dict: +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): +def write_json_atomic(path: str, obj: dict[str, Any]): d = os.path.dirname(path) if d: os.makedirs(d, exist_ok=True) @@ -104,9 +146,9 @@ def write_json_atomic(path: str, obj: dict): class FileLock: def __init__(self, lock_path: str): self.lock_path = lock_path - self.fd = None + self.fd: int | None = None - def acquire(self) -> bool: + def acquire(self): try: self.fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) os.write(self.fd, str(os.getpid()).encode("utf-8")) @@ -128,7 +170,7 @@ def release(self): pass -def looks_like_hosts_file(sample_lines: list[str]) -> bool: +def is_hosts_file_like(sample_lines: list[str]): valid = 0 total = 0 for line in sample_lines: @@ -161,17 +203,17 @@ class GlobalDefaults: user_agent: str | None = DEFAULT_UA @staticmethod - def from_dict(d: dict[str, Any], computed_lists_dir: str) -> "GlobalDefaults": - # lists_dir: prefer config value, fallback to computed - lists_dir = str(d.get("lists_dir") or computed_lists_dir) + def from_dict(d: dict[str, Any], lists_dir: str | None = None): + # lists_dir: prefer config value, fallback to lists_dir arg + lists_dir = str(d.get("lists_dir") or lists_dir or os.path.join(xdg_config_home, "opensnitch", "blocklists", "hosts")) - def _int(v: int | float | str | None, default: int) -> int: + 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) -> str: + def _str(v: str | None, default: str): v = (v or "").strip() return v if v else default @@ -194,18 +236,18 @@ class SubscriptionSpec: filename: str enabled: bool = True format: str = "hosts" - - # optional overrides - 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 - user_agent: str | None = None + interval: int = 24 + interval_units: str = "hours" + timeout: int = 60 + timeout_units: str = "seconds" + max_size: int = 20 + max_size_units: str = "MB" + interval_seconds: int = 24 * 3600 + timeout_seconds: int = 60 + max_bytes: int = 20 * 1024 * 1024 @staticmethod - def from_dict(d: dict[str, Any]) -> "SubscriptionSpec | None": + def from_dict(d: dict[str, Any], defaults: GlobalDefaults): if not isinstance(d, dict): return None @@ -217,15 +259,62 @@ def from_dict(d: dict[str, Any]) -> "SubscriptionSpec | None": if not name: name = filename - def _opt_int(x): + def _opt_int(x: Any): try: return int(x) if x is not None else None except Exception: return None - def _opt_str(x): - x = (x or "").strip() - return x or None + def _opt_str(x: Any): + try: + if x is None: + return None + x = (str(x) or "").strip().lower() + return x if x != "" else None + except Exception: + return None + + 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) or defaults.interval + interval_units_opt = _opt_str(interval_units_raw) + interval_units = interval_units_opt or defaults.interval_units + timeout = _opt_int(timeout_raw) or defaults.timeout + timeout_units_opt = _opt_str(timeout_units_raw) + timeout_units = timeout_units_opt or defaults.timeout_units + max_size = _opt_int(d.get("max_size")) or defaults.max_size + max_size_units = _opt_str(d.get("max_size_units")) or defaults.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) + + 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(interval, 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(timeout, timeout_units, default_timeout_seconds) + elif timeout_is_composite: + timeout = timeout_seconds + timeout_units = "composite" + + max_bytes = to_max_bytes(max_size, max_size_units, default_max_bytes) return SubscriptionSpec( name=name, @@ -234,29 +323,33 @@ def _opt_str(x): enabled=bool(d.get("enabled", True)), format=str(d.get("format", "hosts") or "hosts"), - interval=_opt_int(d.get("interval")), - interval_units=_opt_str(d.get("interval_units")), - timeout=_opt_int(d.get("timeout")), - timeout_units=_opt_str(d.get("timeout_units")), - max_size=_opt_int(d.get("max_size")), - max_size_units=_opt_str(d.get("max_size_units")), - user_agent=_opt_str(d.get("user_agent")), + 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(frozen=True) class PluginConfig: - defaults: GlobalDefaults + defaults: GlobalDefaults = field(default_factory=lambda: GlobalDefaults.from_dict({})) subscriptions: list[SubscriptionSpec] = field(default_factory=list) @staticmethod - def from_actions_config(raw_cfg: dict[str, Any], computed_lists_dir: str) -> "PluginConfig": + def from_dict(raw_cfg: dict[str, Any], lists_dir: str | None = None): raw_cfg = raw_cfg or {} - defaults = GlobalDefaults.from_dict(raw_cfg, computed_lists_dir) + 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) + sub = SubscriptionSpec.from_dict(item, defaults) if sub is not None: subs.append(sub) @@ -283,18 +376,18 @@ class ListMetadata: bytes: int = 0 @staticmethod - def from_dict(d: dict[str, Any]) -> "ListMetadata": + def from_dict(d: dict[str, Any]): m = ListMetadata() if not isinstance(d, dict): return m - def _int(v, default): + def _int(v: Any, default: int): try: return int(v) if v is not None else default except Exception: return default - def _str(v, default=""): + def _str(v: Any, default: str = ""): return str(v or default) m.version = _int(d.get("version", 1), 1) @@ -316,176 +409,91 @@ def _str(v, default=""): return m - def to_dict(self) -> dict[str, Any]: + def to_dict(self): return asdict(self) -# -------------------- config resolution -------------------- - -def effective_user_agent(sub: SubscriptionSpec, defaults: GlobalDefaults) -> str: - return sub.user_agent or defaults.user_agent or DEFAULT_UA - -def effective_interval_seconds(sub: SubscriptionSpec, defaults: GlobalDefaults) -> int: - v = sub.interval if sub.interval is not None else defaults.interval - u = sub.interval_units or defaults.interval_units - return to_seconds(v, u, default_seconds=24 * 3600) - -def effective_timeout_seconds(sub: SubscriptionSpec, defaults: GlobalDefaults) -> int: - v = sub.timeout if sub.timeout is not None else defaults.timeout - u = sub.timeout_units or defaults.timeout_units - return to_seconds(v, u, default_seconds=60) - -def effective_max_bytes(sub: SubscriptionSpec, defaults: GlobalDefaults) -> int: - v = sub.max_size if sub.max_size is not None else defaults.max_size - u = sub.max_size_units or defaults.max_size_units - # default 20MB - return to_max_bytes(v, u, default_bytes=20 * 1024 * 1024) - - -# -------------------- inotify watcher -------------------- - -class ConfigWatcher(threading.Thread): - """ - Watches a single config file via inotify and calls callback() on changes. - Uses a debounce window to avoid duplicate reloads on atomic-save patterns. - """ - def __init__(self, config_path: str, callback, debounce_ms: int = 250): - try: - from inotify_simple import INotify, flags - except ImportError: - raise RuntimeError("inotify_simple is required for ConfigWatcher") - super().__init__(daemon=True) - self.config_path = config_path - self.callback = callback - self.debounce_ms = debounce_ms - self._stop_event = threading.Event() - - self._dir = os.path.dirname(config_path) - self._base = os.path.basename(config_path) - - def stop(self): - self._stop_event.set() - - def run(self): - try: - ino = INotify() - wd = ino.add_watch( - self._dir, - flags.CLOSE_WRITE | flags.MOVED_TO | flags.CREATE | flags.DELETE | flags.MODIFY - ) - - last_fire = 0.0 - - while not self._stop_event.is_set(): - events = ino.read(timeout=int(self.debounce_ms)) - if not events: - continue - - # If any relevant event touches our file, debounce+fire - touched = False - for e in events: - name = e.name or "" - if name == self._base: - touched = True - break - - if not touched: - continue - - now = time.time() - if (now - last_fire) * 1000.0 < self.debounce_ms: - continue - last_fire = now - - try: - self.callback() - except Exception: - # callback must not kill watcher - pass - - except Exception: - # if inotify fails, we silently do nothing (or log if desired) - return - - # -------------------- plugin core -------------------- -class ListSubscriptions: - """ - - Single main config file (array of subscriptions). - - Sidecar metadata per list file. - - Per-subscription timers. - - inotify reload of main config on changes. - """ - - def __init__(self, config_path: str | None = None): - logger.new("list_subscriptions") - self._log = logger.get("list_subscriptions") - - # Where the actions JSON lives (example default) - self.config_path = config_path or os.path.join( - xdg_config_home, "opensnitch", "actions", "listSubscriptionsActions.json" - ) - - # Typed config - self.cfg_typed: PluginConfig | None = None +class ListSubscriptions(PluginBase): + """ A plugin to manage list subscriptions (e.g. blocklists). - # Runtime - self._resultsQueue = Queue() - self.scheduled_tasks: dict[str, GenericTimer] = {} # key -> timer - self._threads: dict[str, threading.Thread] = {} # key -> thread - self._watcher: ConfigWatcher | None = None + The plugin is configured via a JSON file specifying a list of subscriptions. + Each subscription has a URL and a local filename to save to. + The plugin periodically checks each URL for updates, using HTTP cache validators to avoid unnecessary downloads. + Metadata about each subscription is stored in a sidecar JSON file (same name + .meta.json) to track last update time, errors, backoff, etc. + The plugin exposes a results queue for the UI to display subscription status and errors. + """ + # fields overriden from parent class + name = "List Subscriptions" + version = 0 + author = "opensnitch" + created = "" + modified = "" + enabled = False + description = "Manage list subscriptions (e.g. blocklists) with periodic updates" + + # default + TYPE = [PluginBase.TYPE_GLOBAL] + + # runtime state + scheduled_tasks: dict[str, GenericTimer] = {} + default_conf = "{0}/{1}".format(xdg_config_home, "opensnitch/actions/list_subscriptions.json") + default_lists_dir = os.path.join(xdg_config_home, "opensnitch", "blocklists", "hosts") + + def __init__(self, config: dict[str, Any] | None = None): + config = config or {} + self._log = logger + self.signal_in.connect(self.cb_signal) + self._desktop_notifications = DesktopNotifications() + self._ok_msg = "" + self._err_msg = "" + self._notify: dict[str, Any] | None = None + self._notify_title = "[OpenSnitch] List subscriptions downloader" + self._resultsQueue: Queue[tuple[str, bool, str]] = Queue() self._running = False + self._app_icon = os.path.join(os.path.abspath(os.path.dirname(__file__)), "../../res/icon-white.svg") + + if config.get("enabled") is True: + self.enabled = True + + # Load config + plugin_cfg: Any = config.get("config", {}) + if not isinstance(plugin_cfg, dict): + plugin_cfg = {} + self._config = PluginConfig.from_dict(plugin_cfg, lists_dir=self.default_lists_dir) + self._notify = plugin_cfg.get("notify") + if isinstance(self._notify, dict): + ok = self._notify.get("success") + err = self._notify.get("error") + if isinstance(ok, dict): + ok_msg = ok.get("desktop") + if ok_msg: + self._ok_msg = ok_msg + if isinstance(err, dict): + err_msg = err.get("desktop") + if err_msg: + self._err_msg = err_msg + else: + self._notify = None - # requests defaults except UA - self._session = requests.Session() - self._session.headers.update({"User-Agent": DEFAULT_UA}) - - # initial load - self.reload_config() - - # -------- config + reload -------- - - def reload_config(self): - """ - Reload main json config file, rebuild timers. - """ - computed_lists_dir = os.path.join(xdg_config_home, "opensnitch", "blocklists", "hosts") - - try: - raw = read_json(self.config_path) - # navigate to actions.list_subscriptions.config - cfg = ( - raw.get("actions", {}) - .get("list_subscriptions", {}) - .get("config", {}) - ) - self.cfg_typed = PluginConfig.from_actions_config(cfg, computed_lists_dir) - except Exception as e: - self._log.warning("Failed to load config %s: %s", self.config_path, repr(e)) - # Keep last good cfg_typed if available - if self.cfg_typed is None: - # minimal fallback config so plugin doesn't crash - defaults = GlobalDefaults.from_dict({"lists_dir": computed_lists_dir}, computed_lists_dir) - self.cfg_typed = PluginConfig(defaults=defaults, subscriptions=[]) - return - - # update session UA default (sub-level UA will still override per request) - self._session.headers.update({"User-Agent": self.cfg_typed.defaults.user_agent or DEFAULT_UA}) - - # rebuild timers - self.compile() + # Set up requests session with default UA + self._session: requests.Session = requests.Session() + if self._config.defaults.user_agent: + self._session.headers.update({"User-Agent": self._config.defaults.user_agent}) + else: + self._session.headers.update({"User-Agent": DEFAULT_UA}) # -------- metadata sidecar -------- - def _paths(self, sub: SubscriptionSpec) -> tuple[str, str]: - if self.cfg_typed is None: + def _paths(self, sub: SubscriptionSpec): + if self._config is None: raise RuntimeError("PluginConfig is not loaded") - list_path = os.path.join(self.cfg_typed.defaults.lists_dir, sub.filename) + list_path = os.path.join(self._config.defaults.lists_dir, sub.filename) meta_path = list_path + ".meta.json" return list_path, meta_path - def _load_meta(self, meta_path: str) -> ListMetadata: + def _load_meta(self, meta_path: str): try: return ListMetadata.from_dict(read_json(meta_path)) except Exception: @@ -496,30 +504,35 @@ def _save_meta(self, meta_path: str, meta: ListMetadata): # -------- timer lifecycle -------- - def _sub_key(self, sub: SubscriptionSpec) -> str: + def _sub_key(self, sub: SubscriptionSpec): base = f"{sub.url}|{sub.filename}" return hashlib.sha1(base.encode("utf-8")).hexdigest()[:16] + def configure(self, parent: Any = None): + if type(parent) == StatsDialog: + pass + #_gui.add_panel_section() + def compile(self): """ Build one GenericTimer per subscription. Stops timers removed from config. """ - if not self.cfg_typed: + if not self._config: return - latest_keys = set() + latest_keys: set[str] = set() - for sub in self.cfg_typed.subscriptions: + for sub in self._config.subscriptions: if not sub.enabled: continue key = self._sub_key(sub) latest_keys.add(key) - if self.cfg_typed is None: + if self._config is None: continue - interval_s = effective_interval_seconds(sub, self.cfg_typed.defaults) + interval_s = sub.interval_seconds # recreate timer (simple, applies interval changes) if key in self.scheduled_tasks: @@ -531,11 +544,6 @@ def compile(self): self.scheduled_tasks[key] = GenericTimer( interval_s, True, self.cb_run_tasks, (key, sub) ) - if self._running: - try: - self.scheduled_tasks[key].start() - except Exception: - pass # stop removed timers for key in list(self.scheduled_tasks.keys()): @@ -545,13 +553,15 @@ def compile(self): except Exception: pass self.scheduled_tasks.pop(key, None) - self._threads.pop(key, None) - def run(self): + def run(self, parent: Any = None, args: tuple[Any, ...] = ()): # type: ignore[override] """ - Start timers + start inotify watcher. + Start timers. """ - self.compile() + + if parent == StatsDialog: + pass + self._running = True for t in self.scheduled_tasks.values(): @@ -560,14 +570,9 @@ def run(self): except Exception: pass - # start watcher (reload on config edit) - if self._watcher is None: - self._watcher = ConfigWatcher(self.config_path, self.reload_config, debounce_ms=250) - self._watcher.start() - def stop(self): """ - Stop timers + watcher. + Stop timers. """ for t in self.scheduled_tasks.values(): try: @@ -577,24 +582,20 @@ def stop(self): self.scheduled_tasks.clear() self._running = False - if self._watcher is not None: - self._watcher.stop() - self._watcher = None - # -------- scheduled execution -------- - def cb_run_tasks(self, args): + def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): """ - Timer callback for ONE subscription; spawns thread for network work. + Timer callback for one subscription. + Mirrors downloader behavior: start worker thread, join it, + then immediately evaluate queued result. """ + key: str + sub: SubscriptionSpec key, sub = args - th = self._threads.get(key) - if th is not None and th.is_alive(): - return - # due/backoff gate via sidecar meta - list_path, meta_path = self._paths(sub) + _, meta_path = self._paths(sub) meta = self._load_meta(meta_path) if self._in_backoff(meta): @@ -602,11 +603,53 @@ def cb_run_tasks(self, args): if not self._is_due(meta, sub): return - th = threading.Thread(target=self.download, args=(key, sub), daemon=True) + th = threading.Thread(target=self.download, args=(key, sub)) th.start() - self._threads[key] = th + th.join() + + matched: list[tuple[str, bool, str]] = [] + unmatched: list[tuple[str, bool, str]] = [] + while not self._resultsQueue.empty(): + item = self._resultsQueue.get_nowait() + if len(item) >= 3 and item[0] == key: + matched.append(item) + else: + unmatched.append(item) + for item in unmatched: + self._resultsQueue.put(item) + + if not matched: + logger.debug("cb_run_tasks() no result for key=%s sub=%s", key, sub.name) + return + + updated: bool = False + statuses: list[str] = [] + for _, ok, status in matched: + updated = ok + statuses.append(status) + if updated: + result_msg = self._ok_msg or f"{sub.name}: {', '.join(statuses)}" + else: + result_msg = self._err_msg or f"{sub.name} failed: {', '.join(statuses)}" + + if self._notify is not None and self._desktop_notifications.is_available() and self._desktop_notifications.are_enabled(): + self._desktop_notifications.show(self._notify_title, result_msg, self._app_icon) - def _in_backoff(self, meta: ListMetadata) -> bool: + def cb_signal(self, signal: Any): + logger.debug("cb_signal: %s, %s", self.name, signal) + try: + if signal == PluginSignal.ENABLE: + self.enabled = True + + if signal['signal'] == PluginSignal.DISABLE or signal['signal'] == PluginSignal.STOP: #type: ignore[union-attr] + for t in self.scheduled_tasks: + logger.debug("cb_signal.stopping task: %s, %s", self.name, signal) + self.scheduled_tasks[t].stop() + + except Exception as e: + logger.warning("cb_signal() exception: %s", repr(e)) + + def _in_backoff(self, meta: ListMetadata): if not meta.backoff_until: return False dt = parse_iso(meta.backoff_until) @@ -614,18 +657,13 @@ def _in_backoff(self, meta: ListMetadata) -> bool: return False return datetime.now().astimezone() < dt - def _is_due(self, meta: ListMetadata, sub: SubscriptionSpec) -> bool: + def _is_due(self, meta: ListMetadata, sub: SubscriptionSpec): if not meta.last_checked: return True lc = parse_iso(meta.last_checked) if not lc: return True - if self.cfg_typed is None: - # fallback to 24 hours if config is not loaded - interval_s = 24 * 3600 - else: - interval_s = effective_interval_seconds(sub, self.cfg_typed.defaults) - return (datetime.now().astimezone() - lc).total_seconds() >= interval_s + return (datetime.now().astimezone() - lc).total_seconds() >= sub.interval_seconds # -------- worker: download + update metadata -------- @@ -638,10 +676,6 @@ def _mark_failure(self, meta: ListMetadata, err: str): meta.backoff_until = (datetime.now().astimezone() + timedelta(seconds=seconds)).isoformat() def download(self, key: str, sub: SubscriptionSpec): - ok, status = self._download_one(sub) - self._resultsQueue.put((key, ok, status)) - - def _download_one(self, sub: SubscriptionSpec) -> tuple[bool, str]: list_path, meta_path = self._paths(sub) os.makedirs(os.path.dirname(list_path), exist_ok=True) @@ -655,32 +689,33 @@ def _download_one(self, sub: SubscriptionSpec) -> tuple[bool, str]: meta.last_checked = now_iso() meta.last_error = "" - timeout_s = effective_timeout_seconds(sub, self.cfg_typed.defaults) - max_bytes = effective_max_bytes(sub, self.cfg_typed.defaults) - # conditional headers - headers = {} + headers: dict[str, str] = {} if meta.etag: headers["If-None-Match"] = meta.etag if meta.last_modified: headers["If-Modified-Since"] = meta.last_modified - headers["User-Agent"] = effective_user_agent(sub, self.cfg_typed.defaults) + headers["User-Agent"] = self._config.defaults.user_agent or DEFAULT_UA lock = FileLock(list_path + ".lock") if not lock.acquire(): meta.last_result = "busy" self._save_meta(meta_path, meta) - return True, "busy" + self._resultsQueue.put((key, False, "busy")) + return False try: # requests defaults except UA; timeout is used try: - r = self._session.get(sub.url, headers=headers, timeout=timeout_s, stream=True) + r: requests.Response = self._session.get( + sub.url, headers=headers, timeout=sub.timeout_seconds, stream=True + ) except Exception as e: self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) - return False, "request_error" + self._resultsQueue.put((key, False, "request_error")) + return False try: if r.status_code == 304: @@ -688,20 +723,23 @@ def _download_one(self, sub: SubscriptionSpec) -> tuple[bool, str]: meta.backoff_until = "" meta.last_result = "not_modified" self._save_meta(meta_path, meta) - return True, "not_modified" + self._resultsQueue.put((key, True, "not_modified")) + return True if r.status_code != 200: self._mark_failure(meta, f"http_{r.status_code}") self._save_meta(meta_path, meta) - return False, f"http_{r.status_code}" + self._resultsQueue.put((key, False, f"http_{r.status_code}")) + return False - cl = r.headers.get("Content-Length") + cl: str | None = r.headers.get("Content-Length") if cl: try: - if int(cl) > max_bytes: + if int(cl) > sub.max_bytes: self._mark_failure(meta, f"too_large:{cl}") self._save_meta(meta_path, meta) - return False, "too_large" + self._resultsQueue.put((key, False, "too_large")) + return False except Exception: pass @@ -715,7 +753,7 @@ def _download_one(self, sub: SubscriptionSpec) -> tuple[bool, str]: if not chunk: continue downloaded += len(chunk) - if downloaded > max_bytes: + if downloaded > sub.max_bytes: raise RuntimeError("too_large_streamed") f.write(chunk) @@ -730,14 +768,15 @@ def _download_one(self, sub: SubscriptionSpec) -> tuple[bool, str]: f.flush() os.fsync(f.fileno()) - if sub.format.lower() == "hosts" and not looks_like_hosts_file(sample_lines): + if sub.format.lower() == "hosts" and not is_hosts_file_like(sample_lines): try: os.remove(tmp) except Exception: pass self._mark_failure(meta, "bad_format_hosts") self._save_meta(meta_path, meta) - return False, "bad_format" + self._resultsQueue.put((key, False, "bad_format")) + return False os.replace(tmp, list_path) @@ -749,7 +788,8 @@ def _download_one(self, sub: SubscriptionSpec) -> tuple[bool, str]: pass self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) - return False, "write_error" + self._resultsQueue.put((key, False, "write_error")) + return False # update cache validators et = r.headers.get("ETag") @@ -765,9 +805,15 @@ def _download_one(self, sub: SubscriptionSpec) -> tuple[bool, str]: meta.backoff_until = "" meta.last_result = "updated" self._save_meta(meta_path, meta) - return True, "updated" + self._resultsQueue.put((key, True, "updated")) + return True finally: r.close() + except Exception as e: + self._mark_failure(meta, repr(e)) + self._save_meta(meta_path, meta) + self._resultsQueue.put((key, False, "unexpected_error")) + return False finally: lock.release() From 346d6bc16f640c1535bff9040a19bcc8a8c7bfc7 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Mon, 9 Mar 2026 11:11:04 +0100 Subject: [PATCH 04/13] add multi sub handling with UI --- .gitignore | 2 + .../plugins/list_subscriptions/_gui.py | 1782 +++++++++++++++++ .../plugins/list_subscriptions/_models.py | 341 ++++ .../plugins/list_subscriptions/_utils.py | 155 ++ .../plugins/list_subscriptions/blocklist.svg | 37 + .../example/list_subscriptions.json | 4 +- .../list_subscriptions/list_subscriptions.py | 670 +++---- ui/opensnitch/proto/ui_pb2.py | 217 +- ui/opensnitch/proto/ui_pb2.pyi | 494 +++++ ui/opensnitch/proto/ui_pb2_grpc.py | 113 +- 10 files changed, 3294 insertions(+), 521 deletions(-) create mode 100644 ui/opensnitch/plugins/list_subscriptions/_gui.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/_models.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/_utils.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/blocklist.svg create mode 100644 ui/opensnitch/proto/ui_pb2.pyi diff --git a/.gitignore b/.gitignore index 8546be8c16..4f73f44bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ *.sock *.pyc *.profile + +.vscode/ diff --git a/ui/opensnitch/plugins/list_subscriptions/_gui.py b/ui/opensnitch/plugins/list_subscriptions/_gui.py new file mode 100644 index 0000000000..6f08fbf47d --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/_gui.py @@ -0,0 +1,1782 @@ +import json +import logging +import os +import re +import sys +import threading +from urllib.parse import urlparse, unquote +from datetime import datetime +from typing import Any, TYPE_CHECKING + +if TYPE_CHECKING: + # Keep static typing deterministic for linters/IDEs. + # Runtime still supports both PyQt6/PyQt5 below. + from PyQt6 import QtCore, QtGui, QtWidgets + from PyQt6.QtCore import QCoreApplication as QC +else: + if "PyQt5" in sys.modules: + from PyQt5 import QtCore, QtGui, QtWidgets + from PyQt5.QtCore import QCoreApplication as QC + elif "PyQt6" in sys.modules: + from PyQt6 import QtCore, QtGui, QtWidgets + from PyQt6.QtCore import QCoreApplication as QC + else: + try: + from PyQt6 import QtCore, QtGui, QtWidgets + from PyQt6.QtCore import QCoreApplication as QC + except Exception: + from PyQt5 import QtCore, QtGui, QtWidgets + from PyQt5.QtCore import QCoreApplication as QC + +from opensnitch.actions import Actions +from opensnitch.nodes import Nodes +from opensnitch.utils.xdg import xdg_config_home +from opensnitch.plugins.list_subscriptions._models import ( + GlobalDefaults, + PluginConfig, + SubscriptionSpec, + ensure_filename_type_suffix, + normalize_group, + normalize_groups, + normalize_lists_dir, +) +import requests + + +ACTION_FILE = os.path.join(xdg_config_home, "opensnitch", "actions", "list_subscriptions.json") +DEFAULT_LISTS_DIR = os.path.join(xdg_config_home, "opensnitch", "list_subscriptions") +INTERVAL_UNITS = ("seconds", "minutes", "hours", "days", "weeks") +TIMEOUT_UNITS = ("seconds", "minutes", "hours", "days", "weeks") +SIZE_UNITS = ("bytes", "KB", "MB", "GB") + +COL_ENABLED = 0 +COL_NAME = 1 +COL_URL = 2 +COL_FILENAME = 3 +COL_FORMAT = 4 +COL_GROUP = 5 +COL_INTERVAL = 6 +COL_INTERVAL_UNITS = 7 +COL_TIMEOUT = 8 +COL_TIMEOUT_UNITS = 9 +COL_MAX_SIZE = 10 +COL_MAX_SIZE_UNITS = 11 +COL_FILE = 12 +COL_META = 13 +COL_STATE = 14 +COL_LAST_CHECKED = 15 +COL_LAST_UPDATED = 16 +COL_FAILS = 17 +COL_ERROR = 18 + +logger = logging.getLogger(__name__) + + +def _template_action() -> dict[str, Any]: + return { + "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": DEFAULT_LISTS_DIR, + "interval": 24, + "interval_units": "hours", + "timeout": 20, + "timeout_units": "seconds", + "max_size": 50, + "max_size_units": "MB", + "subscriptions": [], + "notify": { + "success": {"desktop": "Lists subscriptions updated"}, + "error": {"desktop": "Error updating lists subscriptions"}, + }, + }, + } + }, + } + + +class SubscriptionDialog(QtWidgets.QDialog): + def __init__( + self, + parent: QtWidgets.QWidget | None, + defaults: GlobalDefaults, + groups: list[str] | None = None, + sub: dict[str, Any] | None = None, + meta: dict[str, str] | None = None, + title: str = "Subscription", + ) -> None: + super().__init__(parent) + self.setWindowTitle(QC.translate("stats", title)) + self._defaults = defaults + self._groups = groups or ["all"] + self._sub = sub or {} + self._meta = meta or {} + self._build_ui() + + def _build_ui(self) -> None: + root = QtWidgets.QVBoxLayout(self) + body = QtWidgets.QHBoxLayout() + settings_group = QtWidgets.QGroupBox(QC.translate("stats", "Settings")) + settings_form = QtWidgets.QFormLayout(settings_group) + + self.enabled_check = QtWidgets.QCheckBox(QC.translate("stats", "Enabled")) + self.enabled_check.setChecked(bool(self._sub.get("enabled", True))) + settings_form.addRow(self.enabled_check) + + self.name_edit = QtWidgets.QLineEdit() + self.name_edit.setText(str(self._sub.get("name", ""))) + settings_form.addRow(QC.translate("stats", "Name"), self.name_edit) + + self.url_edit = QtWidgets.QLineEdit() + self.url_edit.setText(str(self._sub.get("url", ""))) + settings_form.addRow(QC.translate("stats", "URL"), self.url_edit) + + self.filename_edit = QtWidgets.QLineEdit() + self.filename_edit.setText(str(self._sub.get("filename", ""))) + settings_form.addRow(QC.translate("stats", "Filename"), self.filename_edit) + + self.format_combo = QtWidgets.QComboBox() + self.format_combo.addItems(("hosts",)) + self.format_combo.setCurrentText(str(self._sub.get("format", "hosts"))) + settings_form.addRow(QC.translate("stats", "Format"), self.format_combo) + + self.group_combo = QtWidgets.QComboBox() + self.group_combo.setEditable(True) + for g in self._groups: + ng = normalize_group(g) + if ng != "": + self.group_combo.addItem(ng) + current_groups = normalize_groups(self._sub.get("groups")) + current_group_text = ", ".join(current_groups) + if self.group_combo.findText(current_group_text) < 0: + self.group_combo.addItem(current_group_text) + self.group_combo.setCurrentText(current_group_text) + settings_form.addRow(QC.translate("stats", "Groups"), self.group_combo) + + self.interval_spin = QtWidgets.QSpinBox() + self.interval_spin.setRange(1, 999999) + self.interval_spin.setValue(max(1, int(self._sub.get("interval", self._defaults.interval)))) + self.interval_units = QtWidgets.QComboBox() + self.interval_units.addItems(INTERVAL_UNITS) + self.interval_units.setCurrentText( + self._normalize_unit(str(self._sub.get("interval_units", self._defaults.interval_units)), INTERVAL_UNITS, "hours") + ) + interval_row = QtWidgets.QHBoxLayout() + interval_row.addWidget(self.interval_spin) + interval_row.addWidget(self.interval_units) + interval_wrap = QtWidgets.QWidget() + interval_wrap.setLayout(interval_row) + settings_form.addRow(QC.translate("stats", "Interval"), interval_wrap) + + self.timeout_spin = QtWidgets.QSpinBox() + self.timeout_spin.setRange(1, 999999) + self.timeout_spin.setValue(max(1, int(self._sub.get("timeout", self._defaults.timeout)))) + self.timeout_units = QtWidgets.QComboBox() + self.timeout_units.addItems(TIMEOUT_UNITS) + self.timeout_units.setCurrentText( + self._normalize_unit(str(self._sub.get("timeout_units", self._defaults.timeout_units)), TIMEOUT_UNITS, "seconds") + ) + timeout_row = QtWidgets.QHBoxLayout() + timeout_row.addWidget(self.timeout_spin) + timeout_row.addWidget(self.timeout_units) + timeout_wrap = QtWidgets.QWidget() + timeout_wrap.setLayout(timeout_row) + settings_form.addRow(QC.translate("stats", "Timeout"), timeout_wrap) + + self.max_size_spin = QtWidgets.QSpinBox() + self.max_size_spin.setRange(1, 999999) + self.max_size_spin.setValue(max(1, int(self._sub.get("max_size", self._defaults.max_size)))) + self.max_size_units = QtWidgets.QComboBox() + self.max_size_units.addItems(SIZE_UNITS) + self.max_size_units.setCurrentText( + self._normalize_unit(str(self._sub.get("max_size_units", self._defaults.max_size_units)), SIZE_UNITS, "MB") + ) + max_row = QtWidgets.QHBoxLayout() + max_row.addWidget(self.max_size_spin) + max_row.addWidget(self.max_size_units) + max_wrap = QtWidgets.QWidget() + max_wrap.setLayout(max_row) + settings_form.addRow(QC.translate("stats", "Max size"), max_wrap) + + body.addWidget(settings_group, 1) + + meta_group = QtWidgets.QGroupBox(QC.translate("stats", "Metadata")) + meta_form = QtWidgets.QFormLayout(meta_group) + self.meta_file_present = QtWidgets.QLabel(str(self._meta.get("file_present", ""))) + self.meta_meta_present = QtWidgets.QLabel(str(self._meta.get("meta_present", ""))) + self.meta_state = QtWidgets.QLabel(str(self._meta.get("state", ""))) + self.meta_last_checked = QtWidgets.QLabel(str(self._meta.get("last_checked", ""))) + self.meta_last_updated = QtWidgets.QLabel(str(self._meta.get("last_updated", ""))) + self.meta_failures = QtWidgets.QLabel(str(self._meta.get("failures", ""))) + self.meta_error = QtWidgets.QLabel(str(self._meta.get("error", ""))) + self.meta_list_path = QtWidgets.QLabel(str(self._meta.get("list_path", ""))) + self.meta_list_path.setWordWrap(True) + self.meta_meta_path = QtWidgets.QLabel(str(self._meta.get("meta_path", ""))) + self.meta_meta_path.setWordWrap(True) + meta_form.addRow(QC.translate("stats", "List file present"), self.meta_file_present) + meta_form.addRow(QC.translate("stats", "List meta present"), self.meta_meta_present) + meta_form.addRow(QC.translate("stats", "State"), self.meta_state) + meta_form.addRow(QC.translate("stats", "Last checked"), self.meta_last_checked) + meta_form.addRow(QC.translate("stats", "Last updated"), self.meta_last_updated) + meta_form.addRow(QC.translate("stats", "Failures"), self.meta_failures) + meta_form.addRow(QC.translate("stats", "Error"), self.meta_error) + meta_form.addRow(QC.translate("stats", "List path"), self.meta_list_path) + meta_form.addRow(QC.translate("stats", "Meta path"), self.meta_meta_path) + body.addWidget(meta_group, 1) + + root.addLayout(body) + + self.error_label = QtWidgets.QLabel("") + self.error_label.setStyleSheet("color: red;") + root.addWidget(self.error_label) + + buttons = QtWidgets.QHBoxLayout() + buttons.addStretch(1) + self.cancel_button = QtWidgets.QPushButton(QC.translate("stats", "Cancel")) + self.add_button = QtWidgets.QPushButton(QC.translate("stats", "Save")) + self.cancel_button.clicked.connect(self.reject) + self.add_button.clicked.connect(self._validate_then_accept) + buttons.addWidget(self.cancel_button) + buttons.addWidget(self.add_button) + root.addLayout(buttons) + + self.resize(920, 420) + + def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str) -> str: + normalized = (value or "").strip().lower() + for unit in allowed: + if unit.lower() == normalized: + return unit + return fallback + + def _validate_then_accept(self) -> None: + url = (self.url_edit.text() or "").strip() + if url == "": + self.error_label.setText(QC.translate("stats", "URL is required.")) + return + groups = normalize_groups(self.group_combo.currentText()) + if not groups: + self.error_label.setText(QC.translate("stats", "At least one group is required.")) + return + self.error_label.setText("") + self.accept() + + def subscription_dict(self) -> dict[str, Any]: + groups = normalize_groups((self.group_combo.currentText() or "all").strip()) + return { + "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()), + "interval_units": self.interval_units.currentText(), + "timeout": int(self.timeout_spin.value()), + "timeout_units": self.timeout_units.currentText(), + "max_size": int(self.max_size_spin.value()), + "max_size_units": self.max_size_units.currentText(), + } + + +class BulkEditDialog(QtWidgets.QDialog): + def __init__( + self, + parent: QtWidgets.QWidget | None, + defaults: GlobalDefaults, + groups: list[str] | None = None, + ) -> None: + super().__init__(parent) + self.setWindowTitle(QC.translate("stats", "Edit selected subscriptions")) + self._defaults = defaults + self._groups = groups or ["all"] + self._build_ui() + + def _build_ui(self) -> None: + root = QtWidgets.QVBoxLayout(self) + form = QtWidgets.QFormLayout() + + self.apply_enabled = QtWidgets.QCheckBox(QC.translate("stats", "Apply enabled")) + self.enabled_value = QtWidgets.QCheckBox(QC.translate("stats", "Enabled")) + self.enabled_value.setChecked(True) + enabled_row = QtWidgets.QHBoxLayout() + enabled_row.addWidget(self.apply_enabled) + enabled_row.addWidget(self.enabled_value) + enabled_wrap = QtWidgets.QWidget() + enabled_wrap.setLayout(enabled_row) + form.addRow(enabled_wrap) + + self.apply_group = QtWidgets.QCheckBox(QC.translate("stats", "Apply groups")) + self.group_value = QtWidgets.QComboBox() + self.group_value.setEditable(True) + for g in self._groups: + ng = normalize_group(g) + if ng != "": + self.group_value.addItem(ng) + if self.group_value.findText("all") < 0: + self.group_value.addItem("all") + self.group_value.setCurrentText("all") + group_row = QtWidgets.QHBoxLayout() + group_row.addWidget(self.apply_group) + group_row.addWidget(self.group_value) + group_wrap = QtWidgets.QWidget() + group_wrap.setLayout(group_row) + form.addRow(QC.translate("stats", "Groups"), group_wrap) + + self.apply_format = QtWidgets.QCheckBox(QC.translate("stats", "Apply format")) + self.format_value = QtWidgets.QComboBox() + self.format_value.addItems(("hosts",)) + format_row = QtWidgets.QHBoxLayout() + format_row.addWidget(self.apply_format) + format_row.addWidget(self.format_value) + format_wrap = QtWidgets.QWidget() + format_wrap.setLayout(format_row) + form.addRow(QC.translate("stats", "Format"), format_wrap) + + self.apply_interval = QtWidgets.QCheckBox(QC.translate("stats", "Apply interval")) + self.interval_spin = QtWidgets.QSpinBox() + self.interval_spin.setRange(1, 999999) + self.interval_spin.setValue(max(1, int(self._defaults.interval))) + self.interval_units = QtWidgets.QComboBox() + self.interval_units.addItems(INTERVAL_UNITS) + self.interval_units.setCurrentText(self._normalize_unit(self._defaults.interval_units, INTERVAL_UNITS, "hours")) + interval_row = QtWidgets.QHBoxLayout() + interval_row.addWidget(self.apply_interval) + interval_row.addWidget(self.interval_spin) + interval_row.addWidget(self.interval_units) + interval_wrap = QtWidgets.QWidget() + interval_wrap.setLayout(interval_row) + form.addRow(QC.translate("stats", "Interval"), interval_wrap) + + self.apply_timeout = QtWidgets.QCheckBox(QC.translate("stats", "Apply timeout")) + self.timeout_spin = QtWidgets.QSpinBox() + self.timeout_spin.setRange(1, 999999) + self.timeout_spin.setValue(max(1, int(self._defaults.timeout))) + self.timeout_units = QtWidgets.QComboBox() + self.timeout_units.addItems(TIMEOUT_UNITS) + self.timeout_units.setCurrentText(self._normalize_unit(self._defaults.timeout_units, TIMEOUT_UNITS, "seconds")) + timeout_row = QtWidgets.QHBoxLayout() + timeout_row.addWidget(self.apply_timeout) + timeout_row.addWidget(self.timeout_spin) + timeout_row.addWidget(self.timeout_units) + timeout_wrap = QtWidgets.QWidget() + timeout_wrap.setLayout(timeout_row) + form.addRow(QC.translate("stats", "Timeout"), timeout_wrap) + + self.apply_max_size = QtWidgets.QCheckBox(QC.translate("stats", "Apply max size")) + self.max_size_spin = QtWidgets.QSpinBox() + self.max_size_spin.setRange(1, 999999) + self.max_size_spin.setValue(max(1, int(self._defaults.max_size))) + self.max_size_units = QtWidgets.QComboBox() + self.max_size_units.addItems(SIZE_UNITS) + self.max_size_units.setCurrentText(self._normalize_unit(self._defaults.max_size_units, SIZE_UNITS, "MB")) + max_row = QtWidgets.QHBoxLayout() + max_row.addWidget(self.apply_max_size) + max_row.addWidget(self.max_size_spin) + max_row.addWidget(self.max_size_units) + max_wrap = QtWidgets.QWidget() + max_wrap.setLayout(max_row) + form.addRow(QC.translate("stats", "Max size"), max_wrap) + + root.addLayout(form) + self.error_label = QtWidgets.QLabel("") + self.error_label.setStyleSheet("color: red;") + root.addWidget(self.error_label) + + buttons = QtWidgets.QHBoxLayout() + buttons.addStretch(1) + cancel_btn = QtWidgets.QPushButton(QC.translate("stats", "Cancel")) + save_btn = QtWidgets.QPushButton(QC.translate("stats", "Apply")) + cancel_btn.clicked.connect(self.reject) + save_btn.clicked.connect(self._validate_then_accept) + buttons.addWidget(cancel_btn) + buttons.addWidget(save_btn) + root.addLayout(buttons) + self.resize(640, 360) + + def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str) -> str: + normalized = (value or "").strip().lower() + for unit in allowed: + if unit.lower() == normalized: + return unit + return fallback + + def _validate_then_accept(self) -> None: + if not any( + ( + self.apply_enabled.isChecked(), + self.apply_group.isChecked(), + self.apply_format.isChecked(), + self.apply_interval.isChecked(), + self.apply_timeout.isChecked(), + self.apply_max_size.isChecked(), + ) + ): + self.error_label.setText(QC.translate("stats", "Select at least one field to apply.")) + return + self.error_label.setText("") + self.accept() + + def values(self) -> dict[str, Any]: + return { + "enabled": self.enabled_value.isChecked() if self.apply_enabled.isChecked() else None, + "groups": normalize_groups(self.group_value.currentText()) if self.apply_group.isChecked() else None, + "format": (self.format_value.currentText() or "hosts").strip().lower() if self.apply_format.isChecked() else None, + "interval": int(self.interval_spin.value()) if self.apply_interval.isChecked() else None, + "interval_units": self.interval_units.currentText() if self.apply_interval.isChecked() else None, + "timeout": int(self.timeout_spin.value()) if self.apply_timeout.isChecked() else None, + "timeout_units": self.timeout_units.currentText() if self.apply_timeout.isChecked() else None, + "max_size": int(self.max_size_spin.value()) if self.apply_max_size.isChecked() else None, + "max_size_units": self.max_size_units.currentText() if self.apply_max_size.isChecked() else None, + } + + +class ListSubscriptionsDialog(QtWidgets.QDialog): + _download_finished = QtCore.pyqtSignal() + + def __init__( + self, + parent: QtWidgets.QWidget | None = None, + appicon: QtGui.QIcon | None = 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: Any = None + self._state_poll_timer = QtCore.QTimer(self) + self._state_poll_timer.setInterval(2000) + self._state_poll_timer.timeout.connect(self._refresh_states_if_visible) + self._download_finished.connect(self.refresh_states) + self._build_ui() + + def showEvent(self, event: QtGui.QShowEvent) -> None: # type: ignore + super().showEvent(event) + self.load_action_file() + if not self._state_poll_timer.isActive(): + self._state_poll_timer.start() + + def hideEvent(self, event: QtGui.QHideEvent) -> None: # type: ignore + if self._state_poll_timer.isActive(): + self._state_poll_timer.stop() + super().hideEvent(event) + + def closeEvent(self, event: QtGui.QCloseEvent) -> None: # type: ignore + if self._state_poll_timer.isActive(): + self._state_poll_timer.stop() + super().closeEvent(event) + + def _build_ui(self) -> None: + self.resize(1180, 680) + root = QtWidgets.QVBoxLayout(self) + + top_row = QtWidgets.QHBoxLayout() + self.enable_plugin_check = QtWidgets.QCheckBox(QC.translate("stats", "Enable list subscriptions plugin")) + self.create_file_button = QtWidgets.QPushButton(QC.translate("stats", "Create action file")) + self.save_button = QtWidgets.QPushButton(QC.translate("stats", "Save")) + self.reload_button = QtWidgets.QPushButton(QC.translate("stats", "Reload")) + top_row.addWidget(self.enable_plugin_check) + top_row.addStretch(1) + top_row.addWidget(self.create_file_button) + top_row.addWidget(self.save_button) + top_row.addWidget(self.reload_button) + root.addLayout(top_row) + + defaults_row = QtWidgets.QGridLayout() + defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Lists directory")), 0, 0) + self.lists_dir_edit = QtWidgets.QLineEdit() + defaults_row.addWidget(self.lists_dir_edit, 0, 1, 1, 5) + + defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Default interval")), 1, 0) + self.default_interval_spin = QtWidgets.QSpinBox() + self.default_interval_spin.setRange(1, 999999) + defaults_row.addWidget(self.default_interval_spin, 1, 1) + self.default_interval_units = QtWidgets.QComboBox() + self.default_interval_units.addItems(INTERVAL_UNITS) + defaults_row.addWidget(self.default_interval_units, 1, 2) + + defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Default timeout")), 1, 3) + self.default_timeout_spin = QtWidgets.QSpinBox() + self.default_timeout_spin.setRange(1, 999999) + defaults_row.addWidget(self.default_timeout_spin, 1, 4) + self.default_timeout_units = QtWidgets.QComboBox() + self.default_timeout_units.addItems(TIMEOUT_UNITS) + defaults_row.addWidget(self.default_timeout_units, 1, 5) + + defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Default max size")), 2, 0) + self.default_max_size_spin = QtWidgets.QSpinBox() + self.default_max_size_spin.setRange(1, 999999) + defaults_row.addWidget(self.default_max_size_spin, 2, 1) + self.default_max_size_units = QtWidgets.QComboBox() + self.default_max_size_units.addItems(SIZE_UNITS) + defaults_row.addWidget(self.default_max_size_units, 2, 2) + + defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Default User-Agent")), 2, 3) + self.default_user_agent = QtWidgets.QLineEdit() + defaults_row.addWidget(self.default_user_agent, 2, 4, 1, 2) + + defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Node")), 3, 0) + self.nodes_combo = QtWidgets.QComboBox() + defaults_row.addWidget(self.nodes_combo, 3, 1, 1, 2) + root.addLayout(defaults_row) + + self.table = QtWidgets.QTableWidget() + self.table.setColumnCount(19) + self.table.setHorizontalHeaderLabels([ + QC.translate("stats", "Enabled"), + 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", "Last checked"), + QC.translate("stats", "Last updated"), + QC.translate("stats", "Failures"), + QC.translate("stats", "Error"), + ]) + self.table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) + self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) + self.table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + header = self.table.horizontalHeader() + if header is not None: + header.setStretchLastSection(True) + header.setSectionResizeMode(COL_URL, QtWidgets.QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(COL_ERROR, QtWidgets.QHeaderView.ResizeMode.Stretch) + # Keep advanced tuning + verbose metadata available internally but + # reduce visible table complexity; edit dialog exposes full details. + for col in ( + COL_INTERVAL, + COL_INTERVAL_UNITS, + COL_TIMEOUT, + COL_TIMEOUT_UNITS, + COL_MAX_SIZE, + COL_MAX_SIZE_UNITS, + COL_FILE, + COL_META, + COL_FAILS, + COL_ERROR, + ): + self.table.setColumnHidden(col, True) + root.addWidget(self.table) + + actions_row = QtWidgets.QHBoxLayout() + + global_box = QtWidgets.QGroupBox(QC.translate("stats", "Global actions")) + global_layout = QtWidgets.QHBoxLayout(global_box) + self.add_sub_button = QtWidgets.QPushButton(QC.translate("stats", "Add subscription")) + self.refresh_state_button = QtWidgets.QPushButton(QC.translate("stats", "Refresh all")) + self.create_global_rule_button = QtWidgets.QPushButton(QC.translate("stats", "Create global rule")) + global_layout.addWidget(self.add_sub_button) + global_layout.addWidget(self.refresh_state_button) + global_layout.addWidget(self.create_global_rule_button) + global_layout.addStretch(1) + + selected_box = QtWidgets.QGroupBox(QC.translate("stats", "Rule actions")) + selected_layout = QtWidgets.QHBoxLayout(selected_box) + self.edit_sub_button = QtWidgets.QPushButton(QC.translate("stats", "Edit")) + self.remove_sub_button = QtWidgets.QPushButton(QC.translate("stats", "Remove")) + self.refresh_now_button = QtWidgets.QPushButton(QC.translate("stats", "Refresh now")) + self.create_rule_button = QtWidgets.QPushButton(QC.translate("stats", "Create rule")) + selected_layout.addWidget(self.edit_sub_button) + selected_layout.addWidget(self.remove_sub_button) + selected_layout.addWidget(self.refresh_now_button) + selected_layout.addWidget(self.create_rule_button) + selected_layout.addStretch(1) + + actions_row.addWidget(global_box, 1) + actions_row.addWidget(selected_box, 2) + root.addLayout(actions_row) + + self.status_label = QtWidgets.QLabel("") + root.addWidget(self.status_label) + + self.create_file_button.clicked.connect(self.create_action_file) + self.save_button.clicked.connect(self.save_action_file) + self.reload_button.clicked.connect(self.load_action_file) + self.add_sub_button.clicked.connect(self.add_subscription_row) + self.create_global_rule_button.clicked.connect(self.create_global_rule) + self.edit_sub_button.clicked.connect(self.edit_action_clicked) + self.remove_sub_button.clicked.connect(self.remove_selected_subscription) + self.refresh_state_button.clicked.connect(self.refresh_all_now) + self.refresh_now_button.clicked.connect(self.refresh_selected_now) + self.create_rule_button.clicked.connect(self.create_rule_from_selected) + self.table.itemDoubleClicked.connect(lambda *_: self.edit_selected_subscription()) + self.table.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) + self.table.customContextMenuRequested.connect(self._open_table_context_menu) + sel_model = self.table.selectionModel() + if sel_model is not None: + sel_model.selectionChanged.connect(lambda *_: self._update_selected_actions_state()) + self._update_selected_actions_state() + + def load_action_file(self) -> None: + self._loading = True + self._set_status("") + self._reload_nodes() + self.table.setRowCount(0) + self.create_file_button.setVisible(True) + self.lists_dir_edit.setText(DEFAULT_LISTS_DIR) + self.enable_plugin_check.setChecked(False) + self._global_defaults = GlobalDefaults.from_dict({}, lists_dir=DEFAULT_LISTS_DIR) + self._apply_defaults_to_widgets() + + if not os.path.exists(self._action_path): + self._set_status(QC.translate("stats", "Action file not found. Click 'Create action file'."), error=False) + self._loading = False + return + + try: + with open(self._action_path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception as e: + self._set_status(QC.translate("stats", "Error reading action file: {0}").format(str(e)), error=True) + self._loading = False + return + + action_cfg = data.get("actions", {}).get("list_subscriptions", {}) + plugin_cfg = action_cfg.get("config", {}) + subscriptions = plugin_cfg.get("subscriptions", []) + self._global_defaults = GlobalDefaults.from_dict(plugin_cfg, lists_dir=plugin_cfg.get("lists_dir")) + + self.enable_plugin_check.setChecked(bool(action_cfg.get("enabled", False))) + self.lists_dir_edit.setText(normalize_lists_dir(self._global_defaults.lists_dir)) + self._apply_defaults_to_widgets() + + normalized_subs, fixed_count, migrated_legacy_group = self._normalize_loaded_subscriptions(subscriptions) + for sub in normalized_subs: + self._append_row(sub) + + self._loading = False + self.refresh_states() + self._update_selected_actions_state() + self.create_file_button.setVisible(False) + if migrated_legacy_group: + self.save_action_file() + self._set_status( + QC.translate("stats", "Migrated legacy 'group' entries to 'groups' and auto-saved configuration."), + error=False, + ) + return + if fixed_count > 0: + self._set_status( + QC.translate("stats", "Loaded configuration with {0} auto-corrected subscription field(s).").format(fixed_count), + error=False, + ) + else: + self._set_status(QC.translate("stats", "List subscriptions configuration loaded."), error=False) + + def create_action_file(self) -> None: + try: + os.makedirs(os.path.dirname(self._action_path), mode=0o700, exist_ok=True) + if not os.path.exists(self._action_path): + with open(self._action_path, "w", encoding="utf-8") as f: + json.dump(_template_action(), f, indent=2) + self.load_action_file() + self._set_status(QC.translate("stats", "Action file created."), error=False) + except Exception as e: + self._set_status(QC.translate("stats", "Error creating action file: {0}").format(str(e)), error=True) + + def save_action_file(self) -> None: + if self._loading: + return + + if not os.path.exists(self._action_path): + self.create_action_file() + if not os.path.exists(self._action_path): + return + + subscriptions = self._collect_subscriptions() + if subscriptions is None: + return + + action = _template_action() + action["actions"]["list_subscriptions"]["enabled"] = self.enable_plugin_check.isChecked() + lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) + try: + os.makedirs(lists_dir, mode=0o700, exist_ok=True) + except Exception: + pass + action["actions"]["list_subscriptions"]["config"]["lists_dir"] = lists_dir + action["actions"]["list_subscriptions"]["config"]["interval"] = int(self.default_interval_spin.value()) + action["actions"]["list_subscriptions"]["config"]["interval_units"] = self.default_interval_units.currentText() + action["actions"]["list_subscriptions"]["config"]["timeout"] = int(self.default_timeout_spin.value()) + action["actions"]["list_subscriptions"]["config"]["timeout_units"] = self.default_timeout_units.currentText() + action["actions"]["list_subscriptions"]["config"]["max_size"] = int(self.default_max_size_spin.value()) + action["actions"]["list_subscriptions"]["config"]["max_size_units"] = self.default_max_size_units.currentText() + action["actions"]["list_subscriptions"]["config"]["user_agent"] = self.default_user_agent.text().strip() + action["actions"]["list_subscriptions"]["config"]["subscriptions"] = subscriptions + action["updated"] = datetime.now().astimezone().isoformat() + + cfg = action["actions"]["list_subscriptions"]["config"] + compiled_cfg = PluginConfig.from_dict(cfg, lists_dir=cfg.get("lists_dir")) + if len(compiled_cfg.subscriptions) != len(subscriptions): + self._set_status(QC.translate("stats", "Invalid subscriptions: URL and filename are mandatory."), error=True) + return + + tmp_path = self._action_path + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(action, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, self._action_path) + except Exception as e: + self._set_status(QC.translate("stats", "Error saving action file: {0}").format(str(e)), error=True) + return + + self._apply_runtime_state(action["actions"]["list_subscriptions"]["enabled"]) + self.refresh_states() + self._set_status(QC.translate("stats", "List subscriptions configuration saved."), error=False) + + def refresh_states(self) -> None: + lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) + for row in range(self.table.rowCount()): + filename_item = self.table.item(row, COL_FILENAME) + enabled_item = self.table.item(row, COL_ENABLED) + if filename_item is None or enabled_item is None: + continue + + filename = self._safe_filename(filename_item.text()) + list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() + enabled = enabled_item.checkState() == QtCore.Qt.CheckState.Checked + list_path = self._list_file_path(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 = {} + 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 "" + + if not enabled: + state = "disabled" + color = QtGui.QColor("lightgray") + elif not file_exists: + # New/manual subscriptions may not be downloaded yet. + # Expose that as pending instead of an error-like missing state. + if not meta_exists or last_result in ("never", "", "busy"): + state = "pending" + color = QtGui.QColor("khaki") + else: + state = "missing" + color = QtGui.QColor("tomato") + elif last_result in ("updated", "not_modified"): + state = last_result + color = QtGui.QColor("lightgreen") + elif last_result in ("error", "write_error", "request_error", "unexpected_error", "bad_format", "too_large"): + state = last_result + color = QtGui.QColor("salmon") + elif last_result == "busy": + state = "busy" + color = QtGui.QColor("khaki") + else: + state = last_result + color = QtGui.QColor("lightyellow") + + self._set_text_item(row, COL_FILE, "yes" if file_exists else "no", editable=False) + self._set_text_item(row, COL_META, "yes" if meta_exists else "no", editable=False) + self._set_text_item(row, COL_STATE, state, editable=False) + self._set_text_item(row, COL_LAST_CHECKED, last_checked, editable=False) + self._set_text_item(row, COL_LAST_UPDATED, last_updated, editable=False) + self._set_text_item(row, COL_FAILS, fail_count, editable=False) + self._set_text_item(row, COL_ERROR, last_error, editable=False) + + for col in (COL_FILE, COL_META, COL_STATE, COL_LAST_CHECKED, COL_LAST_UPDATED, COL_FAILS, COL_ERROR): + item = self.table.item(row, col) + if item is not None: + item.setBackground(color) + + def add_subscription_row(self) -> None: + dlg = SubscriptionDialog( + self, + self._global_defaults, + groups=self._known_groups(), + sub={ + "enabled": True, + "name": "", + "url": "", + "filename": "", + "format": "hosts", + "groups": ["all"], + "interval": self._global_defaults.interval, + "interval_units": self._global_defaults.interval_units, + "timeout": self._global_defaults.timeout, + "timeout_units": self._global_defaults.timeout_units, + "max_size": self._global_defaults.max_size, + "max_size_units": self._global_defaults.max_size_units, + }, + title="New subscription", + ) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + + sub = dlg.subscription_dict() + self._append_row(sub) + row = self.table.rowCount() - 1 + _, changed = self._ensure_row_final_filename(row) + if changed: + self.refresh_states() + + if not os.path.exists(self._action_path): + self.create_action_file() + self.save_action_file() + self._update_selected_actions_state() + + def edit_selected_subscription(self) -> None: + row = self.table.currentRow() + if row < 0: + self._set_status(QC.translate("stats", "Select a subscription row first."), error=True) + return + + sub = { + "enabled": self.table.item(row, COL_ENABLED) is not None + and self.table.item(row, COL_ENABLED).checkState() == QtCore.Qt.CheckState.Checked, + "name": self._cell_text(row, COL_NAME), + "url": self._cell_text(row, COL_URL), + "filename": self._cell_text(row, COL_FILENAME), + "format": self._cell_text(row, COL_FORMAT) or "hosts", + "groups": normalize_groups(self._cell_text(row, COL_GROUP) or "all"), + "interval": self._to_int_or_keep(self._cell_text(row, COL_INTERVAL)), + "interval_units": self._cell_text(row, COL_INTERVAL_UNITS) or self._global_defaults.interval_units, + "timeout": self._to_int_or_keep(self._cell_text(row, COL_TIMEOUT)), + "timeout_units": self._cell_text(row, COL_TIMEOUT_UNITS) or self._global_defaults.timeout_units, + "max_size": self._to_int_or_keep(self._cell_text(row, COL_MAX_SIZE)), + "max_size_units": self._cell_text(row, COL_MAX_SIZE_UNITS) or self._global_defaults.max_size_units, + } + meta = self._row_meta_snapshot(row) + dlg = SubscriptionDialog( + self, + self._global_defaults, + groups=self._known_groups(), + sub=sub, + meta=meta, + title="Edit subscription", + ) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + updated = dlg.subscription_dict() + + enabled_item = self.table.item(row, COL_ENABLED) + if enabled_item is None: + enabled_item = QtWidgets.QTableWidgetItem("") + enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + self.table.setItem(row, COL_ENABLED, enabled_item) + enabled_item.setCheckState( + QtCore.Qt.CheckState.Checked if bool(updated.get("enabled", True)) else QtCore.Qt.CheckState.Unchecked + ) + self._set_text_item(row, COL_NAME, str(updated.get("name", ""))) + self._set_text_item(row, COL_URL, str(updated.get("url", ""))) + self._set_text_item(row, COL_FILENAME, self._safe_filename(updated.get("filename", ""))) + self._set_text_item(row, COL_FORMAT, str(updated.get("format", "hosts"))) + self._set_text_item(row, COL_GROUP, ", ".join(normalize_groups(updated.get("groups")))) + self._set_text_item(row, COL_INTERVAL, self._to_str(updated.get("interval", self._global_defaults.interval))) + interval_units_val = self._to_str(updated.get("interval_units", self._global_defaults.interval_units)) + self._set_text_item(row, COL_INTERVAL_UNITS, interval_units_val) + self._set_text_item(row, COL_TIMEOUT, self._to_str(updated.get("timeout", self._global_defaults.timeout))) + timeout_units_val = self._to_str(updated.get("timeout_units", self._global_defaults.timeout_units)) + self._set_text_item(row, COL_TIMEOUT_UNITS, timeout_units_val) + self._set_text_item(row, COL_MAX_SIZE, self._to_str(updated.get("max_size", self._global_defaults.max_size))) + max_size_units_val = self._to_str(updated.get("max_size_units", self._global_defaults.max_size_units)) + self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units_val) + self._set_units_combo(row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units_val) + self._set_units_combo(row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units_val) + self._set_units_combo(row, COL_MAX_SIZE_UNITS, SIZE_UNITS, max_size_units_val) + + _, changed = self._ensure_row_final_filename(row) + self.save_action_file() + self.refresh_states() + if changed: + self._set_status(QC.translate("stats", "Subscription updated and filename normalized."), error=False) + else: + self._set_status(QC.translate("stats", "Subscription updated."), error=False) + + def edit_action_clicked(self) -> None: + rows = self._selected_rows() + if len(rows) == 0: + self._set_status(QC.translate("stats", "Select one or more subscriptions first."), error=True) + return + if len(rows) == 1: + self.edit_selected_subscription() + return + self._bulk_edit(rows) + + def remove_selected_subscription(self) -> None: + rows = self._selected_rows() + if not rows: + row = self.table.currentRow() + if row >= 0: + rows = [row] + if not rows: + self._set_status(QC.translate("stats", "Select one or more subscription rows first."), error=True) + return + for row in sorted(rows, reverse=True): + self.table.removeRow(row) + self.save_action_file() + self.refresh_states() + self._update_selected_actions_state() + self._set_status(QC.translate("stats", "Selected subscriptions removed."), error=False) + + def _selected_rows(self) -> list[int]: + idx = self.table.selectionModel() + if idx is None: + return [] + return sorted({i.row() for i in idx.selectedRows()}) + + def _update_selected_actions_state(self) -> None: + count = len(self._selected_rows()) + has_selection = count > 0 + single = count == 1 + self.edit_sub_button.setEnabled(has_selection) + self.remove_sub_button.setEnabled(has_selection) + self.refresh_now_button.setEnabled(single) + self.create_rule_button.setEnabled(has_selection) + + def _open_table_context_menu(self, pos: QtCore.QPoint) -> None: + rows = self._selected_rows() + if not rows: + row = self.table.rowAt(pos.y()) + if row >= 0: + self.table.selectRow(row) + rows = [row] + if not rows: + return + + menu = QtWidgets.QMenu(self.table) + if len(rows) == 1: + act_edit = menu.addAction(QC.translate("stats", "Edit")) + act_remove = menu.addAction(QC.translate("stats", "Remove")) + act_refresh = menu.addAction(QC.translate("stats", "Refresh now")) + act_rule = menu.addAction(QC.translate("stats", "Create rule")) + chosen = menu.exec(self.table.viewport().mapToGlobal(pos)) + if chosen is act_edit: + self.edit_selected_subscription() + elif chosen is act_remove: + self.remove_selected_subscription() + elif chosen is act_refresh: + self.refresh_selected_now() + elif chosen is act_rule: + self.create_rule_from_selected() + return + + act_edit = menu.addAction(QC.translate("stats", "Edit")) + act_remove = menu.addAction(QC.translate("stats", "Remove")) + act_rule = menu.addAction(QC.translate("stats", "Create rule")) + chosen = menu.exec(self.table.viewport().mapToGlobal(pos)) + if chosen is act_edit: + self._bulk_edit(rows) + elif chosen is act_remove: + self.remove_selected_subscription() + elif chosen is act_rule: + self.create_rule_from_selected() + + def _bulk_edit(self, rows: list[int]) -> None: + if not rows: + return + dlg = BulkEditDialog(self, self._global_defaults, groups=self._known_groups()) + if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: + return + values = dlg.values() + for row in rows: + if values.get("enabled") is not None: + enabled_item = self.table.item(row, COL_ENABLED) + if enabled_item is None: + enabled_item = QtWidgets.QTableWidgetItem("") + enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + self.table.setItem(row, 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._set_text_item(row, COL_GROUP, ", ".join(normalize_groups(values["groups"]))) + if values.get("format") is not None: + self._set_text_item(row, COL_FORMAT, str(values["format"])) + if values.get("interval") is not None: + self._set_text_item(row, COL_INTERVAL, str(values["interval"])) + if values.get("interval_units") is not None: + self._set_text_item(row, COL_INTERVAL_UNITS, str(values["interval_units"])) + self._set_units_combo(row, COL_INTERVAL_UNITS, INTERVAL_UNITS, str(values["interval_units"])) + if values.get("timeout") is not None: + self._set_text_item(row, COL_TIMEOUT, str(values["timeout"])) + if values.get("timeout_units") is not None: + self._set_text_item(row, COL_TIMEOUT_UNITS, str(values["timeout_units"])) + self._set_units_combo(row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, str(values["timeout_units"])) + if values.get("max_size") is not None: + self._set_text_item(row, COL_MAX_SIZE, str(values["max_size"])) + if values.get("max_size_units") is not None: + self._set_text_item(row, COL_MAX_SIZE_UNITS, str(values["max_size_units"])) + self._set_units_combo(row, COL_MAX_SIZE_UNITS, SIZE_UNITS, str(values["max_size_units"])) + self._ensure_row_final_filename(row) + self.save_action_file() + self.refresh_states() + self._set_status( + QC.translate("stats", "Updated {0} selected subscriptions.").format(len(rows)), + error=False, + ) + + def _known_groups(self) -> list[str]: + groups: set[str] = {"all"} + for row in range(self.table.rowCount()): + for g in normalize_groups(self._cell_text(row, COL_GROUP) or "all"): + if g != "": + groups.add(g) + return sorted(groups) + + def refresh_selected_now(self) -> None: + row = self.table.currentRow() + if row < 0: + self._set_status(QC.translate("stats", "Select a subscription row first."), error=True) + return + + url = self._cell_text(row, COL_URL) + filename, filename_changed = self._ensure_row_final_filename(row) + if url == "" or filename == "": + self._set_status(QC.translate("stats", "URL and filename cannot be empty."), error=True) + return + if filename_changed: + # Persist the resolved filename to action/config immediately. + self.save_action_file() + + _, _, plug = self._find_loaded_action() + if plug is None: + self._set_status(QC.translate("stats", "Plugin is not loaded. Save configuration first."), error=True) + return + + target_sub = None + try: + for sub in plug._config.subscriptions: + if sub.url == url and sub.filename == filename: + target_sub = sub + break + except Exception: + target_sub = None + + if target_sub is None: + try: + row_sub = SubscriptionSpec.from_dict( + { + "enabled": True, + "name": self._cell_text(row, COL_NAME), + "url": url, + "filename": filename, + "format": self._cell_text(row, COL_FORMAT) or "hosts", + "groups": normalize_groups(self._cell_text(row, COL_GROUP) or "all"), + "interval": self._to_int_or_keep(self._cell_text(row, COL_INTERVAL)), + "interval_units": self._cell_text(row, COL_INTERVAL_UNITS), + "timeout": self._to_int_or_keep(self._cell_text(row, COL_TIMEOUT)), + "timeout_units": self._cell_text(row, COL_TIMEOUT_UNITS), + "max_size": self._to_int_or_keep(self._cell_text(row, COL_MAX_SIZE)), + "max_size_units": self._cell_text(row, COL_MAX_SIZE_UNITS), + }, + plug._config.defaults, + ) + except Exception: + row_sub = None + if row_sub is None: + self._set_status( + QC.translate("stats", "Subscription not found in runtime config. Save first, then retry."), + error=True, + ) + return + target_sub = row_sub + + key = plug._sub_key(target_sub) + list_path, _ = plug._paths(target_sub) + + def _run_refresh() -> None: + try: + logger.warning( + "list_subscriptions.gui: manual refresh start key=%s name='%s' url='%s' file='%s'", + key, target_sub.name, target_sub.url, target_sub.filename + ) + if hasattr(plug, "force_refresh_subscription"): + plug.force_refresh_subscription(target_sub) + else: + # fallback for older plugin objects + plug.download(key, target_sub) + finally: + logger.warning("list_subscriptions.gui: manual refresh finished key=%s", key) + self._download_finished.emit() + + th = threading.Thread(target=_run_refresh, daemon=True) + th.start() + self._set_status( + QC.translate("stats", "Subscription refresh triggered. Destination: {0}").format(list_path), + error=False, + ) + + def refresh_all_now(self) -> None: + _, _, plug = self._find_loaded_action() + if plug is None: + self._set_status(QC.translate("stats", "Plugin is not loaded. Save configuration first."), error=True) + return + + def _run_all_refresh() -> None: + try: + subs = [] + try: + subs = list(getattr(plug._config, "subscriptions", [])) + except Exception: + subs = [] + for sub in subs: + if not getattr(sub, "enabled", True): + continue + try: + if hasattr(plug, "force_refresh_subscription"): + plug.force_refresh_subscription(sub) + else: + key = plug._sub_key(sub) + plug.download(key, sub) + except Exception: + continue + finally: + self._download_finished.emit() + + th = threading.Thread(target=_run_all_refresh, daemon=True) + th.start() + self._set_status(QC.translate("stats", "Bulk refresh triggered for all enabled subscriptions."), error=False) + + def create_rule_from_selected(self) -> None: + rows = self._selected_rows() + if not rows: + row = self.table.currentRow() + if row >= 0: + rows = [row] + if not rows: + self._set_status(QC.translate("stats", "Select one or more subscriptions first."), error=True) + return + + lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) + if len(rows) == 1: + row = rows[0] + url = self._cell_text(row, COL_URL) + filename, filename_changed = self._ensure_row_final_filename(row) + if url == "" or filename == "": + self._set_status(QC.translate("stats", "URL and filename cannot be empty."), error=True) + return + if filename_changed: + # Persist resolved filename so subsequent plugin runs keep the same path. + self.save_action_file() + + name = self._cell_text(row, COL_NAME) or filename + list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() + list_path = self._list_file_path(lists_dir, filename, list_type) + rule_dir = self._prepare_rule_dir(url, filename, list_path, lists_dir) + if rule_dir is None: + return + desc = f"From list subscription: {name}" + 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.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._set_status(QC.translate("stats", "Error preparing grouped rule directory: {0}").format(str(e)), error=True) + return + desc = f"From list subscriptions group: {rule_group}" + + try: + from opensnitch.dialogs.ruleseditor import RulesEditorDialog + except Exception as e: + self._set_status(QC.translate("stats", "Unable to open Rules Editor: {0}").format(str(e)), error=True) + return + + if self._rules_dialog is None: + appicon = self.windowIcon() if self.windowIcon() is not None else None + try: + self._rules_dialog = RulesEditorDialog(parent=None, modal=False, appicon=appicon) + except TypeError: + try: + self._rules_dialog = RulesEditorDialog(parent=None, appicon=appicon) + except TypeError: + self._rules_dialog = RulesEditorDialog() + + self._rules_dialog.new_rule() + # Rules editor expects a directory containing one or more hosts files. + self._rules_dialog.dstListsCheck.setChecked(True) + self._rules_dialog.dstListsLine.setText(rule_dir) + if self._rules_dialog.ruleDescEdit.toPlainText().strip() == "": + self._rules_dialog.ruleDescEdit.setPlainText(desc) + self._rules_dialog.raise_() + self._rules_dialog.activateWindow() + self._set_status(QC.translate("stats", "Rules Editor opened with prefilled list directory path."), error=False) + + def create_global_rule(self) -> None: + lists_dir = normalize_lists_dir(self.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._set_status(QC.translate("stats", "Error preparing global rule directory: {0}").format(str(e)), error=True) + return + + try: + from opensnitch.dialogs.ruleseditor import RulesEditorDialog + except Exception as e: + self._set_status(QC.translate("stats", "Unable to open Rules Editor: {0}").format(str(e)), error=True) + return + + if self._rules_dialog is None: + appicon = self.windowIcon() if self.windowIcon() is not None else None + try: + self._rules_dialog = RulesEditorDialog(parent=None, modal=False, appicon=appicon) + except TypeError: + try: + self._rules_dialog = RulesEditorDialog(parent=None, appicon=appicon) + except TypeError: + self._rules_dialog = RulesEditorDialog() + + self._rules_dialog.new_rule() + self._rules_dialog.dstListsCheck.setChecked(True) + self._rules_dialog.dstListsLine.setText(rule_dir) + if self._rules_dialog.ruleDescEdit.toPlainText().strip() == "": + self._rules_dialog.ruleDescEdit.setPlainText("From list subscriptions group: all") + self._rules_dialog.raise_() + self._rules_dialog.activateWindow() + self._set_status(QC.translate("stats", "Rules Editor opened with global list directory path."), error=False) + + def _choose_group_for_selected(self, rows: list[int]) -> str | None: + if not rows: + return None + selected_group_sets = [set(normalize_groups(self._cell_text(r, COL_GROUP) or "all")) for r in rows] + common = set.intersection(*selected_group_sets) if selected_group_sets else {"all"} + known = self._known_groups() + default_group = "all" + if common: + default_group = sorted(common)[0] + if default_group not in known: + known.append(default_group) + known = sorted(set(known)) + try: + default_idx = known.index(default_group) + except ValueError: + default_idx = 0 + value, ok = QtWidgets.QInputDialog.getItem( + self, + 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 == "": + self._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) -> bool: + if not rows: + return False + target_group = normalize_group(group) + for row in rows: + groups = normalize_groups(self._cell_text(row, COL_GROUP) or "all") + groups.append(target_group) + groups = normalize_groups(groups) + self._set_text_item(row, COL_GROUP, ", ".join(groups)) + return True + + def _prepare_rule_dir(self, url: str, filename: str, list_path: str, lists_dir: str) -> str | None: + _ = (url, filename, lists_dir) + rule_dir = os.path.dirname(list_path) + # Rules should point to the directory that already contains the + # subscription list file. Do not rewrite/copy/symlink the file here. + try: + os.makedirs(rule_dir, mode=0o700, exist_ok=True) + return rule_dir + except Exception as e: + self._set_status(QC.translate("stats", "Error preparing list rule directory: {0}").format(str(e)), error=True) + return None + + def _list_file_path(self, lists_dir: str, filename: str, list_type: str) -> str: + safe_name = self._safe_filename(filename) + if safe_name == "": + safe_name = "subscription.list" + safe_name = ensure_filename_type_suffix(safe_name, list_type) + base, _ext = os.path.splitext(safe_name) + suffix = f"-{(list_type or 'hosts').strip().lower()}" + sub_dirname = base if base else "subscription" + if not sub_dirname.lower().endswith(suffix): + sub_dirname = f"{sub_dirname}{suffix}" + return os.path.join(lists_dir, "sources.list.d", sub_dirname, safe_name) + + def _normalize_loaded_subscriptions(self, subscriptions: Any) -> tuple[list[dict[str, Any]], int, bool]: + out: list[dict[str, Any]] = [] + fixed_count = 0 + migrated_legacy_group = False + seen: dict[str, int] = {} + if not isinstance(subscriptions, list): + return out, fixed_count, migrated_legacy_group + + for idx, raw in enumerate(subscriptions): + if not isinstance(raw, dict): + continue + sub = dict(raw) + url = (str(sub.get("url", "")) or "").strip() + name = (str(sub.get("name", "")) or "").strip() + list_type = (str(sub.get("format", "hosts")) or "hosts").strip().lower() + had_legacy_group = ("groups" not in sub) and ("group" in sub) + groups = normalize_groups(sub.get("groups", [sub.get("group")] if had_legacy_group else None)) + filename = self._safe_filename(sub.get("filename", "")) + + if filename == "": + filename = self._guess_filename(name, url) + sub["filename"] = filename + fixed_count += 1 + typed_filename = ensure_filename_type_suffix(filename, list_type) + if typed_filename != filename: + filename = typed_filename + sub["filename"] = filename + fixed_count += 1 + + if name == "": + if filename != "": + name = filename + elif url != "": + name = self._filename_from_url(url) or f"subscription-{idx + 1}" + else: + name = f"subscription-{idx + 1}" + sub["name"] = name + fixed_count += 1 + if sub.get("groups") != groups: + sub["groups"] = groups + fixed_count += 1 + if had_legacy_group: + migrated_legacy_group = True + + key = os.path.normcase(filename) + if filename != "": + if key in seen: + base, ext = os.path.splitext(filename) + n = 2 + candidate = filename + while os.path.normcase(candidate) in seen: + suffix = f"-{n}" + candidate = f"{base}{suffix}{ext}" if ext else f"{base}{suffix}" + n += 1 + sub["filename"] = candidate + filename = candidate + key = os.path.normcase(filename) + fixed_count += 1 + seen[key] = idx + + out.append(sub) + + return out, fixed_count, migrated_legacy_group + + def _apply_runtime_state(self, enabled: bool) -> None: + old_key, old_action, old_plugin = self._find_loaded_action() + if old_plugin is not None: + try: + old_plugin.stop() + except Exception: + pass + + if old_key is not None: + self._actions.delete(old_key) + + if not enabled: + return + + obj, compiled = self._actions.load(self._action_path) + if obj is None or compiled is None: + self._set_status(QC.translate("stats", "Config saved but runtime reload failed. Restart UI."), error=True) + return + + # pylint: disable=protected-access + self._actions._actions_list[obj["name"]] = compiled + plug = compiled.get("actions", {}).get("list_subscriptions") + if plug is not None: + try: + plug.run() + except Exception: + self._set_status(QC.translate("stats", "Plugin enabled but failed to start. Restart UI."), error=True) + + def _find_loaded_action(self) -> tuple[Any, Any, Any]: + for action_key, action_obj in self._actions.getAll().items(): + if action_obj is None: + continue + act_cfg = action_obj.get("actions", {}) + plug = act_cfg.get("list_subscriptions") + if plug is not None: + return action_key, action_obj, plug + return None, None, None + + def _collect_subscriptions(self) -> list[dict[str, Any]] | None: + out: list[dict[str, Any]] = [] + auto_filled = 0 + seen_filenames: dict[str, int] = {} + for row in range(self.table.rowCount()): + enabled_item = self.table.item(row, COL_ENABLED) + interval = self._cell_text(row, COL_INTERVAL) + interval_units = self._cell_text(row, COL_INTERVAL_UNITS) + timeout = self._cell_text(row, COL_TIMEOUT) + timeout_units = self._cell_text(row, COL_TIMEOUT_UNITS) + max_size = self._cell_text(row, COL_MAX_SIZE) + max_size_units = self._cell_text(row, COL_MAX_SIZE_UNITS) + name = self._cell_text(row, COL_NAME) + url = self._cell_text(row, COL_URL) + list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() + groups = normalize_groups(self._cell_text(row, COL_GROUP) or "all") + filename = self._safe_filename(self._cell_text(row, COL_FILENAME)) + if filename == "": + filename = self._guess_filename(name, url) + if filename != "": + auto_filled += 1 + filename = ensure_filename_type_suffix(filename, list_type) + self._set_text_item(row, COL_FILENAME, filename) + file_key = os.path.normcase(filename) + if file_key in seen_filenames: + first_row = seen_filenames[file_key] + 1 + self._set_status( + QC.translate("stats", "Conflicting filename '{0}' on rows {1} and {2}.").format( + filename, first_row, row + 1 + ), + error=True, + ) + return None + seen_filenames[file_key] = row + sub = { + "enabled": enabled_item is not None and enabled_item.checkState() == QtCore.Qt.CheckState.Checked, + "name": name, + "url": url, + "filename": filename, + "format": list_type, + "groups": groups, + "interval": self._to_int_or_keep(interval or self._global_defaults.interval), + "interval_units": interval_units or self._global_defaults.interval_units, + "timeout": self._to_int_or_keep(timeout or self._global_defaults.timeout), + "timeout_units": timeout_units or self._global_defaults.timeout_units, + "max_size": self._to_int_or_keep(max_size or self._global_defaults.max_size), + "max_size_units": max_size_units or self._global_defaults.max_size_units, + } + if sub["url"] == "" or sub["filename"] == "": + self._set_status(QC.translate("stats", "URL and filename cannot be empty (row {0}).").format(row + 1), error=True) + return None + out.append(sub) + + if auto_filled > 0: + self._set_status( + QC.translate("stats", "Auto-filled filename for {0} subscription(s).").format(auto_filled), + error=False, + ) + return out + + def _row_meta_snapshot(self, row: int) -> dict[str, str]: + lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) + filename = self._safe_filename(self._cell_text(row, COL_FILENAME)) + list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() + list_path = self._list_file_path(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 = {} + + return { + "file_present": "yes" if file_exists else "no", + "meta_present": "yes" if meta_exists else "no", + "state": str(meta.get("last_result", self._cell_text(row, COL_STATE) or "never")), + "last_checked": str(meta.get("last_checked", self._cell_text(row, COL_LAST_CHECKED) or "")), + "last_updated": str(meta.get("last_updated", self._cell_text(row, COL_LAST_UPDATED) or "")), + "failures": str(meta.get("fail_count", self._cell_text(row, COL_FAILS) or "0")), + "error": str(meta.get("last_error", self._cell_text(row, COL_ERROR) or "")), + "list_path": list_path, + "meta_path": meta_path, + } + + def _ensure_row_final_filename(self, row: int) -> tuple[str, bool]: + name = self._cell_text(row, COL_NAME) + url = self._cell_text(row, COL_URL) + list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() + original = self._safe_filename(self._cell_text(row, COL_FILENAME)) + final_name = original + changed = False + + if final_name == "": + final_name = self._guess_filename(name, url) + changed = final_name != "" + final_name = ensure_filename_type_suffix(final_name, list_type) + if final_name != original: + changed = True + + if final_name != "": + key = os.path.normcase(final_name) + existing: set[str] = set() + for i in range(self.table.rowCount()): + if i == row: + continue + other = self._safe_filename(self._cell_text(i, COL_FILENAME)) + if other != "": + existing.add(os.path.normcase(other)) + if key in existing: + base, ext = os.path.splitext(final_name) + n = 2 + candidate = final_name + while os.path.normcase(candidate) in existing: + suffix = f"-{n}" + candidate = f"{base}{suffix}{ext}" if ext else f"{base}{suffix}" + n += 1 + final_name = candidate + changed = True + + if changed: + self._set_text_item(row, COL_FILENAME, final_name) + return final_name, changed + + def _append_row(self, sub: dict[str, Any]) -> None: + row = self.table.rowCount() + self.table.insertRow(row) + + enabled_item = QtWidgets.QTableWidgetItem("") + enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + enabled_item.setCheckState(QtCore.Qt.CheckState.Checked if bool(sub.get("enabled", True)) else QtCore.Qt.CheckState.Unchecked) + self.table.setItem(row, COL_ENABLED, enabled_item) + + self._set_text_item(row, COL_NAME, str(sub.get("name", ""))) + self._set_text_item(row, COL_URL, str(sub.get("url", ""))) + self._set_text_item(row, COL_FILENAME, self._safe_filename(sub.get("filename", ""))) + self._set_text_item(row, COL_FORMAT, str(sub.get("format", "hosts"))) + groups = normalize_groups(sub.get("groups")) + self._set_text_item(row, COL_GROUP, ", ".join(groups)) + interval = sub.get("interval") + timeout = sub.get("timeout") + max_size = sub.get("max_size") + interval_units = sub.get("interval_units") + timeout_units = sub.get("timeout_units") + max_size_units = sub.get("max_size_units") + self._set_text_item( + row, + COL_INTERVAL, + self._to_str(interval if interval not in ("", None) else self._global_defaults.interval), + ) + self._set_text_item( + row, + COL_INTERVAL_UNITS, + self._to_str(interval_units if interval_units not in ("", None) else self._global_defaults.interval_units), + ) + self._set_text_item( + row, + COL_TIMEOUT, + self._to_str(timeout if timeout not in ("", None) else self._global_defaults.timeout), + ) + self._set_text_item( + row, + COL_TIMEOUT_UNITS, + self._to_str(timeout_units if timeout_units not in ("", None) else self._global_defaults.timeout_units), + ) + self._set_text_item( + row, + COL_MAX_SIZE, + self._to_str(max_size if max_size not in ("", None) else self._global_defaults.max_size), + ) + self._set_text_item( + row, + COL_MAX_SIZE_UNITS, + self._to_str(max_size_units if max_size_units not in ("", None) else self._global_defaults.max_size_units), + ) + self._set_units_combo( + row, + COL_INTERVAL_UNITS, + INTERVAL_UNITS, + self._to_str(interval_units if interval_units not in ("", None) else self._global_defaults.interval_units), + ) + self._set_units_combo( + row, + COL_TIMEOUT_UNITS, + TIMEOUT_UNITS, + self._to_str(timeout_units if timeout_units not in ("", None) else self._global_defaults.timeout_units), + ) + self._set_units_combo( + row, + COL_MAX_SIZE_UNITS, + SIZE_UNITS, + self._to_str(max_size_units if max_size_units not in ("", None) else self._global_defaults.max_size_units), + ) + + self._set_text_item(row, COL_FILE, "", editable=False) + self._set_text_item(row, COL_META, "", editable=False) + self._set_text_item(row, COL_STATE, "", editable=False) + self._set_text_item(row, COL_LAST_CHECKED, "", editable=False) + self._set_text_item(row, COL_LAST_UPDATED, "", editable=False) + self._set_text_item(row, COL_FAILS, "", editable=False) + self._set_text_item(row, COL_ERROR, "", editable=False) + + def _reload_nodes(self) -> None: + self.nodes_combo.blockSignals(True) + self.nodes_combo.clear() + for addr in self._nodes.get_nodes(): + self.nodes_combo.addItem(addr, addr) + self.nodes_combo.blockSignals(False) + + def _apply_defaults_to_widgets(self) -> None: + self.default_interval_spin.setValue(max(1, int(self._global_defaults.interval))) + self.default_interval_units.setCurrentText( + self._normalize_unit(self._global_defaults.interval_units, INTERVAL_UNITS, "hours") + ) + self.default_timeout_spin.setValue(max(1, int(self._global_defaults.timeout))) + self.default_timeout_units.setCurrentText( + self._normalize_unit(self._global_defaults.timeout_units, TIMEOUT_UNITS, "seconds") + ) + self.default_max_size_spin.setValue(max(1, int(self._global_defaults.max_size))) + self.default_max_size_units.setCurrentText( + self._normalize_unit(self._global_defaults.max_size_units, SIZE_UNITS, "MB") + ) + self.default_user_agent.setText((self._global_defaults.user_agent or "").strip()) + + def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str) -> str: + normalized = (value or "").strip().lower() + for unit in allowed: + if unit.lower() == normalized: + return unit + return fallback + + def _set_units_combo(self, row: int, col: int, allowed: tuple[str, ...], value: str) -> None: + combo = QtWidgets.QComboBox() + combo.addItems(allowed) + combo.setCurrentText(self._normalize_unit(value, allowed, allowed[0])) + self.table.setCellWidget(row, col, combo) + + def _safe_filename(self, value: Any) -> str: + return os.path.basename((self._to_str(value) or "").strip()) + + def _guess_filename(self, name: str, url: str) -> str: + from_header = self._filename_from_headers(url) + if from_header != "": + return self._safe_filename(from_header) + + from_url = self._filename_from_url(url) + if from_url != "": + return self._safe_filename(from_url) + + slug = self._slugify_name(name) + return self._safe_filename(slug) + + def _filename_from_headers(self, url: str) -> str: + if (url or "").strip() == "": + return "" + try: + r = requests.head(url, allow_redirects=True, timeout=5) + cd = r.headers.get("Content-Disposition", "") + if cd: + # Prefer RFC 5987 filename*; fallback to filename + 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 = requests.utils.parse_dict_header(";".join(cd.split(";")[1:])) + raw = params.get("filename") + if raw: + filename = requests.utils.unquote_header_value(str(raw)).strip() + if filename: + return unquote(str(filename)).strip() + except Exception: + return "" + return "" + + def _filename_from_url(self, url: str) -> str: + u = (url or "").strip() + if u == "": + return "" + try: + parsed = urlparse(u) + base = os.path.basename(unquote(parsed.path or "")) + return base.strip() + except Exception: + return "" + + def _slugify_name(self, name: str) -> str: + 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 slug + + def _set_text_item(self, row: int, col: int, text: str, editable: bool = True) -> None: + item = self.table.item(row, col) + if item is None: + item = QtWidgets.QTableWidgetItem() + self.table.setItem(row, col, item) + item.setText(text) + if editable: + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsEditable) + else: + item.setFlags(item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + + def _cell_text(self, row: int, col: int) -> str: + w = self.table.cellWidget(row, col) + if isinstance(w, QtWidgets.QComboBox): + return (w.currentText() or "").strip() + item = self.table.item(row, col) + if item is None: + return "" + return (item.text() or "").strip() + + def _to_int_or_keep(self, value: Any) -> Any: + if value == "": + return value + try: + return int(value) + except Exception: + return value + + def _to_str(self, value: Any) -> str: + if value is None: + return "" + return str(value) + + def _set_status(self, msg: str, error: bool = False) -> None: + self.status_label.setStyleSheet("color: red;" if error else "color: green;") + self.status_label.setText(msg) + + def _refresh_states_if_visible(self) -> None: + if self.isVisible() and not self._loading: + self.refresh_states() diff --git a/ui/opensnitch/plugins/list_subscriptions/_models.py b/ui/opensnitch/plugins/list_subscriptions/_models.py new file mode 100644 index 0000000000..3f7bc45277 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/_models.py @@ -0,0 +1,341 @@ +import os +import re +from dataclasses import dataclass, field, asdict, replace +from typing import Any +from urllib.parse import urlparse, unquote + +from opensnitch.utils.xdg import xdg_config_home +from opensnitch.plugins.list_subscriptions._utils import ( + to_seconds, + parse_compact_duration, + to_max_bytes, +) + + +DEFAULT_UA = ( + "Mozilla/5.0 (X11; Linux x86_64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0 Safari/537.36" +) + + +def normalize_lists_dir(path: str | None) -> str: + 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 safe_filename(value: Any) -> str: + return os.path.basename((str(value or "")).strip()) + + +def filename_from_url(url: str | None) -> str: + try: + parsed = urlparse((url or "").strip()) + return safe_filename(unquote(parsed.path or "")) + except Exception: + return "" + + +def slugify_name(name: str | None) -> str: + 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 derive_filename(name: str | None, url: str | None, filename: str | None) -> str: + 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) -> str: + fn = safe_filename(filename) + base, ext = os.path.splitext(fn) + ltype = (list_type or "hosts").strip().lower() + 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 normalize_group(group: str | None) -> str: + raw = (group or "all").strip().lower() + raw = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-._") + return raw if raw else "all" + + +def normalize_groups(groups: Any) -> list[str]: + 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 in seen: + continue + seen.add(g) + out.append(g) + return out if out else ["all"] + + +@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), + ) + + +@dataclass(frozen=True) +class SubscriptionSpec: + name: str + url: str + filename: str + groups: tuple[str, ...] = ("all",) + enabled: bool = True + format: str = "hosts" + interval: int = 24 + interval_units: str = "hours" + timeout: int = 60 + timeout_units: str = "seconds" + max_size: int = 20 + max_size_units: str = "MB" + interval_seconds: int = 24 * 3600 + timeout_seconds: int = 60 + max_bytes: int = 20 * 1024 * 1024 + + @staticmethod + def from_dict(d: dict[str, Any], defaults: GlobalDefaults): + if not isinstance(d, dict): + return None + + 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")) + filename = ensure_filename_type_suffix(filename, list_type) + 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 "all" not in groups: + groups.insert(0, "all") + if not url: + return None + if not name: + name = filename + + def _opt_int(x: Any): + try: + return int(x) if x is not None else None + except Exception: + return None + + def _opt_str(x: Any): + try: + if x is None: + return None + x = (str(x) or "").strip().lower() + return x if x != "" else None + except Exception: + return None + + 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) or defaults.interval + interval_units_opt = _opt_str(interval_units_raw) + interval_units = interval_units_opt or defaults.interval_units + timeout = _opt_int(timeout_raw) or defaults.timeout + timeout_units_opt = _opt_str(timeout_units_raw) + timeout_units = timeout_units_opt or defaults.timeout_units + max_size = _opt_int(d.get("max_size")) or defaults.max_size + max_size_units = _opt_str(d.get("max_size_units")) or defaults.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) + + 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(interval, 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(timeout, timeout_units, default_timeout_seconds) + elif timeout_is_composite: + timeout = timeout_seconds + timeout_units = "composite" + + max_bytes = to_max_bytes(max_size, 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(frozen=True) +class PluginConfig: + defaults: GlobalDefaults = field(default_factory=lambda: GlobalDefaults.from_dict({})) + subscriptions: list[SubscriptionSpec] = field(default_factory=list) + + @staticmethod + def from_dict(raw_cfg: dict[str, Any], lists_dir: str | None = None): + raw_cfg = raw_cfg or {} + if not isinstance(raw_cfg, dict): + raw_cfg = {} + defaults = GlobalDefaults.from_dict(raw_cfg, lists_dir) + + subs: list[SubscriptionSpec] = [] + seen_filenames: set[str] = set() + for item in (raw_cfg.get("subscriptions") or []): + sub = SubscriptionSpec.from_dict(item, defaults) + if sub is not None: + key = os.path.normcase(sub.filename) + if key in seen_filenames: + base, ext = os.path.splitext(sub.filename) + n = 2 + candidate = sub.filename + while os.path.normcase(candidate) in seen_filenames: + suffix = f"-{n}" + candidate = f"{base}{suffix}{ext}" if ext else f"{base}{suffix}" + n += 1 + sub = replace(sub, filename=candidate) + if sub.name.strip() == "" or sub.name == sub.filename: + sub = replace(sub, name=candidate) + key = os.path.normcase(sub.filename) + seen_filenames.add(key) + subs.append(sub) + + return PluginConfig(defaults=defaults, subscriptions=subs) + + +@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) diff --git a/ui/opensnitch/plugins/list_subscriptions/_utils.py b/ui/opensnitch/plugins/list_subscriptions/_utils.py new file mode 100644 index 0000000000..398ce9be20 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/_utils.py @@ -0,0 +1,155 @@ +import errno +import json +import os +import re +from datetime import datetime +from typing import Any + + +TIME_MULT = { + "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 = { + "s": TIME_MULT["seconds"], + "m": TIME_MULT["minutes"], + "h": TIME_MULT["hours"], + "d": TIME_MULT["days"], + "w": TIME_MULT["weeks"], +} + +SIZE_MULT = { + "bytes": 1, + "kb": 1024, + "mb": 1024 * 1024, + "gb": 1024 * 1024 * 1024, +} + + +def now_iso(): + return datetime.now().astimezone().isoformat() + + +def parse_iso(ts: str): + try: + return datetime.fromisoformat(ts) + except Exception: + return None + + +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 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) + + +class FileLock: + def __init__(self, lock_path: str): + self.lock_path = lock_path + self.fd: int | None = None + + def acquire(self): + try: + self.fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + os.write(self.fd, str(os.getpid()).encode("utf-8")) + return True + except OSError as e: + if e.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 + + +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 diff --git a/ui/opensnitch/plugins/list_subscriptions/blocklist.svg b/ui/opensnitch/plugins/list_subscriptions/blocklist.svg new file mode 100644 index 0000000000..9e3419f670 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/blocklist.svg @@ -0,0 +1,37 @@ + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json b/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json index 84ef5d5419..7f07bebe8f 100644 --- a/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json +++ b/ui/opensnitch/plugins/list_subscriptions/example/list_subscriptions.json @@ -3,12 +3,12 @@ "created": "", "updated": "", "description": "Manage and auto-update blocklist subscriptions (hosts format)", - "type": ["global"], + "type": ["global", "main-dialog"], "actions": { "list_subscriptions": { "enabled": true, "config": { - "lists_dir": "~/.config/opensnitch/blocklists/hosts", + "lists_dir": "~/.config/opensnitch/list_subscriptions", "interval": 24, "interval_units": "hours", diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index b0412b346d..f2faf3bbdf 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -1,22 +1,46 @@ import os import logging -import json -import errno import hashlib import threading -import re -from dataclasses import dataclass, field, asdict +import shutil +import sys from typing import Any from datetime import datetime, timedelta from queue import Queue import requests +if "PyQt5" in sys.modules: + from PyQt5 import QtCore, QtGui +elif "PyQt6" in sys.modules: + from PyQt6 import QtCore, QtGui +else: + try: + from PyQt6 import QtCore, QtGui + except Exception: + from PyQt5 import QtCore, QtGui from opensnitch.dialogs.stats import StatsDialog from opensnitch.notifications import DesktopNotifications from opensnitch.plugins import PluginBase, PluginSignal from opensnitch.utils import GenericTimer from opensnitch.utils.xdg import xdg_config_home +from opensnitch.plugins.list_subscriptions._models import ( + DEFAULT_UA, + ListMetadata, + PluginConfig, + SubscriptionSpec, + ensure_filename_type_suffix, + normalize_group, + normalize_lists_dir, +) +from opensnitch.plugins.list_subscriptions._utils import ( + FileLock, + is_hosts_file_like, + now_iso, + parse_iso, + read_json, + write_json_atomic, +) ch = logging.StreamHandler() #ch.setLevel(logging.ERROR) @@ -26,392 +50,6 @@ logger.addHandler(ch) logger.setLevel(logging.WARNING) -# -------------------- constants -------------------- - -DEFAULT_UA = ( - "Mozilla/5.0 (X11; Linux x86_64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/120.0 Safari/537.36" -) - -TIME_MULT = { - "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 = { - "s": TIME_MULT["seconds"], - "m": TIME_MULT["minutes"], - "h": TIME_MULT["hours"], - "d": TIME_MULT["days"], - "w": TIME_MULT["weeks"], -} - -SIZE_MULT = { - "bytes": 1, - "kb": 1024, - "mb": 1024 * 1024, - "gb": 1024 * 1024 * 1024, -} - - -# -------------------- time helpers (ISO 8601) -------------------- - -def now_iso(): - return datetime.now().astimezone().isoformat() - - -def parse_iso(ts: str): - try: - return datetime.fromisoformat(ts) - except Exception: - return None - - -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 - - -# -------------------- JSON IO -------------------- - -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) - - -# -------------------- lock + atomic swap -------------------- - -class FileLock: - def __init__(self, lock_path: str): - self.lock_path = lock_path - self.fd: int | None = None - - def acquire(self): - try: - self.fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) - os.write(self.fd, str(os.getpid()).encode("utf-8")) - return True - except OSError as e: - if e.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 - - -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 - - -# -------------------- dataclasses: schema -------------------- - -@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: prefer config value, fallback to lists_dir arg - lists_dir = str(d.get("lists_dir") or lists_dir or os.path.join(xdg_config_home, "opensnitch", "blocklists", "hosts")) - - 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), - ) - - -@dataclass(frozen=True) -class SubscriptionSpec: - name: str - url: str - filename: str - enabled: bool = True - format: str = "hosts" - interval: int = 24 - interval_units: str = "hours" - timeout: int = 60 - timeout_units: str = "seconds" - max_size: int = 20 - max_size_units: str = "MB" - interval_seconds: int = 24 * 3600 - timeout_seconds: int = 60 - max_bytes: int = 20 * 1024 * 1024 - - @staticmethod - def from_dict(d: dict[str, Any], defaults: GlobalDefaults): - if not isinstance(d, dict): - return None - - name = (d.get("name") or "").strip() - url = (d.get("url") or "").strip() - filename = (d.get("filename") or "").strip() - if not url or not filename: - return None - if not name: - name = filename - - def _opt_int(x: Any): - try: - return int(x) if x is not None else None - except Exception: - return None - - def _opt_str(x: Any): - try: - if x is None: - return None - x = (str(x) or "").strip().lower() - return x if x != "" else None - except Exception: - return None - - 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) or defaults.interval - interval_units_opt = _opt_str(interval_units_raw) - interval_units = interval_units_opt or defaults.interval_units - timeout = _opt_int(timeout_raw) or defaults.timeout - timeout_units_opt = _opt_str(timeout_units_raw) - timeout_units = timeout_units_opt or defaults.timeout_units - max_size = _opt_int(d.get("max_size")) or defaults.max_size - max_size_units = _opt_str(d.get("max_size_units")) or defaults.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) - - 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(interval, 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(timeout, timeout_units, default_timeout_seconds) - elif timeout_is_composite: - timeout = timeout_seconds - timeout_units = "composite" - - max_bytes = to_max_bytes(max_size, max_size_units, default_max_bytes) - - return SubscriptionSpec( - name=name, - url=url, - filename=filename, - enabled=bool(d.get("enabled", True)), - format=str(d.get("format", "hosts") or "hosts"), - - 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(frozen=True) -class PluginConfig: - defaults: GlobalDefaults = field(default_factory=lambda: GlobalDefaults.from_dict({})) - subscriptions: list[SubscriptionSpec] = field(default_factory=list) - - @staticmethod - def from_dict(raw_cfg: dict[str, Any], lists_dir: str | None = None): - 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) - - return PluginConfig(defaults=defaults, subscriptions=subs) - - -@dataclass -class ListMetadata: - version: int = 1 - url: str = "" - format: str = "hosts" - - etag: str = "" - last_modified: str = "" # HTTP header value, not ISO - - last_checked: str = "" # ISO - last_updated: str = "" # ISO - backoff_until: str = "" # ISO - - 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) - # -------------------- plugin core -------------------- @@ -425,7 +63,7 @@ class ListSubscriptions(PluginBase): The plugin exposes a results queue for the UI to display subscription status and errors. """ # fields overriden from parent class - name = "List Subscriptions" + name = "List_subscriptions" version = 0 author = "opensnitch" created = "" @@ -439,7 +77,7 @@ class ListSubscriptions(PluginBase): # runtime state scheduled_tasks: dict[str, GenericTimer] = {} default_conf = "{0}/{1}".format(xdg_config_home, "opensnitch/actions/list_subscriptions.json") - default_lists_dir = os.path.join(xdg_config_home, "opensnitch", "blocklists", "hosts") + default_lists_dir = os.path.join(xdg_config_home, "opensnitch", "list_subscriptions") def __init__(self, config: dict[str, Any] | None = None): config = config or {} @@ -453,6 +91,8 @@ def __init__(self, config: dict[str, Any] | None = None): self._resultsQueue: Queue[tuple[str, bool, str]] = Queue() self._running = False self._app_icon = os.path.join(os.path.abspath(os.path.dirname(__file__)), "../../res/icon-white.svg") + self._cfg_dialog = None + self._cfg_action = None if config.get("enabled") is True: self.enabled = True @@ -489,10 +129,145 @@ def __init__(self, config: dict[str, Any] | None = None): def _paths(self, sub: SubscriptionSpec): if self._config is None: raise RuntimeError("PluginConfig is not loaded") - list_path = os.path.join(self._config.defaults.lists_dir, sub.filename) + lists_dir = normalize_lists_dir(self._config.defaults.lists_dir) + os.makedirs(lists_dir, mode=0o700, exist_ok=True) + sources_dir = os.path.join(lists_dir, "sources.list.d") + os.makedirs(sources_dir, mode=0o700, exist_ok=True) + safe_filename = os.path.basename((sub.filename or "").strip()) + if safe_filename == "": + safe_filename = "subscription.list" + safe_filename = ensure_filename_type_suffix(safe_filename, sub.format) + base, _ext = os.path.splitext(safe_filename) + list_type = (sub.format or "hosts").strip().lower() + suffix = f"-{list_type}" + sub_dirname = base if base else "subscription" + if not sub_dirname.lower().endswith(suffix): + sub_dirname = f"{sub_dirname}{suffix}" + sub_dir = os.path.join(sources_dir, sub_dirname) + os.makedirs(sub_dir, mode=0o700, exist_ok=True) + list_path = os.path.join(sub_dir, safe_filename) meta_path = list_path + ".meta.json" return list_path, meta_path + def _rules_root_dir(self): + if self._config is None: + return os.path.join(self.default_lists_dir, "rules.list.d") + return os.path.join(normalize_lists_dir(self._config.defaults.lists_dir), "rules.list.d") + + def _sources_root_dir(self): + if self._config is None: + return os.path.join(self.default_lists_dir, "sources.list.d") + return os.path.join(normalize_lists_dir(self._config.defaults.lists_dir), "sources.list.d") + + def _sync_sources_dirs(self): + if self._config is None: + return + sources_dir = self._sources_root_dir() + os.makedirs(sources_dir, mode=0o700, exist_ok=True) + + desired_dirs: set[str] = set() + for sub in self._config.subscriptions: + list_path, _ = self._paths(sub) + desired_dirs.add(os.path.dirname(list_path)) + + for entry in os.listdir(sources_dir): + p = os.path.join(sources_dir, entry) + try: + if os.path.isdir(p) and not os.path.islink(p): + if p not in desired_dirs: + shutil.rmtree(p) + else: + os.unlink(p) + except Exception: + pass + + def _sync_global_symlinks(self): + if self._config is None: + return + rules_dir = self._rules_root_dir() + os.makedirs(rules_dir, mode=0o700, exist_ok=True) + desired: dict[str, dict[str, str]] = {} + for idx, sub in enumerate(self._config.subscriptions): + if not getattr(sub, "enabled", True): + continue + list_path, _ = self._paths(sub) + if not os.path.exists(list_path): + continue + raw_groups: tuple[str, ...] = getattr(sub, "groups", tuple()) + groups: list[str] = list(raw_groups) + groups.append("all") + groups = sorted(normalize_group(g) for g in set(groups)) + link_name = f"{idx:02d}-{os.path.basename(list_path)}" + for group in groups: + desired.setdefault(group, {})[link_name] = list_path + + existing_groups: set[str] = set() + for name in os.listdir(rules_dir): + p = os.path.join(rules_dir, name) + if os.path.isdir(p) and not os.path.islink(p): + existing_groups.add(name) + if name not in desired: + try: + shutil.rmtree(p) + except Exception: + pass + else: + try: + os.unlink(p) + except Exception: + pass + + for group_name in (existing_groups | set(desired.keys())): + group_dir = os.path.join(rules_dir, group_name) + desired_links = desired.get(group_name, {}) + if desired_links: + os.makedirs(group_dir, mode=0o700, exist_ok=True) + try: + existing_entries = os.listdir(group_dir) + except Exception: + existing_entries = [] + + for entry in existing_entries: + entry_path = os.path.join(group_dir, entry) + if entry not in desired_links: + try: + if os.path.isdir(entry_path) and not os.path.islink(entry_path): + shutil.rmtree(entry_path) + else: + os.unlink(entry_path) + except Exception: + pass + continue + + expected_target = desired_links[entry] + in_sync = False + try: + if os.path.islink(entry_path): + in_sync = os.path.realpath(entry_path) == os.path.realpath(expected_target) + except Exception: + in_sync = False + + if not in_sync: + try: + if os.path.isdir(entry_path) and not os.path.islink(entry_path): + shutil.rmtree(entry_path) + else: + os.unlink(entry_path) + except Exception: + pass + + for link_name, target in desired_links.items(): + link_path = os.path.join(group_dir, link_name) + if os.path.lexists(link_path): + continue + try: + os.symlink(target, link_path) + except Exception: + try: + shutil.copy2(target, link_path) + except Exception: + pass + def _load_meta(self, meta_path: str): try: return ListMetadata.from_dict(read_json(meta_path)) @@ -510,8 +285,68 @@ def _sub_key(self, sub: SubscriptionSpec): def configure(self, parent: Any = None): if type(parent) == StatsDialog: - pass - #_gui.add_panel_section() + if self._cfg_action is not None: + return + + menu = parent.actionsButton.menu() + if menu is None: + return + + icon_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "blocklist.svg") + icon = QtGui.QIcon(icon_path) if os.path.exists(icon_path) else QtGui.QIcon() + + quit_action = self._find_quit_action(menu) + if quit_action is not None: + if not icon.isNull(): + self._cfg_action = menu.addAction(icon, "List subscriptions") + else: + self._cfg_action = menu.addAction("List subscriptions") + menu.insertAction(quit_action, self._cfg_action) + else: + acts = menu.actions() + if acts and not acts[-1].isSeparator(): + menu.addSeparator() + if not icon.isNull(): + self._cfg_action = menu.addAction(icon, "List subscriptions") + else: + self._cfg_action = menu.addAction("List subscriptions") + + self._cfg_action.triggered.connect(lambda *_: self._open_config_dialog(parent)) + + def _find_quit_action(self, menu: Any): + qt_key = getattr(getattr(QtCore, "Qt", object()), "Key", None) + key_q = getattr(qt_key, "Key_Q", None) if qt_key is not None else None + for act in menu.actions(): + if act.isSeparator(): + continue + txt = (act.text() or "").replace("&", "").strip().lower() + if txt == "quit": + return act + shortcut = act.shortcut() + if key_q is not None and shortcut and shortcut.matches(QtGui.QKeySequence(key_q)): + return act + # In OpenSnitch main actions menu, Quit is typically the last entry. + acts = [a for a in menu.actions() if not a.isSeparator()] + if acts: + return acts[-1] + return None + + def _open_config_dialog(self, parent): + from opensnitch.plugins.list_subscriptions import _gui + + appicon = None + try: + appicon = parent.windowIcon() + except Exception: + appicon = None + + if self._cfg_dialog is None: + # Some wrapped dialog types are not accepted as QWidget parents by + # PyQt6 constructors in plugin context. Use a top-level dialog. + self._cfg_dialog = _gui.ListSubscriptionsDialog(parent=None, appicon=appicon) + self._cfg_dialog.show() + self._cfg_dialog.raise_() + self._cfg_dialog.activateWindow() def compile(self): """ @@ -553,6 +388,8 @@ def compile(self): except Exception: pass self.scheduled_tasks.pop(key, None) + self._sync_sources_dirs() + self._sync_global_symlinks() def run(self, parent: Any = None, args: tuple[Any, ...] = ()): # type: ignore[override] """ @@ -570,6 +407,22 @@ def run(self, parent: Any = None, args: tuple[Any, ...] = ()): # type: ignore[o except Exception: pass + # Validate + force download all subscriptions at startup. + th = threading.Thread(target=self._startup_recheck_all, daemon=True) + th.start() + + def _startup_recheck_all(self): + if self._config is None: + return + for sub in self._config.subscriptions: + if not sub.enabled: + continue + try: + self.force_refresh_subscription(sub) + except Exception as e: + logger.warning("list_subscriptions: startup recheck error name='%s' err=%s", sub.name, repr(e)) + self._sync_global_symlinks() + def stop(self): """ Stop timers. @@ -599,8 +452,10 @@ def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): meta = self._load_meta(meta_path) if self._in_backoff(meta): + logger.warning("list_subscriptions: skip '%s' (in backoff)", sub.name) return if not self._is_due(meta, sub): + logger.warning("list_subscriptions: skip '%s' (not due yet)", sub.name) return th = threading.Thread(target=self.download, args=(key, sub)) @@ -635,6 +490,20 @@ def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): if self._notify is not None and self._desktop_notifications.is_available() and self._desktop_notifications.are_enabled(): self._desktop_notifications.show(self._notify_title, result_msg, self._app_icon) + def force_refresh_subscription(self, sub: SubscriptionSpec): + key = self._sub_key(sub) + logger.warning( + "list_subscriptions: force refresh requested name='%s' url='%s' file='%s'", + sub.name, sub.url, sub.filename + ) + ok = self.download(key, sub) + logger.warning( + "list_subscriptions: force refresh finished name='%s' result=%s", + sub.name, "ok" if ok else "error" + ) + self._sync_global_symlinks() + return ok + def cb_signal(self, signal: Any): logger.debug("cb_signal: %s, %s", self.name, signal) try: @@ -678,6 +547,10 @@ def _mark_failure(self, meta: ListMetadata, err: str): def download(self, key: str, sub: SubscriptionSpec): list_path, meta_path = self._paths(sub) os.makedirs(os.path.dirname(list_path), exist_ok=True) + logger.warning( + "list_subscriptions: download start key=%s name='%s' dst='%s'", + key, sub.name, list_path + ) meta = self._load_meta(meta_path) @@ -724,12 +597,14 @@ def download(self, key: str, sub: SubscriptionSpec): meta.last_result = "not_modified" self._save_meta(meta_path, meta) self._resultsQueue.put((key, True, "not_modified")) + logger.warning("list_subscriptions: download not-modified name='%s'", sub.name) return True if r.status_code != 200: self._mark_failure(meta, f"http_{r.status_code}") self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, f"http_{r.status_code}")) + logger.warning("list_subscriptions: download http error name='%s' code=%s", sub.name, r.status_code) return False cl: str | None = r.headers.get("Content-Length") @@ -739,6 +614,7 @@ def download(self, key: str, sub: SubscriptionSpec): self._mark_failure(meta, f"too_large:{cl}") self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, "too_large")) + logger.warning("list_subscriptions: download too-large name='%s' len=%s", sub.name, cl) return False except Exception: pass @@ -776,6 +652,7 @@ def download(self, key: str, sub: SubscriptionSpec): self._mark_failure(meta, "bad_format_hosts") self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, "bad_format")) + logger.warning("list_subscriptions: download bad-format name='%s'", sub.name) return False os.replace(tmp, list_path) @@ -789,6 +666,7 @@ def download(self, key: str, sub: SubscriptionSpec): self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, "write_error")) + logger.warning("list_subscriptions: download write-error name='%s' err=%s", sub.name, repr(e)) return False # update cache validators @@ -806,6 +684,7 @@ def download(self, key: str, sub: SubscriptionSpec): meta.last_result = "updated" self._save_meta(meta_path, meta) self._resultsQueue.put((key, True, "updated")) + logger.warning("list_subscriptions: download updated name='%s' bytes=%s", sub.name, downloaded) return True finally: r.close() @@ -813,6 +692,7 @@ def download(self, key: str, sub: SubscriptionSpec): self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, "unexpected_error")) + logger.warning("list_subscriptions: download unexpected-error name='%s' err=%s", sub.name, repr(e)) return False finally: diff --git a/ui/opensnitch/proto/ui_pb2.py b/ui/opensnitch/proto/ui_pb2.py index a864f586a7..b75655f25a 100644 --- a/ui/opensnitch/proto/ui_pb2.py +++ b/ui/opensnitch/proto/ui_pb2.py @@ -1,11 +1,22 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE # source: ui.proto +# Protobuf Python Version: 6.33.1 """Generated protocol buffer code.""" -from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 33, + 1, + '', + 'ui.proto' +) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() @@ -15,106 +26,106 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x08ui.proto\x12\x08protocol\"\xcb\x04\n\x05\x41lert\x12\n\n\x02id\x18\x01 \x01(\x04\x12\"\n\x04type\x18\x02 \x01(\x0e\x32\x14.protocol.Alert.Type\x12&\n\x06\x61\x63tion\x18\x03 \x01(\x0e\x32\x16.protocol.Alert.Action\x12*\n\x08priority\x18\x04 \x01(\x0e\x32\x18.protocol.Alert.Priority\x12\"\n\x04what\x18\x05 \x01(\x0e\x32\x14.protocol.Alert.What\x12\x0e\n\x04text\x18\x06 \x01(\tH\x00\x12!\n\x04proc\x18\x08 \x01(\x0b\x32\x11.protocol.ProcessH\x00\x12$\n\x04\x63onn\x18\t \x01(\x0b\x32\x14.protocol.ConnectionH\x00\x12\x1e\n\x04rule\x18\n \x01(\x0b\x32\x0e.protocol.RuleH\x00\x12\"\n\x06\x66wrule\x18\x0b \x01(\x0b\x32\x10.protocol.FwRuleH\x00\")\n\x08Priority\x12\x07\n\x03LOW\x10\x00\x12\n\n\x06MEDIUM\x10\x01\x12\x08\n\x04HIGH\x10\x02\"(\n\x04Type\x12\t\n\x05\x45RROR\x10\x00\x12\x0b\n\x07WARNING\x10\x01\x12\x08\n\x04INFO\x10\x02\"2\n\x06\x41\x63tion\x12\x08\n\x04NONE\x10\x00\x12\x0e\n\nSHOW_ALERT\x10\x01\x12\x0e\n\nSAVE_TO_DB\x10\x02\"l\n\x04What\x12\x0b\n\x07GENERIC\x10\x00\x12\x10\n\x0cPROC_MONITOR\x10\x01\x12\x0c\n\x08\x46IREWALL\x10\x02\x12\x0e\n\nCONNECTION\x10\x03\x12\x08\n\x04RULE\x10\x04\x12\x0b\n\x07NETLINK\x10\x05\x12\x10\n\x0cKERNEL_EVENT\x10\x06\x42\x06\n\x04\x64\x61ta\"\x19\n\x0bMsgResponse\x12\n\n\x02id\x18\x01 \x01(\x04\"o\n\x05\x45vent\x12\x0c\n\x04time\x18\x01 \x01(\t\x12(\n\nconnection\x18\x02 \x01(\x0b\x32\x14.protocol.Connection\x12\x1c\n\x04rule\x18\x03 \x01(\x0b\x32\x0e.protocol.Rule\x12\x10\n\x08unixnano\x18\x04 \x01(\x03\"\xd3\x06\n\nStatistics\x12\x16\n\x0e\x64\x61\x65mon_version\x18\x01 \x01(\t\x12\r\n\x05rules\x18\x02 \x01(\x04\x12\x0e\n\x06uptime\x18\x03 \x01(\x04\x12\x15\n\rdns_responses\x18\x04 \x01(\x04\x12\x13\n\x0b\x63onnections\x18\x05 \x01(\x04\x12\x0f\n\x07ignored\x18\x06 \x01(\x04\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x07 \x01(\x04\x12\x0f\n\x07\x64ropped\x18\x08 \x01(\x04\x12\x11\n\trule_hits\x18\t \x01(\x04\x12\x13\n\x0brule_misses\x18\n \x01(\x04\x12\x33\n\x08\x62y_proto\x18\x0b \x03(\x0b\x32!.protocol.Statistics.ByProtoEntry\x12\x37\n\nby_address\x18\x0c \x03(\x0b\x32#.protocol.Statistics.ByAddressEntry\x12\x31\n\x07\x62y_host\x18\r \x03(\x0b\x32 .protocol.Statistics.ByHostEntry\x12\x31\n\x07\x62y_port\x18\x0e \x03(\x0b\x32 .protocol.Statistics.ByPortEntry\x12/\n\x06\x62y_uid\x18\x0f \x03(\x0b\x32\x1f.protocol.Statistics.ByUidEntry\x12=\n\rby_executable\x18\x10 \x03(\x0b\x32&.protocol.Statistics.ByExecutableEntry\x12\x1f\n\x06\x65vents\x18\x11 \x03(\x0b\x32\x0f.protocol.Event\x1a.\n\x0c\x42yProtoEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a\x30\n\x0e\x42yAddressEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a-\n\x0b\x42yHostEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a-\n\x0b\x42yPortEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a,\n\nByUidEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a\x33\n\x11\x42yExecutableEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\">\n\x0bPingRequest\x12\n\n\x02id\x18\x01 \x01(\x04\x12#\n\x05stats\x18\x02 \x01(\x0b\x32\x14.protocol.Statistics\"\x17\n\tPingReply\x12\n\n\x02id\x18\x01 \x01(\x04\"\'\n\tStringInt\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\r\"\x9b\x03\n\x07Process\x12\x0b\n\x03pid\x18\x01 \x01(\x04\x12\x0c\n\x04ppid\x18\x02 \x01(\x04\x12\x0b\n\x03uid\x18\x03 \x01(\x04\x12\x0c\n\x04\x63omm\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x06 \x03(\t\x12\'\n\x03\x65nv\x18\x07 \x03(\x0b\x32\x1a.protocol.Process.EnvEntry\x12\x0b\n\x03\x63wd\x18\x08 \x01(\t\x12\x33\n\tchecksums\x18\t \x03(\x0b\x32 .protocol.Process.ChecksumsEntry\x12\x10\n\x08io_reads\x18\n \x01(\x04\x12\x11\n\tio_writes\x18\x0b \x01(\x04\x12\x11\n\tnet_reads\x18\x0c \x01(\x04\x12\x12\n\nnet_writes\x18\r \x01(\x04\x12)\n\x0cprocess_tree\x18\x0e \x03(\x0b\x32\x13.protocol.StringInt\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x30\n\x0e\x43hecksumsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xf3\x03\n\nConnection\x12\x10\n\x08protocol\x18\x01 \x01(\t\x12\x0e\n\x06src_ip\x18\x02 \x01(\t\x12\x10\n\x08src_port\x18\x03 \x01(\r\x12\x0e\n\x06\x64st_ip\x18\x04 \x01(\t\x12\x10\n\x08\x64st_host\x18\x05 \x01(\t\x12\x10\n\x08\x64st_port\x18\x06 \x01(\r\x12\x0f\n\x07user_id\x18\x07 \x01(\r\x12\x12\n\nprocess_id\x18\x08 \x01(\r\x12\x14\n\x0cprocess_path\x18\t \x01(\t\x12\x13\n\x0bprocess_cwd\x18\n \x01(\t\x12\x14\n\x0cprocess_args\x18\x0b \x03(\t\x12\x39\n\x0bprocess_env\x18\x0c \x03(\x0b\x32$.protocol.Connection.ProcessEnvEntry\x12\x45\n\x11process_checksums\x18\r \x03(\x0b\x32*.protocol.Connection.ProcessChecksumsEntry\x12)\n\x0cprocess_tree\x18\x0e \x03(\x0b\x32\x13.protocol.StringInt\x1a\x31\n\x0fProcessEnvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x37\n\x15ProcessChecksumsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"l\n\x08Operator\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0f\n\x07operand\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\t\x12\x11\n\tsensitive\x18\x04 \x01(\x08\x12 \n\x04list\x18\x05 \x03(\x0b\x32\x12.protocol.Operator\"\xb6\x01\n\x04Rule\x12\x0f\n\x07\x63reated\x18\x01 \x01(\x03\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\x12\x12\n\nprecedence\x18\x05 \x01(\x08\x12\r\n\x05nolog\x18\x06 \x01(\x08\x12\x0e\n\x06\x61\x63tion\x18\x07 \x01(\t\x12\x10\n\x08\x64uration\x18\x08 \x01(\t\x12$\n\x08operator\x18\t \x01(\x0b\x32\x12.protocol.Operator\"-\n\x0fStatementValues\x12\x0b\n\x03Key\x18\x01 \x01(\t\x12\r\n\x05Value\x18\x02 \x01(\t\"P\n\tStatement\x12\n\n\x02Op\x18\x01 \x01(\t\x12\x0c\n\x04Name\x18\x02 \x01(\t\x12)\n\x06Values\x18\x03 \x03(\x0b\x32\x19.protocol.StatementValues\"5\n\x0b\x45xpressions\x12&\n\tStatement\x18\x01 \x01(\x0b\x32\x13.protocol.Statement\"\xd6\x01\n\x06\x46wRule\x12\r\n\x05Table\x18\x01 \x01(\t\x12\r\n\x05\x43hain\x18\x02 \x01(\t\x12\x0c\n\x04UUID\x18\x03 \x01(\t\x12\x0f\n\x07\x45nabled\x18\x04 \x01(\x08\x12\x10\n\x08Position\x18\x05 \x01(\x04\x12\x13\n\x0b\x44\x65scription\x18\x06 \x01(\t\x12\x12\n\nParameters\x18\x07 \x01(\t\x12*\n\x0b\x45xpressions\x18\x08 \x03(\x0b\x32\x15.protocol.Expressions\x12\x0e\n\x06Target\x18\t \x01(\t\x12\x18\n\x10TargetParameters\x18\n \x01(\t\"\x95\x01\n\x07\x46wChain\x12\x0c\n\x04Name\x18\x01 \x01(\t\x12\r\n\x05Table\x18\x02 \x01(\t\x12\x0e\n\x06\x46\x61mily\x18\x03 \x01(\t\x12\x10\n\x08Priority\x18\x04 \x01(\t\x12\x0c\n\x04Type\x18\x05 \x01(\t\x12\x0c\n\x04Hook\x18\x06 \x01(\t\x12\x0e\n\x06Policy\x18\x07 \x01(\t\x12\x1f\n\x05Rules\x18\x08 \x03(\x0b\x32\x10.protocol.FwRule\"M\n\x08\x46wChains\x12\x1e\n\x04Rule\x18\x01 \x01(\x0b\x32\x10.protocol.FwRule\x12!\n\x06\x43hains\x18\x02 \x03(\x0b\x32\x11.protocol.FwChain\"X\n\x0bSysFirewall\x12\x0f\n\x07\x45nabled\x18\x01 \x01(\x08\x12\x0f\n\x07Version\x18\x02 \x01(\r\x12\'\n\x0bSystemRules\x18\x03 \x03(\x0b\x32\x12.protocol.FwChains\"\xc4\x01\n\x0c\x43lientConfig\x12\n\n\x02id\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x19\n\x11isFirewallRunning\x18\x04 \x01(\x08\x12\x0e\n\x06\x63onfig\x18\x05 \x01(\t\x12\x10\n\x08logLevel\x18\x06 \x01(\r\x12\x1d\n\x05rules\x18\x07 \x03(\x0b\x32\x0e.protocol.Rule\x12-\n\x0esystemFirewall\x18\x08 \x01(\x0b\x32\x15.protocol.SysFirewall\"\xbb\x01\n\x0cNotification\x12\n\n\x02id\x18\x01 \x01(\x04\x12\x12\n\nclientName\x18\x02 \x01(\t\x12\x12\n\nserverName\x18\x03 \x01(\t\x12\x1e\n\x04type\x18\x04 \x01(\x0e\x32\x10.protocol.Action\x12\x0c\n\x04\x64\x61ta\x18\x05 \x01(\t\x12\x1d\n\x05rules\x18\x06 \x03(\x0b\x32\x0e.protocol.Rule\x12*\n\x0bsysFirewall\x18\x07 \x01(\x0b\x32\x15.protocol.SysFirewall\"\\\n\x11NotificationReply\x12\n\n\x02id\x18\x01 \x01(\x04\x12-\n\x04\x63ode\x18\x02 \x01(\x0e\x32\x1f.protocol.NotificationReplyCode\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\t*\x95\x02\n\x06\x41\x63tion\x12\x08\n\x04NONE\x10\x00\x12\x17\n\x13\x45NABLE_INTERCEPTION\x10\x01\x12\x18\n\x14\x44ISABLE_INTERCEPTION\x10\x02\x12\x13\n\x0f\x45NABLE_FIREWALL\x10\x03\x12\x14\n\x10\x44ISABLE_FIREWALL\x10\x04\x12\x13\n\x0fRELOAD_FW_RULES\x10\x05\x12\x11\n\rCHANGE_CONFIG\x10\x06\x12\x0f\n\x0b\x45NABLE_RULE\x10\x07\x12\x10\n\x0c\x44ISABLE_RULE\x10\x08\x12\x0f\n\x0b\x44\x45LETE_RULE\x10\t\x12\x0f\n\x0b\x43HANGE_RULE\x10\n\x12\r\n\tLOG_LEVEL\x10\x0b\x12\x08\n\x04STOP\x10\x0c\x12\x0e\n\nTASK_START\x10\r\x12\r\n\tTASK_STOP\x10\x0e**\n\x15NotificationReplyCode\x12\x06\n\x02OK\x10\x00\x12\t\n\x05\x45RROR\x10\x01\x32\xaf\x02\n\x02UI\x12\x34\n\x04Ping\x12\x15.protocol.PingRequest\x1a\x13.protocol.PingReply\"\x00\x12\x31\n\x07\x41skRule\x12\x14.protocol.Connection\x1a\x0e.protocol.Rule\"\x00\x12=\n\tSubscribe\x12\x16.protocol.ClientConfig\x1a\x16.protocol.ClientConfig\"\x00\x12J\n\rNotifications\x12\x1b.protocol.NotificationReply\x1a\x16.protocol.Notification\"\x00(\x01\x30\x01\x12\x35\n\tPostAlert\x12\x0f.protocol.Alert\x1a\x15.protocol.MsgResponse\"\x00\x42\x35Z3github.com/evilsocket/opensnitch/daemon/ui/protocolb\x06proto3') -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ui_pb2', globals()) -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - DESCRIPTOR._serialized_options = b'Z3github.com/evilsocket/opensnitch/daemon/ui/protocol' - _STATISTICS_BYPROTOENTRY._options = None - _STATISTICS_BYPROTOENTRY._serialized_options = b'8\001' - _STATISTICS_BYADDRESSENTRY._options = None - _STATISTICS_BYADDRESSENTRY._serialized_options = b'8\001' - _STATISTICS_BYHOSTENTRY._options = None - _STATISTICS_BYHOSTENTRY._serialized_options = b'8\001' - _STATISTICS_BYPORTENTRY._options = None - _STATISTICS_BYPORTENTRY._serialized_options = b'8\001' - _STATISTICS_BYUIDENTRY._options = None - _STATISTICS_BYUIDENTRY._serialized_options = b'8\001' - _STATISTICS_BYEXECUTABLEENTRY._options = None - _STATISTICS_BYEXECUTABLEENTRY._serialized_options = b'8\001' - _PROCESS_ENVENTRY._options = None - _PROCESS_ENVENTRY._serialized_options = b'8\001' - _PROCESS_CHECKSUMSENTRY._options = None - _PROCESS_CHECKSUMSENTRY._serialized_options = b'8\001' - _CONNECTION_PROCESSENVENTRY._options = None - _CONNECTION_PROCESSENVENTRY._serialized_options = b'8\001' - _CONNECTION_PROCESSCHECKSUMSENTRY._options = None - _CONNECTION_PROCESSCHECKSUMSENTRY._serialized_options = b'8\001' - _ACTION._serialized_start=4153 - _ACTION._serialized_end=4430 - _NOTIFICATIONREPLYCODE._serialized_start=4432 - _NOTIFICATIONREPLYCODE._serialized_end=4474 - _ALERT._serialized_start=23 - _ALERT._serialized_end=610 - _ALERT_PRIORITY._serialized_start=357 - _ALERT_PRIORITY._serialized_end=398 - _ALERT_TYPE._serialized_start=400 - _ALERT_TYPE._serialized_end=440 - _ALERT_ACTION._serialized_start=442 - _ALERT_ACTION._serialized_end=492 - _ALERT_WHAT._serialized_start=494 - _ALERT_WHAT._serialized_end=602 - _MSGRESPONSE._serialized_start=612 - _MSGRESPONSE._serialized_end=637 - _EVENT._serialized_start=639 - _EVENT._serialized_end=750 - _STATISTICS._serialized_start=753 - _STATISTICS._serialized_end=1604 - _STATISTICS_BYPROTOENTRY._serialized_start=1315 - _STATISTICS_BYPROTOENTRY._serialized_end=1361 - _STATISTICS_BYADDRESSENTRY._serialized_start=1363 - _STATISTICS_BYADDRESSENTRY._serialized_end=1411 - _STATISTICS_BYHOSTENTRY._serialized_start=1413 - _STATISTICS_BYHOSTENTRY._serialized_end=1458 - _STATISTICS_BYPORTENTRY._serialized_start=1460 - _STATISTICS_BYPORTENTRY._serialized_end=1505 - _STATISTICS_BYUIDENTRY._serialized_start=1507 - _STATISTICS_BYUIDENTRY._serialized_end=1551 - _STATISTICS_BYEXECUTABLEENTRY._serialized_start=1553 - _STATISTICS_BYEXECUTABLEENTRY._serialized_end=1604 - _PINGREQUEST._serialized_start=1606 - _PINGREQUEST._serialized_end=1668 - _PINGREPLY._serialized_start=1670 - _PINGREPLY._serialized_end=1693 - _STRINGINT._serialized_start=1695 - _STRINGINT._serialized_end=1734 - _PROCESS._serialized_start=1737 - _PROCESS._serialized_end=2148 - _PROCESS_ENVENTRY._serialized_start=2056 - _PROCESS_ENVENTRY._serialized_end=2098 - _PROCESS_CHECKSUMSENTRY._serialized_start=2100 - _PROCESS_CHECKSUMSENTRY._serialized_end=2148 - _CONNECTION._serialized_start=2151 - _CONNECTION._serialized_end=2650 - _CONNECTION_PROCESSENVENTRY._serialized_start=2544 - _CONNECTION_PROCESSENVENTRY._serialized_end=2593 - _CONNECTION_PROCESSCHECKSUMSENTRY._serialized_start=2595 - _CONNECTION_PROCESSCHECKSUMSENTRY._serialized_end=2650 - _OPERATOR._serialized_start=2652 - _OPERATOR._serialized_end=2760 - _RULE._serialized_start=2763 - _RULE._serialized_end=2945 - _STATEMENTVALUES._serialized_start=2947 - _STATEMENTVALUES._serialized_end=2992 - _STATEMENT._serialized_start=2994 - _STATEMENT._serialized_end=3074 - _EXPRESSIONS._serialized_start=3076 - _EXPRESSIONS._serialized_end=3129 - _FWRULE._serialized_start=3132 - _FWRULE._serialized_end=3346 - _FWCHAIN._serialized_start=3349 - _FWCHAIN._serialized_end=3498 - _FWCHAINS._serialized_start=3500 - _FWCHAINS._serialized_end=3577 - _SYSFIREWALL._serialized_start=3579 - _SYSFIREWALL._serialized_end=3667 - _CLIENTCONFIG._serialized_start=3670 - _CLIENTCONFIG._serialized_end=3866 - _NOTIFICATION._serialized_start=3869 - _NOTIFICATION._serialized_end=4056 - _NOTIFICATIONREPLY._serialized_start=4058 - _NOTIFICATIONREPLY._serialized_end=4150 - _UI._serialized_start=4477 - _UI._serialized_end=4780 +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ui_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals['DESCRIPTOR']._loaded_options = None + _globals['DESCRIPTOR']._serialized_options = b'Z3github.com/evilsocket/opensnitch/daemon/ui/protocol' + _globals['_STATISTICS_BYPROTOENTRY']._loaded_options = None + _globals['_STATISTICS_BYPROTOENTRY']._serialized_options = b'8\001' + _globals['_STATISTICS_BYADDRESSENTRY']._loaded_options = None + _globals['_STATISTICS_BYADDRESSENTRY']._serialized_options = b'8\001' + _globals['_STATISTICS_BYHOSTENTRY']._loaded_options = None + _globals['_STATISTICS_BYHOSTENTRY']._serialized_options = b'8\001' + _globals['_STATISTICS_BYPORTENTRY']._loaded_options = None + _globals['_STATISTICS_BYPORTENTRY']._serialized_options = b'8\001' + _globals['_STATISTICS_BYUIDENTRY']._loaded_options = None + _globals['_STATISTICS_BYUIDENTRY']._serialized_options = b'8\001' + _globals['_STATISTICS_BYEXECUTABLEENTRY']._loaded_options = None + _globals['_STATISTICS_BYEXECUTABLEENTRY']._serialized_options = b'8\001' + _globals['_PROCESS_ENVENTRY']._loaded_options = None + _globals['_PROCESS_ENVENTRY']._serialized_options = b'8\001' + _globals['_PROCESS_CHECKSUMSENTRY']._loaded_options = None + _globals['_PROCESS_CHECKSUMSENTRY']._serialized_options = b'8\001' + _globals['_CONNECTION_PROCESSENVENTRY']._loaded_options = None + _globals['_CONNECTION_PROCESSENVENTRY']._serialized_options = b'8\001' + _globals['_CONNECTION_PROCESSCHECKSUMSENTRY']._loaded_options = None + _globals['_CONNECTION_PROCESSCHECKSUMSENTRY']._serialized_options = b'8\001' + _globals['_ACTION']._serialized_start=4153 + _globals['_ACTION']._serialized_end=4430 + _globals['_NOTIFICATIONREPLYCODE']._serialized_start=4432 + _globals['_NOTIFICATIONREPLYCODE']._serialized_end=4474 + _globals['_ALERT']._serialized_start=23 + _globals['_ALERT']._serialized_end=610 + _globals['_ALERT_PRIORITY']._serialized_start=357 + _globals['_ALERT_PRIORITY']._serialized_end=398 + _globals['_ALERT_TYPE']._serialized_start=400 + _globals['_ALERT_TYPE']._serialized_end=440 + _globals['_ALERT_ACTION']._serialized_start=442 + _globals['_ALERT_ACTION']._serialized_end=492 + _globals['_ALERT_WHAT']._serialized_start=494 + _globals['_ALERT_WHAT']._serialized_end=602 + _globals['_MSGRESPONSE']._serialized_start=612 + _globals['_MSGRESPONSE']._serialized_end=637 + _globals['_EVENT']._serialized_start=639 + _globals['_EVENT']._serialized_end=750 + _globals['_STATISTICS']._serialized_start=753 + _globals['_STATISTICS']._serialized_end=1604 + _globals['_STATISTICS_BYPROTOENTRY']._serialized_start=1315 + _globals['_STATISTICS_BYPROTOENTRY']._serialized_end=1361 + _globals['_STATISTICS_BYADDRESSENTRY']._serialized_start=1363 + _globals['_STATISTICS_BYADDRESSENTRY']._serialized_end=1411 + _globals['_STATISTICS_BYHOSTENTRY']._serialized_start=1413 + _globals['_STATISTICS_BYHOSTENTRY']._serialized_end=1458 + _globals['_STATISTICS_BYPORTENTRY']._serialized_start=1460 + _globals['_STATISTICS_BYPORTENTRY']._serialized_end=1505 + _globals['_STATISTICS_BYUIDENTRY']._serialized_start=1507 + _globals['_STATISTICS_BYUIDENTRY']._serialized_end=1551 + _globals['_STATISTICS_BYEXECUTABLEENTRY']._serialized_start=1553 + _globals['_STATISTICS_BYEXECUTABLEENTRY']._serialized_end=1604 + _globals['_PINGREQUEST']._serialized_start=1606 + _globals['_PINGREQUEST']._serialized_end=1668 + _globals['_PINGREPLY']._serialized_start=1670 + _globals['_PINGREPLY']._serialized_end=1693 + _globals['_STRINGINT']._serialized_start=1695 + _globals['_STRINGINT']._serialized_end=1734 + _globals['_PROCESS']._serialized_start=1737 + _globals['_PROCESS']._serialized_end=2148 + _globals['_PROCESS_ENVENTRY']._serialized_start=2056 + _globals['_PROCESS_ENVENTRY']._serialized_end=2098 + _globals['_PROCESS_CHECKSUMSENTRY']._serialized_start=2100 + _globals['_PROCESS_CHECKSUMSENTRY']._serialized_end=2148 + _globals['_CONNECTION']._serialized_start=2151 + _globals['_CONNECTION']._serialized_end=2650 + _globals['_CONNECTION_PROCESSENVENTRY']._serialized_start=2544 + _globals['_CONNECTION_PROCESSENVENTRY']._serialized_end=2593 + _globals['_CONNECTION_PROCESSCHECKSUMSENTRY']._serialized_start=2595 + _globals['_CONNECTION_PROCESSCHECKSUMSENTRY']._serialized_end=2650 + _globals['_OPERATOR']._serialized_start=2652 + _globals['_OPERATOR']._serialized_end=2760 + _globals['_RULE']._serialized_start=2763 + _globals['_RULE']._serialized_end=2945 + _globals['_STATEMENTVALUES']._serialized_start=2947 + _globals['_STATEMENTVALUES']._serialized_end=2992 + _globals['_STATEMENT']._serialized_start=2994 + _globals['_STATEMENT']._serialized_end=3074 + _globals['_EXPRESSIONS']._serialized_start=3076 + _globals['_EXPRESSIONS']._serialized_end=3129 + _globals['_FWRULE']._serialized_start=3132 + _globals['_FWRULE']._serialized_end=3346 + _globals['_FWCHAIN']._serialized_start=3349 + _globals['_FWCHAIN']._serialized_end=3498 + _globals['_FWCHAINS']._serialized_start=3500 + _globals['_FWCHAINS']._serialized_end=3577 + _globals['_SYSFIREWALL']._serialized_start=3579 + _globals['_SYSFIREWALL']._serialized_end=3667 + _globals['_CLIENTCONFIG']._serialized_start=3670 + _globals['_CLIENTCONFIG']._serialized_end=3866 + _globals['_NOTIFICATION']._serialized_start=3869 + _globals['_NOTIFICATION']._serialized_end=4056 + _globals['_NOTIFICATIONREPLY']._serialized_start=4058 + _globals['_NOTIFICATIONREPLY']._serialized_end=4150 + _globals['_UI']._serialized_start=4477 + _globals['_UI']._serialized_end=4780 # @@protoc_insertion_point(module_scope) diff --git a/ui/opensnitch/proto/ui_pb2.pyi b/ui/opensnitch/proto/ui_pb2.pyi new file mode 100644 index 0000000000..afacfc05b8 --- /dev/null +++ b/ui/opensnitch/proto/ui_pb2.pyi @@ -0,0 +1,494 @@ +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from collections.abc import Iterable as _Iterable, Mapping as _Mapping +from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class Action(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + NONE: _ClassVar[Action] + ENABLE_INTERCEPTION: _ClassVar[Action] + DISABLE_INTERCEPTION: _ClassVar[Action] + ENABLE_FIREWALL: _ClassVar[Action] + DISABLE_FIREWALL: _ClassVar[Action] + RELOAD_FW_RULES: _ClassVar[Action] + CHANGE_CONFIG: _ClassVar[Action] + ENABLE_RULE: _ClassVar[Action] + DISABLE_RULE: _ClassVar[Action] + DELETE_RULE: _ClassVar[Action] + CHANGE_RULE: _ClassVar[Action] + LOG_LEVEL: _ClassVar[Action] + STOP: _ClassVar[Action] + TASK_START: _ClassVar[Action] + TASK_STOP: _ClassVar[Action] + +class NotificationReplyCode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + OK: _ClassVar[NotificationReplyCode] + ERROR: _ClassVar[NotificationReplyCode] +NONE: Action +ENABLE_INTERCEPTION: Action +DISABLE_INTERCEPTION: Action +ENABLE_FIREWALL: Action +DISABLE_FIREWALL: Action +RELOAD_FW_RULES: Action +CHANGE_CONFIG: Action +ENABLE_RULE: Action +DISABLE_RULE: Action +DELETE_RULE: Action +CHANGE_RULE: Action +LOG_LEVEL: Action +STOP: Action +TASK_START: Action +TASK_STOP: Action +OK: NotificationReplyCode +ERROR: NotificationReplyCode + +class Alert(_message.Message): + __slots__ = () + class Priority(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + LOW: _ClassVar[Alert.Priority] + MEDIUM: _ClassVar[Alert.Priority] + HIGH: _ClassVar[Alert.Priority] + LOW: Alert.Priority + MEDIUM: Alert.Priority + HIGH: Alert.Priority + class Type(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + ERROR: _ClassVar[Alert.Type] + WARNING: _ClassVar[Alert.Type] + INFO: _ClassVar[Alert.Type] + ERROR: Alert.Type + WARNING: Alert.Type + INFO: Alert.Type + class Action(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + NONE: _ClassVar[Alert.Action] + SHOW_ALERT: _ClassVar[Alert.Action] + SAVE_TO_DB: _ClassVar[Alert.Action] + NONE: Alert.Action + SHOW_ALERT: Alert.Action + SAVE_TO_DB: Alert.Action + class What(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + GENERIC: _ClassVar[Alert.What] + PROC_MONITOR: _ClassVar[Alert.What] + FIREWALL: _ClassVar[Alert.What] + CONNECTION: _ClassVar[Alert.What] + RULE: _ClassVar[Alert.What] + NETLINK: _ClassVar[Alert.What] + KERNEL_EVENT: _ClassVar[Alert.What] + GENERIC: Alert.What + PROC_MONITOR: Alert.What + FIREWALL: Alert.What + CONNECTION: Alert.What + RULE: Alert.What + NETLINK: Alert.What + KERNEL_EVENT: Alert.What + ID_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + ACTION_FIELD_NUMBER: _ClassVar[int] + PRIORITY_FIELD_NUMBER: _ClassVar[int] + WHAT_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + PROC_FIELD_NUMBER: _ClassVar[int] + CONN_FIELD_NUMBER: _ClassVar[int] + RULE_FIELD_NUMBER: _ClassVar[int] + FWRULE_FIELD_NUMBER: _ClassVar[int] + id: int + type: Alert.Type + action: Alert.Action + priority: Alert.Priority + what: Alert.What + text: str + proc: Process + conn: Connection + rule: Rule + fwrule: FwRule + def __init__(self, id: _Optional[int] = ..., type: _Optional[_Union[Alert.Type, str]] = ..., action: _Optional[_Union[Alert.Action, str]] = ..., priority: _Optional[_Union[Alert.Priority, str]] = ..., what: _Optional[_Union[Alert.What, str]] = ..., text: _Optional[str] = ..., proc: _Optional[_Union[Process, _Mapping]] = ..., conn: _Optional[_Union[Connection, _Mapping]] = ..., rule: _Optional[_Union[Rule, _Mapping]] = ..., fwrule: _Optional[_Union[FwRule, _Mapping]] = ...) -> None: ... + +class MsgResponse(_message.Message): + __slots__ = () + ID_FIELD_NUMBER: _ClassVar[int] + id: int + def __init__(self, id: _Optional[int] = ...) -> None: ... + +class Event(_message.Message): + __slots__ = () + TIME_FIELD_NUMBER: _ClassVar[int] + CONNECTION_FIELD_NUMBER: _ClassVar[int] + RULE_FIELD_NUMBER: _ClassVar[int] + UNIXNANO_FIELD_NUMBER: _ClassVar[int] + time: str + connection: Connection + rule: Rule + unixnano: int + def __init__(self, time: _Optional[str] = ..., connection: _Optional[_Union[Connection, _Mapping]] = ..., rule: _Optional[_Union[Rule, _Mapping]] = ..., unixnano: _Optional[int] = ...) -> None: ... + +class Statistics(_message.Message): + __slots__ = () + class ByProtoEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: int + def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... + class ByAddressEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: int + def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... + class ByHostEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: int + def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... + class ByPortEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: int + def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... + class ByUidEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: int + def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... + class ByExecutableEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: int + def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... + DAEMON_VERSION_FIELD_NUMBER: _ClassVar[int] + RULES_FIELD_NUMBER: _ClassVar[int] + UPTIME_FIELD_NUMBER: _ClassVar[int] + DNS_RESPONSES_FIELD_NUMBER: _ClassVar[int] + CONNECTIONS_FIELD_NUMBER: _ClassVar[int] + IGNORED_FIELD_NUMBER: _ClassVar[int] + ACCEPTED_FIELD_NUMBER: _ClassVar[int] + DROPPED_FIELD_NUMBER: _ClassVar[int] + RULE_HITS_FIELD_NUMBER: _ClassVar[int] + RULE_MISSES_FIELD_NUMBER: _ClassVar[int] + BY_PROTO_FIELD_NUMBER: _ClassVar[int] + BY_ADDRESS_FIELD_NUMBER: _ClassVar[int] + BY_HOST_FIELD_NUMBER: _ClassVar[int] + BY_PORT_FIELD_NUMBER: _ClassVar[int] + BY_UID_FIELD_NUMBER: _ClassVar[int] + BY_EXECUTABLE_FIELD_NUMBER: _ClassVar[int] + EVENTS_FIELD_NUMBER: _ClassVar[int] + daemon_version: str + rules: int + uptime: int + dns_responses: int + connections: int + ignored: int + accepted: int + dropped: int + rule_hits: int + rule_misses: int + by_proto: _containers.ScalarMap[str, int] + by_address: _containers.ScalarMap[str, int] + by_host: _containers.ScalarMap[str, int] + by_port: _containers.ScalarMap[str, int] + by_uid: _containers.ScalarMap[str, int] + by_executable: _containers.ScalarMap[str, int] + events: _containers.RepeatedCompositeFieldContainer[Event] + def __init__(self, daemon_version: _Optional[str] = ..., rules: _Optional[int] = ..., uptime: _Optional[int] = ..., dns_responses: _Optional[int] = ..., connections: _Optional[int] = ..., ignored: _Optional[int] = ..., accepted: _Optional[int] = ..., dropped: _Optional[int] = ..., rule_hits: _Optional[int] = ..., rule_misses: _Optional[int] = ..., by_proto: _Optional[_Mapping[str, int]] = ..., by_address: _Optional[_Mapping[str, int]] = ..., by_host: _Optional[_Mapping[str, int]] = ..., by_port: _Optional[_Mapping[str, int]] = ..., by_uid: _Optional[_Mapping[str, int]] = ..., by_executable: _Optional[_Mapping[str, int]] = ..., events: _Optional[_Iterable[_Union[Event, _Mapping]]] = ...) -> None: ... + +class PingRequest(_message.Message): + __slots__ = () + ID_FIELD_NUMBER: _ClassVar[int] + STATS_FIELD_NUMBER: _ClassVar[int] + id: int + stats: Statistics + def __init__(self, id: _Optional[int] = ..., stats: _Optional[_Union[Statistics, _Mapping]] = ...) -> None: ... + +class PingReply(_message.Message): + __slots__ = () + ID_FIELD_NUMBER: _ClassVar[int] + id: int + def __init__(self, id: _Optional[int] = ...) -> None: ... + +class StringInt(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: int + def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... + +class Process(_message.Message): + __slots__ = () + class EnvEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + class ChecksumsEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + PID_FIELD_NUMBER: _ClassVar[int] + PPID_FIELD_NUMBER: _ClassVar[int] + UID_FIELD_NUMBER: _ClassVar[int] + COMM_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + ARGS_FIELD_NUMBER: _ClassVar[int] + ENV_FIELD_NUMBER: _ClassVar[int] + CWD_FIELD_NUMBER: _ClassVar[int] + CHECKSUMS_FIELD_NUMBER: _ClassVar[int] + IO_READS_FIELD_NUMBER: _ClassVar[int] + IO_WRITES_FIELD_NUMBER: _ClassVar[int] + NET_READS_FIELD_NUMBER: _ClassVar[int] + NET_WRITES_FIELD_NUMBER: _ClassVar[int] + PROCESS_TREE_FIELD_NUMBER: _ClassVar[int] + pid: int + ppid: int + uid: int + comm: str + path: str + args: _containers.RepeatedScalarFieldContainer[str] + env: _containers.ScalarMap[str, str] + cwd: str + checksums: _containers.ScalarMap[str, str] + io_reads: int + io_writes: int + net_reads: int + net_writes: int + process_tree: _containers.RepeatedCompositeFieldContainer[StringInt] + def __init__(self, pid: _Optional[int] = ..., ppid: _Optional[int] = ..., uid: _Optional[int] = ..., comm: _Optional[str] = ..., path: _Optional[str] = ..., args: _Optional[_Iterable[str]] = ..., env: _Optional[_Mapping[str, str]] = ..., cwd: _Optional[str] = ..., checksums: _Optional[_Mapping[str, str]] = ..., io_reads: _Optional[int] = ..., io_writes: _Optional[int] = ..., net_reads: _Optional[int] = ..., net_writes: _Optional[int] = ..., process_tree: _Optional[_Iterable[_Union[StringInt, _Mapping]]] = ...) -> None: ... + +class Connection(_message.Message): + __slots__ = () + class ProcessEnvEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + class ProcessChecksumsEntry(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + PROTOCOL_FIELD_NUMBER: _ClassVar[int] + SRC_IP_FIELD_NUMBER: _ClassVar[int] + SRC_PORT_FIELD_NUMBER: _ClassVar[int] + DST_IP_FIELD_NUMBER: _ClassVar[int] + DST_HOST_FIELD_NUMBER: _ClassVar[int] + DST_PORT_FIELD_NUMBER: _ClassVar[int] + USER_ID_FIELD_NUMBER: _ClassVar[int] + PROCESS_ID_FIELD_NUMBER: _ClassVar[int] + PROCESS_PATH_FIELD_NUMBER: _ClassVar[int] + PROCESS_CWD_FIELD_NUMBER: _ClassVar[int] + PROCESS_ARGS_FIELD_NUMBER: _ClassVar[int] + PROCESS_ENV_FIELD_NUMBER: _ClassVar[int] + PROCESS_CHECKSUMS_FIELD_NUMBER: _ClassVar[int] + PROCESS_TREE_FIELD_NUMBER: _ClassVar[int] + protocol: str + src_ip: str + src_port: int + dst_ip: str + dst_host: str + dst_port: int + user_id: int + process_id: int + process_path: str + process_cwd: str + process_args: _containers.RepeatedScalarFieldContainer[str] + process_env: _containers.ScalarMap[str, str] + process_checksums: _containers.ScalarMap[str, str] + process_tree: _containers.RepeatedCompositeFieldContainer[StringInt] + def __init__(self, protocol: _Optional[str] = ..., src_ip: _Optional[str] = ..., src_port: _Optional[int] = ..., dst_ip: _Optional[str] = ..., dst_host: _Optional[str] = ..., dst_port: _Optional[int] = ..., user_id: _Optional[int] = ..., process_id: _Optional[int] = ..., process_path: _Optional[str] = ..., process_cwd: _Optional[str] = ..., process_args: _Optional[_Iterable[str]] = ..., process_env: _Optional[_Mapping[str, str]] = ..., process_checksums: _Optional[_Mapping[str, str]] = ..., process_tree: _Optional[_Iterable[_Union[StringInt, _Mapping]]] = ...) -> None: ... + +class Operator(_message.Message): + __slots__ = () + TYPE_FIELD_NUMBER: _ClassVar[int] + OPERAND_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + SENSITIVE_FIELD_NUMBER: _ClassVar[int] + LIST_FIELD_NUMBER: _ClassVar[int] + type: str + operand: str + data: str + sensitive: bool + list: _containers.RepeatedCompositeFieldContainer[Operator] + def __init__(self, type: _Optional[str] = ..., operand: _Optional[str] = ..., data: _Optional[str] = ..., sensitive: _Optional[bool] = ..., list: _Optional[_Iterable[_Union[Operator, _Mapping]]] = ...) -> None: ... + +class Rule(_message.Message): + __slots__ = () + CREATED_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + DESCRIPTION_FIELD_NUMBER: _ClassVar[int] + ENABLED_FIELD_NUMBER: _ClassVar[int] + PRECEDENCE_FIELD_NUMBER: _ClassVar[int] + NOLOG_FIELD_NUMBER: _ClassVar[int] + ACTION_FIELD_NUMBER: _ClassVar[int] + DURATION_FIELD_NUMBER: _ClassVar[int] + OPERATOR_FIELD_NUMBER: _ClassVar[int] + created: int + name: str + description: str + enabled: bool + precedence: bool + nolog: bool + action: str + duration: str + operator: Operator + def __init__(self, created: _Optional[int] = ..., name: _Optional[str] = ..., description: _Optional[str] = ..., enabled: _Optional[bool] = ..., precedence: _Optional[bool] = ..., nolog: _Optional[bool] = ..., action: _Optional[str] = ..., duration: _Optional[str] = ..., operator: _Optional[_Union[Operator, _Mapping]] = ...) -> None: ... + +class StatementValues(_message.Message): + __slots__ = () + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + Key: str + Value: str + def __init__(self, Key: _Optional[str] = ..., Value: _Optional[str] = ...) -> None: ... + +class Statement(_message.Message): + __slots__ = () + OP_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + VALUES_FIELD_NUMBER: _ClassVar[int] + Op: str + Name: str + Values: _containers.RepeatedCompositeFieldContainer[StatementValues] + def __init__(self, Op: _Optional[str] = ..., Name: _Optional[str] = ..., Values: _Optional[_Iterable[_Union[StatementValues, _Mapping]]] = ...) -> None: ... + +class Expressions(_message.Message): + __slots__ = () + STATEMENT_FIELD_NUMBER: _ClassVar[int] + Statement: Statement + def __init__(self, Statement: _Optional[_Union[Statement, _Mapping]] = ...) -> None: ... + +class FwRule(_message.Message): + __slots__ = () + TABLE_FIELD_NUMBER: _ClassVar[int] + CHAIN_FIELD_NUMBER: _ClassVar[int] + UUID_FIELD_NUMBER: _ClassVar[int] + ENABLED_FIELD_NUMBER: _ClassVar[int] + POSITION_FIELD_NUMBER: _ClassVar[int] + DESCRIPTION_FIELD_NUMBER: _ClassVar[int] + PARAMETERS_FIELD_NUMBER: _ClassVar[int] + EXPRESSIONS_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + TARGETPARAMETERS_FIELD_NUMBER: _ClassVar[int] + Table: str + Chain: str + UUID: str + Enabled: bool + Position: int + Description: str + Parameters: str + Expressions: _containers.RepeatedCompositeFieldContainer[Expressions] + Target: str + TargetParameters: str + def __init__(self, Table: _Optional[str] = ..., Chain: _Optional[str] = ..., UUID: _Optional[str] = ..., Enabled: _Optional[bool] = ..., Position: _Optional[int] = ..., Description: _Optional[str] = ..., Parameters: _Optional[str] = ..., Expressions: _Optional[_Iterable[_Union[Expressions, _Mapping]]] = ..., Target: _Optional[str] = ..., TargetParameters: _Optional[str] = ...) -> None: ... + +class FwChain(_message.Message): + __slots__ = () + NAME_FIELD_NUMBER: _ClassVar[int] + TABLE_FIELD_NUMBER: _ClassVar[int] + FAMILY_FIELD_NUMBER: _ClassVar[int] + PRIORITY_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + HOOK_FIELD_NUMBER: _ClassVar[int] + POLICY_FIELD_NUMBER: _ClassVar[int] + RULES_FIELD_NUMBER: _ClassVar[int] + Name: str + Table: str + Family: str + Priority: str + Type: str + Hook: str + Policy: str + Rules: _containers.RepeatedCompositeFieldContainer[FwRule] + def __init__(self, Name: _Optional[str] = ..., Table: _Optional[str] = ..., Family: _Optional[str] = ..., Priority: _Optional[str] = ..., Type: _Optional[str] = ..., Hook: _Optional[str] = ..., Policy: _Optional[str] = ..., Rules: _Optional[_Iterable[_Union[FwRule, _Mapping]]] = ...) -> None: ... + +class FwChains(_message.Message): + __slots__ = () + RULE_FIELD_NUMBER: _ClassVar[int] + CHAINS_FIELD_NUMBER: _ClassVar[int] + Rule: FwRule + Chains: _containers.RepeatedCompositeFieldContainer[FwChain] + def __init__(self, Rule: _Optional[_Union[FwRule, _Mapping]] = ..., Chains: _Optional[_Iterable[_Union[FwChain, _Mapping]]] = ...) -> None: ... + +class SysFirewall(_message.Message): + __slots__ = () + ENABLED_FIELD_NUMBER: _ClassVar[int] + VERSION_FIELD_NUMBER: _ClassVar[int] + SYSTEMRULES_FIELD_NUMBER: _ClassVar[int] + Enabled: bool + Version: int + SystemRules: _containers.RepeatedCompositeFieldContainer[FwChains] + def __init__(self, Enabled: _Optional[bool] = ..., Version: _Optional[int] = ..., SystemRules: _Optional[_Iterable[_Union[FwChains, _Mapping]]] = ...) -> None: ... + +class ClientConfig(_message.Message): + __slots__ = () + ID_FIELD_NUMBER: _ClassVar[int] + NAME_FIELD_NUMBER: _ClassVar[int] + VERSION_FIELD_NUMBER: _ClassVar[int] + ISFIREWALLRUNNING_FIELD_NUMBER: _ClassVar[int] + CONFIG_FIELD_NUMBER: _ClassVar[int] + LOGLEVEL_FIELD_NUMBER: _ClassVar[int] + RULES_FIELD_NUMBER: _ClassVar[int] + SYSTEMFIREWALL_FIELD_NUMBER: _ClassVar[int] + id: int + name: str + version: str + isFirewallRunning: bool + config: str + logLevel: int + rules: _containers.RepeatedCompositeFieldContainer[Rule] + systemFirewall: SysFirewall + def __init__(self, id: _Optional[int] = ..., name: _Optional[str] = ..., version: _Optional[str] = ..., isFirewallRunning: _Optional[bool] = ..., config: _Optional[str] = ..., logLevel: _Optional[int] = ..., rules: _Optional[_Iterable[_Union[Rule, _Mapping]]] = ..., systemFirewall: _Optional[_Union[SysFirewall, _Mapping]] = ...) -> None: ... + +class Notification(_message.Message): + __slots__ = () + ID_FIELD_NUMBER: _ClassVar[int] + CLIENTNAME_FIELD_NUMBER: _ClassVar[int] + SERVERNAME_FIELD_NUMBER: _ClassVar[int] + TYPE_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + RULES_FIELD_NUMBER: _ClassVar[int] + SYSFIREWALL_FIELD_NUMBER: _ClassVar[int] + id: int + clientName: str + serverName: str + type: Action + data: str + rules: _containers.RepeatedCompositeFieldContainer[Rule] + sysFirewall: SysFirewall + def __init__(self, id: _Optional[int] = ..., clientName: _Optional[str] = ..., serverName: _Optional[str] = ..., type: _Optional[_Union[Action, str]] = ..., data: _Optional[str] = ..., rules: _Optional[_Iterable[_Union[Rule, _Mapping]]] = ..., sysFirewall: _Optional[_Union[SysFirewall, _Mapping]] = ...) -> None: ... + +class NotificationReply(_message.Message): + __slots__ = () + ID_FIELD_NUMBER: _ClassVar[int] + CODE_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + id: int + code: NotificationReplyCode + data: str + def __init__(self, id: _Optional[int] = ..., code: _Optional[_Union[NotificationReplyCode, str]] = ..., data: _Optional[str] = ...) -> None: ... diff --git a/ui/opensnitch/proto/ui_pb2_grpc.py b/ui/opensnitch/proto/ui_pb2_grpc.py index 4e3a786551..4ad9187261 100644 --- a/ui/opensnitch/proto/ui_pb2_grpc.py +++ b/ui/opensnitch/proto/ui_pb2_grpc.py @@ -1,8 +1,28 @@ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc +import warnings -from . import ui_pb2 as ui__pb2 +import ui_pb2 as ui__pb2 + +GRPC_GENERATED_VERSION = '1.76.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + ' but the generated code in ui_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) class UIStub(object): @@ -18,27 +38,27 @@ def __init__(self, channel): '/protocol.UI/Ping', request_serializer=ui__pb2.PingRequest.SerializeToString, response_deserializer=ui__pb2.PingReply.FromString, - ) + _registered_method=True) self.AskRule = channel.unary_unary( '/protocol.UI/AskRule', request_serializer=ui__pb2.Connection.SerializeToString, response_deserializer=ui__pb2.Rule.FromString, - ) + _registered_method=True) self.Subscribe = channel.unary_unary( '/protocol.UI/Subscribe', request_serializer=ui__pb2.ClientConfig.SerializeToString, response_deserializer=ui__pb2.ClientConfig.FromString, - ) + _registered_method=True) self.Notifications = channel.stream_stream( '/protocol.UI/Notifications', request_serializer=ui__pb2.NotificationReply.SerializeToString, response_deserializer=ui__pb2.Notification.FromString, - ) + _registered_method=True) self.PostAlert = channel.unary_unary( '/protocol.UI/PostAlert', request_serializer=ui__pb2.Alert.SerializeToString, response_deserializer=ui__pb2.MsgResponse.FromString, - ) + _registered_method=True) class UIServicer(object): @@ -106,6 +126,7 @@ def add_UIServicer_to_server(servicer, server): generic_handler = grpc.method_handlers_generic_handler( 'protocol.UI', rpc_method_handlers) server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('protocol.UI', rpc_method_handlers) # This class is part of an EXPERIMENTAL API. @@ -123,11 +144,21 @@ def Ping(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/protocol.UI/Ping', + return grpc.experimental.unary_unary( + request, + target, + '/protocol.UI/Ping', ui__pb2.PingRequest.SerializeToString, ui__pb2.PingReply.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod def AskRule(request, @@ -140,11 +171,21 @@ def AskRule(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/protocol.UI/AskRule', + return grpc.experimental.unary_unary( + request, + target, + '/protocol.UI/AskRule', ui__pb2.Connection.SerializeToString, ui__pb2.Rule.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod def Subscribe(request, @@ -157,11 +198,21 @@ def Subscribe(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/protocol.UI/Subscribe', + return grpc.experimental.unary_unary( + request, + target, + '/protocol.UI/Subscribe', ui__pb2.ClientConfig.SerializeToString, ui__pb2.ClientConfig.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod def Notifications(request_iterator, @@ -174,11 +225,21 @@ def Notifications(request_iterator, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.stream_stream(request_iterator, target, '/protocol.UI/Notifications', + return grpc.experimental.stream_stream( + request_iterator, + target, + '/protocol.UI/Notifications', ui__pb2.NotificationReply.SerializeToString, ui__pb2.Notification.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) @staticmethod def PostAlert(request, @@ -191,8 +252,18 @@ def PostAlert(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary(request, target, '/protocol.UI/PostAlert', + return grpc.experimental.unary_unary( + request, + target, + '/protocol.UI/PostAlert', ui__pb2.Alert.SerializeToString, ui__pb2.MsgResponse.FromString, - options, channel_credentials, - insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) From bd1fbe36dae0adbad7d5573897e7855f010b0dfa Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Mon, 9 Mar 2026 11:58:24 +0100 Subject: [PATCH 05/13] migrate to Qt ui files --- .../plugins/list_subscriptions/_gui.py | 441 +++++++----------- .../list_subscriptions/bulk_edit_dialog.ui | 195 ++++++++ .../list_subscriptions_dialog.ui | 246 ++++++++++ .../list_subscriptions/subscription_dialog.ui | 321 +++++++++++++ 4 files changed, 942 insertions(+), 261 deletions(-) create mode 100644 ui/opensnitch/plugins/list_subscriptions/bulk_edit_dialog.ui create mode 100644 ui/opensnitch/plugins/list_subscriptions/list_subscriptions_dialog.ui create mode 100644 ui/opensnitch/plugins/list_subscriptions/subscription_dialog.ui diff --git a/ui/opensnitch/plugins/list_subscriptions/_gui.py b/ui/opensnitch/plugins/list_subscriptions/_gui.py index 6f08fbf47d..3d5e43c2a3 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_gui.py +++ b/ui/opensnitch/plugins/list_subscriptions/_gui.py @@ -11,21 +11,21 @@ if TYPE_CHECKING: # Keep static typing deterministic for linters/IDEs. # Runtime still supports both PyQt6/PyQt5 below. - from PyQt6 import QtCore, QtGui, QtWidgets + from PyQt6 import QtCore, QtGui, QtWidgets, uic from PyQt6.QtCore import QCoreApplication as QC else: if "PyQt5" in sys.modules: - from PyQt5 import QtCore, QtGui, QtWidgets + from PyQt5 import QtCore, QtGui, QtWidgets, uic from PyQt5.QtCore import QCoreApplication as QC elif "PyQt6" in sys.modules: - from PyQt6 import QtCore, QtGui, QtWidgets + from PyQt6 import QtCore, QtGui, QtWidgets, uic from PyQt6.QtCore import QCoreApplication as QC else: try: - from PyQt6 import QtCore, QtGui, QtWidgets + from PyQt6 import QtCore, QtGui, QtWidgets, uic from PyQt6.QtCore import QCoreApplication as QC except Exception: - from PyQt5 import QtCore, QtGui, QtWidgets + from PyQt5 import QtCore, QtGui, QtWidgets, uic from PyQt5.QtCore import QCoreApplication as QC from opensnitch.actions import Actions @@ -45,6 +45,15 @@ ACTION_FILE = os.path.join(xdg_config_home, "opensnitch", "actions", "list_subscriptions.json") DEFAULT_LISTS_DIR = os.path.join(xdg_config_home, "opensnitch", "list_subscriptions") +PLUGIN_DIR = os.path.abspath(os.path.dirname(__file__)) +LIST_SUBSCRIPTIONS_DIALOG_UI_PATH = os.path.join(PLUGIN_DIR, "list_subscriptions_dialog.ui") +SUBSCRIPTION_DIALOG_UI_PATH = os.path.join(PLUGIN_DIR, "subscription_dialog.ui") +BULK_EDIT_DIALOG_UI_PATH = os.path.join(PLUGIN_DIR, "bulk_edit_dialog.ui") + +SubscriptionDialogUI = uic.loadUiType(SUBSCRIPTION_DIALOG_UI_PATH)[0] # type: ignore +BulkEditDialogUI = uic.loadUiType(BULK_EDIT_DIALOG_UI_PATH)[0] # type: ignore +ListSubscriptionsDialogUI = uic.loadUiType(LIST_SUBSCRIPTIONS_DIALOG_UI_PATH)[0] # type: ignore + INTERVAL_UNITS = ("seconds", "minutes", "hours", "days", "weeks") TIMEOUT_UNITS = ("seconds", "minutes", "hours", "days", "weeks") SIZE_UNITS = ("bytes", "KB", "MB", "GB") @@ -101,7 +110,34 @@ def _template_action() -> dict[str, Any]: } -class SubscriptionDialog(QtWidgets.QDialog): +class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): + if TYPE_CHECKING: + enabled_check: QtWidgets.QCheckBox + name_edit: QtWidgets.QLineEdit + url_edit: QtWidgets.QLineEdit + filename_edit: QtWidgets.QLineEdit + format_combo: QtWidgets.QComboBox + group_combo: 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 + meta_group: QtWidgets.QGroupBox + meta_file_present: QtWidgets.QLabel + meta_meta_present: QtWidgets.QLabel + meta_state: QtWidgets.QLabel + meta_last_checked: QtWidgets.QLabel + meta_last_updated: QtWidgets.QLabel + meta_failures: QtWidgets.QLabel + meta_error: QtWidgets.QLabel + meta_list_path: QtWidgets.QLabel + meta_meta_path: QtWidgets.QLabel + error_label: QtWidgets.QLabel + cancel_button: QtWidgets.QPushButton + add_button: QtWidgets.QPushButton + def __init__( self, parent: QtWidgets.QWidget | None, @@ -113,6 +149,7 @@ def __init__( ) -> None: super().__init__(parent) self.setWindowTitle(QC.translate("stats", title)) + self._title = title self._defaults = defaults self._groups = groups or ["all"] self._sub = sub or {} @@ -120,34 +157,19 @@ def __init__( self._build_ui() def _build_ui(self) -> None: - root = QtWidgets.QVBoxLayout(self) - body = QtWidgets.QHBoxLayout() - settings_group = QtWidgets.QGroupBox(QC.translate("stats", "Settings")) - settings_form = QtWidgets.QFormLayout(settings_group) + self.setupUi(self) + self.error_label.setStyleSheet("color: red;") + self.group_combo.setEditable(True) + self.add_button.clicked.connect(self._validate_then_accept) + self.cancel_button.clicked.connect(self.reject) - self.enabled_check = QtWidgets.QCheckBox(QC.translate("stats", "Enabled")) self.enabled_check.setChecked(bool(self._sub.get("enabled", True))) - settings_form.addRow(self.enabled_check) - - self.name_edit = QtWidgets.QLineEdit() self.name_edit.setText(str(self._sub.get("name", ""))) - settings_form.addRow(QC.translate("stats", "Name"), self.name_edit) - - self.url_edit = QtWidgets.QLineEdit() self.url_edit.setText(str(self._sub.get("url", ""))) - settings_form.addRow(QC.translate("stats", "URL"), self.url_edit) - - self.filename_edit = QtWidgets.QLineEdit() self.filename_edit.setText(str(self._sub.get("filename", ""))) - settings_form.addRow(QC.translate("stats", "Filename"), self.filename_edit) - - self.format_combo = QtWidgets.QComboBox() + self.format_combo.clear() self.format_combo.addItems(("hosts",)) self.format_combo.setCurrentText(str(self._sub.get("format", "hosts"))) - settings_form.addRow(QC.translate("stats", "Format"), self.format_combo) - - self.group_combo = QtWidgets.QComboBox() - self.group_combo.setEditable(True) for g in self._groups: ng = normalize_group(g) if ng != "": @@ -157,95 +179,38 @@ def _build_ui(self) -> None: if self.group_combo.findText(current_group_text) < 0: self.group_combo.addItem(current_group_text) self.group_combo.setCurrentText(current_group_text) - settings_form.addRow(QC.translate("stats", "Groups"), self.group_combo) - - self.interval_spin = QtWidgets.QSpinBox() self.interval_spin.setRange(1, 999999) self.interval_spin.setValue(max(1, int(self._sub.get("interval", self._defaults.interval)))) - self.interval_units = QtWidgets.QComboBox() + self.interval_units.clear() self.interval_units.addItems(INTERVAL_UNITS) self.interval_units.setCurrentText( self._normalize_unit(str(self._sub.get("interval_units", self._defaults.interval_units)), INTERVAL_UNITS, "hours") ) - interval_row = QtWidgets.QHBoxLayout() - interval_row.addWidget(self.interval_spin) - interval_row.addWidget(self.interval_units) - interval_wrap = QtWidgets.QWidget() - interval_wrap.setLayout(interval_row) - settings_form.addRow(QC.translate("stats", "Interval"), interval_wrap) - - self.timeout_spin = QtWidgets.QSpinBox() self.timeout_spin.setRange(1, 999999) self.timeout_spin.setValue(max(1, int(self._sub.get("timeout", self._defaults.timeout)))) - self.timeout_units = QtWidgets.QComboBox() + self.timeout_units.clear() self.timeout_units.addItems(TIMEOUT_UNITS) self.timeout_units.setCurrentText( self._normalize_unit(str(self._sub.get("timeout_units", self._defaults.timeout_units)), TIMEOUT_UNITS, "seconds") ) - timeout_row = QtWidgets.QHBoxLayout() - timeout_row.addWidget(self.timeout_spin) - timeout_row.addWidget(self.timeout_units) - timeout_wrap = QtWidgets.QWidget() - timeout_wrap.setLayout(timeout_row) - settings_form.addRow(QC.translate("stats", "Timeout"), timeout_wrap) - - self.max_size_spin = QtWidgets.QSpinBox() self.max_size_spin.setRange(1, 999999) self.max_size_spin.setValue(max(1, int(self._sub.get("max_size", self._defaults.max_size)))) - self.max_size_units = QtWidgets.QComboBox() + self.max_size_units.clear() self.max_size_units.addItems(SIZE_UNITS) self.max_size_units.setCurrentText( self._normalize_unit(str(self._sub.get("max_size_units", self._defaults.max_size_units)), SIZE_UNITS, "MB") ) - max_row = QtWidgets.QHBoxLayout() - max_row.addWidget(self.max_size_spin) - max_row.addWidget(self.max_size_units) - max_wrap = QtWidgets.QWidget() - max_wrap.setLayout(max_row) - settings_form.addRow(QC.translate("stats", "Max size"), max_wrap) - - body.addWidget(settings_group, 1) - - meta_group = QtWidgets.QGroupBox(QC.translate("stats", "Metadata")) - meta_form = QtWidgets.QFormLayout(meta_group) - self.meta_file_present = QtWidgets.QLabel(str(self._meta.get("file_present", ""))) - self.meta_meta_present = QtWidgets.QLabel(str(self._meta.get("meta_present", ""))) - self.meta_state = QtWidgets.QLabel(str(self._meta.get("state", ""))) - self.meta_last_checked = QtWidgets.QLabel(str(self._meta.get("last_checked", ""))) - self.meta_last_updated = QtWidgets.QLabel(str(self._meta.get("last_updated", ""))) - self.meta_failures = QtWidgets.QLabel(str(self._meta.get("failures", ""))) - self.meta_error = QtWidgets.QLabel(str(self._meta.get("error", ""))) - self.meta_list_path = QtWidgets.QLabel(str(self._meta.get("list_path", ""))) - self.meta_list_path.setWordWrap(True) - self.meta_meta_path = QtWidgets.QLabel(str(self._meta.get("meta_path", ""))) - self.meta_meta_path.setWordWrap(True) - meta_form.addRow(QC.translate("stats", "List file present"), self.meta_file_present) - meta_form.addRow(QC.translate("stats", "List meta present"), self.meta_meta_present) - meta_form.addRow(QC.translate("stats", "State"), self.meta_state) - meta_form.addRow(QC.translate("stats", "Last checked"), self.meta_last_checked) - meta_form.addRow(QC.translate("stats", "Last updated"), self.meta_last_updated) - meta_form.addRow(QC.translate("stats", "Failures"), self.meta_failures) - meta_form.addRow(QC.translate("stats", "Error"), self.meta_error) - meta_form.addRow(QC.translate("stats", "List path"), self.meta_list_path) - meta_form.addRow(QC.translate("stats", "Meta path"), self.meta_meta_path) - body.addWidget(meta_group, 1) - - root.addLayout(body) - - self.error_label = QtWidgets.QLabel("") - self.error_label.setStyleSheet("color: red;") - root.addWidget(self.error_label) - - buttons = QtWidgets.QHBoxLayout() - buttons.addStretch(1) - self.cancel_button = QtWidgets.QPushButton(QC.translate("stats", "Cancel")) - self.add_button = QtWidgets.QPushButton(QC.translate("stats", "Save")) - self.cancel_button.clicked.connect(self.reject) - self.add_button.clicked.connect(self._validate_then_accept) - buttons.addWidget(self.cancel_button) - buttons.addWidget(self.add_button) - root.addLayout(buttons) - + 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.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 _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str) -> str: @@ -260,6 +225,24 @@ def _validate_then_accept(self) -> None: if url == "": self.error_label.setText(QC.translate("stats", "URL is required.")) return + name = (self.name_edit.text() or "").strip() + filename = os.path.basename((self.filename_edit.text() or "").strip()) + list_type = (self.format_combo.currentText() or "hosts").strip().lower() + + if name == "" and filename == "": + self.error_label.setText(QC.translate("stats", "Provide at least a name or a filename.")) + return + + if filename == "" and name != "": + filename = self._slugify_name(name) + filename = ensure_filename_type_suffix(filename, list_type) + + if name == "" and filename != "": + name = self._deslugify_filename(filename, list_type) + + self.name_edit.setText(name) + self.filename_edit.setText(filename) + groups = normalize_groups(self.group_combo.currentText()) if not groups: self.error_label.setText(QC.translate("stats", "At least one group is required.")) @@ -267,6 +250,29 @@ def _validate_then_accept(self) -> None: self.error_label.setText("") self.accept() + def _slugify_name(self, name: str) -> str: + 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 slug + + def _deslugify_filename(self, filename: str, list_type: str) -> str: + safe = os.path.basename((filename or "").strip()) + base, _ext = os.path.splitext(safe) + suffix = f"-{(list_type or 'hosts').strip().lower()}" + 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 subscription_dict(self) -> dict[str, Any]: groups = normalize_groups((self.group_combo.currentText() or "all").strip()) return { @@ -285,7 +291,27 @@ def subscription_dict(self) -> dict[str, Any]: } -class BulkEditDialog(QtWidgets.QDialog): +class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI): + if TYPE_CHECKING: + apply_enabled: QtWidgets.QCheckBox + enabled_value: QtWidgets.QCheckBox + apply_group: QtWidgets.QCheckBox + group_value: QtWidgets.QComboBox + apply_format: QtWidgets.QCheckBox + format_value: QtWidgets.QComboBox + apply_interval: QtWidgets.QCheckBox + interval_spin: QtWidgets.QSpinBox + interval_units: QtWidgets.QComboBox + apply_timeout: QtWidgets.QCheckBox + timeout_spin: QtWidgets.QSpinBox + timeout_units: QtWidgets.QComboBox + apply_max_size: QtWidgets.QCheckBox + max_size_spin: QtWidgets.QSpinBox + max_size_units: QtWidgets.QComboBox + error_label: QtWidgets.QLabel + cancel_button: QtWidgets.QPushButton + save_button: QtWidgets.QPushButton + def __init__( self, parent: QtWidgets.QWidget | None, @@ -299,22 +325,14 @@ def __init__( self._build_ui() def _build_ui(self) -> None: - root = QtWidgets.QVBoxLayout(self) - form = QtWidgets.QFormLayout() + self.setupUi(self) + self.error_label.setStyleSheet("color: red;") + self.group_value.setEditable(True) + self.cancel_button.clicked.connect(self.reject) + self.save_button.clicked.connect(self._validate_then_accept) - self.apply_enabled = QtWidgets.QCheckBox(QC.translate("stats", "Apply enabled")) - self.enabled_value = QtWidgets.QCheckBox(QC.translate("stats", "Enabled")) self.enabled_value.setChecked(True) - enabled_row = QtWidgets.QHBoxLayout() - enabled_row.addWidget(self.apply_enabled) - enabled_row.addWidget(self.enabled_value) - enabled_wrap = QtWidgets.QWidget() - enabled_wrap.setLayout(enabled_row) - form.addRow(enabled_wrap) - - self.apply_group = QtWidgets.QCheckBox(QC.translate("stats", "Apply groups")) - self.group_value = QtWidgets.QComboBox() - self.group_value.setEditable(True) + self.group_value.clear() for g in self._groups: ng = normalize_group(g) if ng != "": @@ -322,82 +340,23 @@ def _build_ui(self) -> None: if self.group_value.findText("all") < 0: self.group_value.addItem("all") self.group_value.setCurrentText("all") - group_row = QtWidgets.QHBoxLayout() - group_row.addWidget(self.apply_group) - group_row.addWidget(self.group_value) - group_wrap = QtWidgets.QWidget() - group_wrap.setLayout(group_row) - form.addRow(QC.translate("stats", "Groups"), group_wrap) - - self.apply_format = QtWidgets.QCheckBox(QC.translate("stats", "Apply format")) - self.format_value = QtWidgets.QComboBox() + self.format_value.clear() self.format_value.addItems(("hosts",)) - format_row = QtWidgets.QHBoxLayout() - format_row.addWidget(self.apply_format) - format_row.addWidget(self.format_value) - format_wrap = QtWidgets.QWidget() - format_wrap.setLayout(format_row) - form.addRow(QC.translate("stats", "Format"), format_wrap) - - self.apply_interval = QtWidgets.QCheckBox(QC.translate("stats", "Apply interval")) - self.interval_spin = QtWidgets.QSpinBox() self.interval_spin.setRange(1, 999999) self.interval_spin.setValue(max(1, int(self._defaults.interval))) - self.interval_units = QtWidgets.QComboBox() + self.interval_units.clear() self.interval_units.addItems(INTERVAL_UNITS) self.interval_units.setCurrentText(self._normalize_unit(self._defaults.interval_units, INTERVAL_UNITS, "hours")) - interval_row = QtWidgets.QHBoxLayout() - interval_row.addWidget(self.apply_interval) - interval_row.addWidget(self.interval_spin) - interval_row.addWidget(self.interval_units) - interval_wrap = QtWidgets.QWidget() - interval_wrap.setLayout(interval_row) - form.addRow(QC.translate("stats", "Interval"), interval_wrap) - - self.apply_timeout = QtWidgets.QCheckBox(QC.translate("stats", "Apply timeout")) - self.timeout_spin = QtWidgets.QSpinBox() self.timeout_spin.setRange(1, 999999) self.timeout_spin.setValue(max(1, int(self._defaults.timeout))) - self.timeout_units = QtWidgets.QComboBox() + self.timeout_units.clear() self.timeout_units.addItems(TIMEOUT_UNITS) self.timeout_units.setCurrentText(self._normalize_unit(self._defaults.timeout_units, TIMEOUT_UNITS, "seconds")) - timeout_row = QtWidgets.QHBoxLayout() - timeout_row.addWidget(self.apply_timeout) - timeout_row.addWidget(self.timeout_spin) - timeout_row.addWidget(self.timeout_units) - timeout_wrap = QtWidgets.QWidget() - timeout_wrap.setLayout(timeout_row) - form.addRow(QC.translate("stats", "Timeout"), timeout_wrap) - - self.apply_max_size = QtWidgets.QCheckBox(QC.translate("stats", "Apply max size")) - self.max_size_spin = QtWidgets.QSpinBox() self.max_size_spin.setRange(1, 999999) self.max_size_spin.setValue(max(1, int(self._defaults.max_size))) - self.max_size_units = QtWidgets.QComboBox() + self.max_size_units.clear() self.max_size_units.addItems(SIZE_UNITS) self.max_size_units.setCurrentText(self._normalize_unit(self._defaults.max_size_units, SIZE_UNITS, "MB")) - max_row = QtWidgets.QHBoxLayout() - max_row.addWidget(self.apply_max_size) - max_row.addWidget(self.max_size_spin) - max_row.addWidget(self.max_size_units) - max_wrap = QtWidgets.QWidget() - max_wrap.setLayout(max_row) - form.addRow(QC.translate("stats", "Max size"), max_wrap) - - root.addLayout(form) - self.error_label = QtWidgets.QLabel("") - self.error_label.setStyleSheet("color: red;") - root.addWidget(self.error_label) - - buttons = QtWidgets.QHBoxLayout() - buttons.addStretch(1) - cancel_btn = QtWidgets.QPushButton(QC.translate("stats", "Cancel")) - save_btn = QtWidgets.QPushButton(QC.translate("stats", "Apply")) - cancel_btn.clicked.connect(self.reject) - save_btn.clicked.connect(self._validate_then_accept) - buttons.addWidget(cancel_btn) - buttons.addWidget(save_btn) - root.addLayout(buttons) self.resize(640, 360) def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str) -> str: @@ -437,7 +396,31 @@ def values(self) -> dict[str, Any]: } -class ListSubscriptionsDialog(QtWidgets.QDialog): +class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): + if TYPE_CHECKING: + enable_plugin_check: QtWidgets.QCheckBox + create_file_button: QtWidgets.QPushButton + save_button: QtWidgets.QPushButton + reload_button: QtWidgets.QPushButton + lists_dir_edit: QtWidgets.QLineEdit + default_interval_spin: QtWidgets.QSpinBox + default_interval_units: QtWidgets.QComboBox + default_timeout_spin: QtWidgets.QSpinBox + default_timeout_units: QtWidgets.QComboBox + default_max_size_spin: QtWidgets.QSpinBox + default_max_size_units: QtWidgets.QComboBox + default_user_agent: QtWidgets.QLineEdit + nodes_combo: QtWidgets.QComboBox + table: QtWidgets.QTableWidget + add_sub_button: QtWidgets.QPushButton + refresh_state_button: QtWidgets.QPushButton + create_global_rule_button: QtWidgets.QPushButton + edit_sub_button: QtWidgets.QPushButton + remove_sub_button: QtWidgets.QPushButton + refresh_now_button: QtWidgets.QPushButton + create_rule_button: QtWidgets.QPushButton + status_label: QtWidgets.QLabel + _download_finished = QtCore.pyqtSignal() def __init__( @@ -480,60 +463,20 @@ def closeEvent(self, event: QtGui.QCloseEvent) -> None: # type: ignore super().closeEvent(event) def _build_ui(self) -> None: + self.setupUi(self) + self.setWindowTitle(QC.translate("stats", "List subscriptions")) self.resize(1180, 680) - root = QtWidgets.QVBoxLayout(self) - - top_row = QtWidgets.QHBoxLayout() - self.enable_plugin_check = QtWidgets.QCheckBox(QC.translate("stats", "Enable list subscriptions plugin")) - self.create_file_button = QtWidgets.QPushButton(QC.translate("stats", "Create action file")) - self.save_button = QtWidgets.QPushButton(QC.translate("stats", "Save")) - self.reload_button = QtWidgets.QPushButton(QC.translate("stats", "Reload")) - top_row.addWidget(self.enable_plugin_check) - top_row.addStretch(1) - top_row.addWidget(self.create_file_button) - top_row.addWidget(self.save_button) - top_row.addWidget(self.reload_button) - root.addLayout(top_row) - - defaults_row = QtWidgets.QGridLayout() - defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Lists directory")), 0, 0) - self.lists_dir_edit = QtWidgets.QLineEdit() - defaults_row.addWidget(self.lists_dir_edit, 0, 1, 1, 5) - - defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Default interval")), 1, 0) - self.default_interval_spin = QtWidgets.QSpinBox() + self.default_interval_spin.setRange(1, 999999) - defaults_row.addWidget(self.default_interval_spin, 1, 1) - self.default_interval_units = QtWidgets.QComboBox() + self.default_interval_units.clear() self.default_interval_units.addItems(INTERVAL_UNITS) - defaults_row.addWidget(self.default_interval_units, 1, 2) - - defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Default timeout")), 1, 3) - self.default_timeout_spin = QtWidgets.QSpinBox() self.default_timeout_spin.setRange(1, 999999) - defaults_row.addWidget(self.default_timeout_spin, 1, 4) - self.default_timeout_units = QtWidgets.QComboBox() + self.default_timeout_units.clear() self.default_timeout_units.addItems(TIMEOUT_UNITS) - defaults_row.addWidget(self.default_timeout_units, 1, 5) - - defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Default max size")), 2, 0) - self.default_max_size_spin = QtWidgets.QSpinBox() self.default_max_size_spin.setRange(1, 999999) - defaults_row.addWidget(self.default_max_size_spin, 2, 1) - self.default_max_size_units = QtWidgets.QComboBox() + self.default_max_size_units.clear() self.default_max_size_units.addItems(SIZE_UNITS) - defaults_row.addWidget(self.default_max_size_units, 2, 2) - - defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Default User-Agent")), 2, 3) - self.default_user_agent = QtWidgets.QLineEdit() - defaults_row.addWidget(self.default_user_agent, 2, 4, 1, 2) - - defaults_row.addWidget(QtWidgets.QLabel(QC.translate("stats", "Node")), 3, 0) - self.nodes_combo = QtWidgets.QComboBox() - defaults_row.addWidget(self.nodes_combo, 3, 1, 1, 2) - root.addLayout(defaults_row) - self.table = QtWidgets.QTableWidget() self.table.setColumnCount(19) self.table.setHorizontalHeaderLabels([ QC.translate("stats", "Enabled"), @@ -579,38 +522,6 @@ def _build_ui(self) -> None: COL_ERROR, ): self.table.setColumnHidden(col, True) - root.addWidget(self.table) - - actions_row = QtWidgets.QHBoxLayout() - - global_box = QtWidgets.QGroupBox(QC.translate("stats", "Global actions")) - global_layout = QtWidgets.QHBoxLayout(global_box) - self.add_sub_button = QtWidgets.QPushButton(QC.translate("stats", "Add subscription")) - self.refresh_state_button = QtWidgets.QPushButton(QC.translate("stats", "Refresh all")) - self.create_global_rule_button = QtWidgets.QPushButton(QC.translate("stats", "Create global rule")) - global_layout.addWidget(self.add_sub_button) - global_layout.addWidget(self.refresh_state_button) - global_layout.addWidget(self.create_global_rule_button) - global_layout.addStretch(1) - - selected_box = QtWidgets.QGroupBox(QC.translate("stats", "Rule actions")) - selected_layout = QtWidgets.QHBoxLayout(selected_box) - self.edit_sub_button = QtWidgets.QPushButton(QC.translate("stats", "Edit")) - self.remove_sub_button = QtWidgets.QPushButton(QC.translate("stats", "Remove")) - self.refresh_now_button = QtWidgets.QPushButton(QC.translate("stats", "Refresh now")) - self.create_rule_button = QtWidgets.QPushButton(QC.translate("stats", "Create rule")) - selected_layout.addWidget(self.edit_sub_button) - selected_layout.addWidget(self.remove_sub_button) - selected_layout.addWidget(self.refresh_now_button) - selected_layout.addWidget(self.create_rule_button) - selected_layout.addStretch(1) - - actions_row.addWidget(global_box, 1) - actions_row.addWidget(selected_box, 2) - root.addLayout(actions_row) - - self.status_label = QtWidgets.QLabel("") - root.addWidget(self.status_label) self.create_file_button.clicked.connect(self.create_action_file) self.save_button.clicked.connect(self.save_action_file) @@ -859,9 +770,14 @@ def edit_selected_subscription(self) -> None: self._set_status(QC.translate("stats", "Select a subscription row first."), error=True) return + enabled_item = self.table.item(row, COL_ENABLED) + if enabled_item is None: + enabled_item = QtWidgets.QTableWidgetItem("") + enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + self.table.setItem(row, COL_ENABLED, enabled_item) + sub = { - "enabled": self.table.item(row, COL_ENABLED) is not None - and self.table.item(row, COL_ENABLED).checkState() == QtCore.Qt.CheckState.Checked, + "enabled": enabled_item.checkState() == QtCore.Qt.CheckState.Checked, "name": self._cell_text(row, COL_NAME), "url": self._cell_text(row, COL_URL), "filename": self._cell_text(row, COL_FILENAME), @@ -973,12 +889,15 @@ def _open_table_context_menu(self, pos: QtCore.QPoint) -> None: return menu = QtWidgets.QMenu(self.table) + viewport = self.table.viewport() + if viewport is None: + return if len(rows) == 1: act_edit = menu.addAction(QC.translate("stats", "Edit")) - act_remove = menu.addAction(QC.translate("stats", "Remove")) + act_remove = menu.addAction(QC.translate("stats", "Delete")) act_refresh = menu.addAction(QC.translate("stats", "Refresh now")) act_rule = menu.addAction(QC.translate("stats", "Create rule")) - chosen = menu.exec(self.table.viewport().mapToGlobal(pos)) + chosen = menu.exec(viewport.mapToGlobal(pos)) if chosen is act_edit: self.edit_selected_subscription() elif chosen is act_remove: @@ -990,9 +909,9 @@ def _open_table_context_menu(self, pos: QtCore.QPoint) -> None: return act_edit = menu.addAction(QC.translate("stats", "Edit")) - act_remove = menu.addAction(QC.translate("stats", "Remove")) + act_remove = menu.addAction(QC.translate("stats", "Delete")) act_rule = menu.addAction(QC.translate("stats", "Create rule")) - chosen = menu.exec(self.table.viewport().mapToGlobal(pos)) + chosen = menu.exec(viewport.mapToGlobal(pos)) if chosen is act_edit: self._bulk_edit(rows) elif chosen is act_remove: diff --git a/ui/opensnitch/plugins/list_subscriptions/bulk_edit_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/bulk_edit_dialog.ui new file mode 100644 index 0000000000..c512cd377f --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/bulk_edit_dialog.ui @@ -0,0 +1,195 @@ + + + BulkEditDialog + + + + 0 + 0 + 640 + 360 + + + + Edit selected subscriptions + + + + + + + + + + Apply enabled + + + + + + + Enabled + + + + + + + + + Groups + + + + + + + + + Apply groups + + + + + + + + + + + + Format + + + + + + + + + Apply format + + + + + + + + + + + + Interval + + + + + + + + + Apply interval + + + + + + + + + + + + + + + Timeout + + + + + + + + + Apply timeout + + + + + + + + + + + + + + + Max size + + + + + + + + + Apply max size + + + + + + + + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + + + + + Apply + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions_dialog.ui new file mode 100644 index 0000000000..c191cfc6b8 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions_dialog.ui @@ -0,0 +1,246 @@ + + + ListSubscriptionsDialog + + + + 0 + 0 + 1180 + 680 + + + + List subscriptions + + + + + + + + Enable list subscriptions plugin + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Create action file + + + + + + + Save + + + + + + + Reload + + + + + + + + + + + Lists directory + + + + + + + + + + Default interval + + + + + + + + + + + + + Default timeout + + + + + + + + + + + + + Default max size + + + + + + + + + + + + + Default User-Agent + + + + + + + + + + Node + + + + + + + + + + + + + + + + + Global actions + + + + + + Add subscription + + + + + + + Refresh all + + + + + + + Create global rule + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + Rule actions + + + + + + Edit + + + + + + + Delete + + + + + + + Refresh now + + + + + + + Create rule + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/subscription_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/subscription_dialog.ui new file mode 100644 index 0000000000..e7971b8c2d --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/subscription_dialog.ui @@ -0,0 +1,321 @@ + + + SubscriptionDialog + + + + 0 + 0 + 920 + 420 + + + + Subscription + + + + + + + + Settings + + + + + + Enabled + + + + + + + Name + + + + + + + + + + URL + + + + + + + + + + Filename + + + + + + + + + + Format + + + + + + + + + + Groups + + + + + + + + + + Interval + + + + + + + + + + + + + + + + + Timeout + + + + + + + + + + + + + + + + + Max size + + + + + + + + + + + + + + + + + + + + Metadata + + + + + + List file present + + + + + + + + + + + + + + List meta present + + + + + + + + + + + + + + State + + + + + + + + + + + + + + Last checked + + + + + + + + + + + + + + Last updated + + + + + + + + + + + + + + Failures + + + + + + + + + + + + + + Error + + + + + + + + + + + + + + List path + + + + + + + + + + true + + + + + + + Meta path + + + + + + + + + + true + + + + + + + + + + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Cancel + + + + + + + Save + + + + + + + + + + From 76aafd4f6ab1f18bc63b585cb4044150827b9566 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Mon, 9 Mar 2026 17:46:11 +0100 Subject: [PATCH 06/13] migrate live mutable config object in ui to mutable dataclasses --- .../plugins/list_subscriptions/_gui.py | 584 ++++++++---------- .../plugins/list_subscriptions/_models.py | 149 +++++ 2 files changed, 413 insertions(+), 320 deletions(-) diff --git a/ui/opensnitch/plugins/list_subscriptions/_gui.py b/ui/opensnitch/plugins/list_subscriptions/_gui.py index 3d5e43c2a3..f72588e1db 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_gui.py +++ b/ui/opensnitch/plugins/list_subscriptions/_gui.py @@ -6,7 +6,7 @@ import threading from urllib.parse import urlparse, unquote from datetime import datetime -from typing import Any, TYPE_CHECKING +from typing import cast, Any, TYPE_CHECKING if TYPE_CHECKING: # Keep static typing deterministic for linters/IDEs. @@ -33,6 +33,8 @@ from opensnitch.utils.xdg import xdg_config_home from opensnitch.plugins.list_subscriptions._models import ( GlobalDefaults, + MutableActionConfig, + MutableSubscriptionSpec, PluginConfig, SubscriptionSpec, ensure_filename_type_suffix, @@ -40,7 +42,9 @@ normalize_groups, normalize_lists_dir, ) +from opensnitch.dialogs.ruleseditor import RulesEditorDialog import requests +from .list_subscriptions import ListSubscriptions ACTION_FILE = os.path.join(xdg_config_home, "opensnitch", "actions", "list_subscriptions.json") @@ -81,35 +85,6 @@ logger = logging.getLogger(__name__) -def _template_action() -> dict[str, Any]: - return { - "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": DEFAULT_LISTS_DIR, - "interval": 24, - "interval_units": "hours", - "timeout": 20, - "timeout_units": "seconds", - "max_size": 50, - "max_size_units": "MB", - "subscriptions": [], - "notify": { - "success": {"desktop": "Lists subscriptions updated"}, - "error": {"desktop": "Error updating lists subscriptions"}, - }, - }, - } - }, - } - - class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): if TYPE_CHECKING: enabled_check: QtWidgets.QCheckBox @@ -137,68 +112,82 @@ class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): error_label: QtWidgets.QLabel 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: dict[str, Any] | None = None, + sub: MutableSubscriptionSpec | None = None, meta: dict[str, str] | None = None, title: str = "Subscription", - ) -> None: + ): super().__init__(parent) self.setWindowTitle(QC.translate("stats", title)) self._title = title self._defaults = defaults self._groups = groups or ["all"] - self._sub = sub or {} + self._sub = sub or MutableSubscriptionSpec( + enabled=True, + groups=["all"], + interval=self._defaults.interval, + interval_units=self._defaults.interval_units, + timeout=self._defaults.timeout, + timeout_units=self._defaults.timeout_units, + max_size=self._defaults.max_size, + max_size_units=self._defaults.max_size_units, + ) self._meta = meta or {} self._build_ui() - def _build_ui(self) -> None: + def _build_ui(self): self.setupUi(self) self.error_label.setStyleSheet("color: red;") self.group_combo.setEditable(True) self.add_button.clicked.connect(self._validate_then_accept) self.cancel_button.clicked.connect(self.reject) - self.enabled_check.setChecked(bool(self._sub.get("enabled", True))) - self.name_edit.setText(str(self._sub.get("name", ""))) - self.url_edit.setText(str(self._sub.get("url", ""))) - self.filename_edit.setText(str(self._sub.get("filename", ""))) + 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.get("format", "hosts"))) + self.format_combo.setCurrentText(str(self._sub.format or "hosts")) for g in self._groups: ng = normalize_group(g) if ng != "": self.group_combo.addItem(ng) - current_groups = normalize_groups(self._sub.get("groups")) + current_groups = normalize_groups(self._sub.groups) current_group_text = ", ".join(current_groups) if self.group_combo.findText(current_group_text) < 0: self.group_combo.addItem(current_group_text) self.group_combo.setCurrentText(current_group_text) self.interval_spin.setRange(1, 999999) - self.interval_spin.setValue(max(1, int(self._sub.get("interval", self._defaults.interval)))) + self.interval_spin.setValue(max(1, int(self._sub.interval))) self.interval_units.clear() self.interval_units.addItems(INTERVAL_UNITS) self.interval_units.setCurrentText( - self._normalize_unit(str(self._sub.get("interval_units", self._defaults.interval_units)), INTERVAL_UNITS, "hours") + self._normalize_unit(str(self._sub.interval_units), INTERVAL_UNITS, "hours") ) self.timeout_spin.setRange(1, 999999) - self.timeout_spin.setValue(max(1, int(self._sub.get("timeout", self._defaults.timeout)))) + self.timeout_spin.setValue(max(1, int(self._sub.timeout))) self.timeout_units.clear() self.timeout_units.addItems(TIMEOUT_UNITS) self.timeout_units.setCurrentText( - self._normalize_unit(str(self._sub.get("timeout_units", self._defaults.timeout_units)), TIMEOUT_UNITS, "seconds") + self._normalize_unit(str(self._sub.timeout_units), TIMEOUT_UNITS, "seconds") ) self.max_size_spin.setRange(1, 999999) - self.max_size_spin.setValue(max(1, int(self._sub.get("max_size", self._defaults.max_size)))) + self.max_size_spin.setValue(max(1, int(self._sub.max_size))) self.max_size_units.clear() self.max_size_units.addItems(SIZE_UNITS) self.max_size_units.setCurrentText( - self._normalize_unit(str(self._sub.get("max_size_units", self._defaults.max_size_units)), SIZE_UNITS, "MB") + self._normalize_unit(str(self._sub.max_size_units), SIZE_UNITS, "MB") ) self.meta_file_present.setText(str(self._meta.get("file_present", ""))) self.meta_meta_present.setText(str(self._meta.get("meta_present", ""))) @@ -213,14 +202,14 @@ def _build_ui(self) -> None: self.meta_group.setVisible(False) self.resize(920, 420) - def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str) -> str: + def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): normalized = (value or "").strip().lower() for unit in allowed: if unit.lower() == normalized: return unit return fallback - def _validate_then_accept(self) -> None: + def _validate_then_accept(self): url = (self.url_edit.text() or "").strip() if url == "": self.error_label.setText(QC.translate("stats", "URL is required.")) @@ -250,7 +239,7 @@ def _validate_then_accept(self) -> None: self.error_label.setText("") self.accept() - def _slugify_name(self, name: str) -> str: + def _slugify_name(self, name: str): raw = (name or "").strip().lower() if raw == "": return "subscription.list" @@ -261,7 +250,7 @@ def _slugify_name(self, name: str) -> str: slug += ".list" return slug - def _deslugify_filename(self, filename: str, list_type: str) -> str: + def _deslugify_filename(self, filename: str, list_type: str): safe = os.path.basename((filename or "").strip()) base, _ext = os.path.splitext(safe) suffix = f"-{(list_type or 'hosts').strip().lower()}" @@ -273,22 +262,22 @@ def _deslugify_filename(self, filename: str, list_type: str) -> str: return safe return pretty.title() - def subscription_dict(self) -> dict[str, Any]: + def subscription_spec(self): groups = normalize_groups((self.group_combo.currentText() or "all").strip()) - return { - "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()), - "interval_units": self.interval_units.currentText(), - "timeout": int(self.timeout_spin.value()), - "timeout_units": self.timeout_units.currentText(), - "max_size": int(self.max_size_spin.value()), - "max_size_units": self.max_size_units.currentText(), - } + 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()), + interval_units=self.interval_units.currentText(), + timeout=int(self.timeout_spin.value()), + timeout_units=self.timeout_units.currentText(), + max_size=int(self.max_size_spin.value()), + max_size_units=self.max_size_units.currentText(), + ) class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI): @@ -311,20 +300,22 @@ class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI): error_label: QtWidgets.QLabel 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, - ) -> None: + ): super().__init__(parent) self.setWindowTitle(QC.translate("stats", "Edit selected subscriptions")) self._defaults = defaults self._groups = groups or ["all"] self._build_ui() - def _build_ui(self) -> None: + def _build_ui(self): self.setupUi(self) self.error_label.setStyleSheet("color: red;") self.group_value.setEditable(True) @@ -359,14 +350,14 @@ def _build_ui(self) -> None: self.max_size_units.setCurrentText(self._normalize_unit(self._defaults.max_size_units, SIZE_UNITS, "MB")) self.resize(640, 360) - def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str) -> str: + def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): normalized = (value or "").strip().lower() for unit in allowed: if unit.lower() == normalized: return unit return fallback - def _validate_then_accept(self) -> None: + def _validate_then_accept(self): if not any( ( self.apply_enabled.isChecked(), @@ -382,7 +373,7 @@ def _validate_then_accept(self) -> None: self.error_label.setText("") self.accept() - def values(self) -> dict[str, Any]: + def values(self): return { "enabled": self.enabled_value.isChecked() if self.apply_enabled.isChecked() else None, "groups": normalize_groups(self.group_value.currentText()) if self.apply_group.isChecked() else None, @@ -420,6 +411,12 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): refresh_now_button: QtWidgets.QPushButton create_rule_button: QtWidgets.QPushButton status_label: QtWidgets.QLabel + _nodes: Nodes + _actions: Actions + _action_path: str + _loading: bool + _global_defaults: GlobalDefaults + _state_poll_timer: QtCore.QTimer _download_finished = QtCore.pyqtSignal() @@ -427,7 +424,7 @@ def __init__( self, parent: QtWidgets.QWidget | None = None, appicon: QtGui.QIcon | None = None, - ) -> None: + ): dlg_parent = parent if isinstance(parent, QtWidgets.QWidget) else None super().__init__(dlg_parent) self.setWindowTitle(QC.translate("stats", "List subscriptions")) @@ -439,30 +436,30 @@ def __init__( self._action_path = ACTION_FILE self._loading = False self._global_defaults: GlobalDefaults = GlobalDefaults.from_dict({}, lists_dir=DEFAULT_LISTS_DIR) - self._rules_dialog: Any = None + self._rules_dialog: RulesEditorDialog | None = None self._state_poll_timer = QtCore.QTimer(self) self._state_poll_timer.setInterval(2000) self._state_poll_timer.timeout.connect(self._refresh_states_if_visible) self._download_finished.connect(self.refresh_states) self._build_ui() - def showEvent(self, event: QtGui.QShowEvent) -> None: # type: ignore + def showEvent(self, event: QtGui.QShowEvent): # type: ignore super().showEvent(event) self.load_action_file() if not self._state_poll_timer.isActive(): self._state_poll_timer.start() - def hideEvent(self, event: QtGui.QHideEvent) -> None: # type: ignore + def hideEvent(self, event: QtGui.QHideEvent): # type: ignore if self._state_poll_timer.isActive(): self._state_poll_timer.stop() super().hideEvent(event) - def closeEvent(self, event: QtGui.QCloseEvent) -> None: # type: ignore + def closeEvent(self, event: QtGui.QCloseEvent): # type: ignore if self._state_poll_timer.isActive(): self._state_poll_timer.stop() super().closeEvent(event) - def _build_ui(self) -> None: + def _build_ui(self): self.setupUi(self) self.setWindowTitle(QC.translate("stats", "List subscriptions")) self.resize(1180, 680) @@ -541,7 +538,7 @@ def _build_ui(self) -> None: sel_model.selectionChanged.connect(lambda *_: self._update_selected_actions_state()) self._update_selected_actions_state() - def load_action_file(self) -> None: + def load_action_file(self): self._loading = True self._set_status("") self._reload_nodes() @@ -565,16 +562,27 @@ def load_action_file(self) -> None: self._loading = False return - action_cfg = data.get("actions", {}).get("list_subscriptions", {}) - plugin_cfg = action_cfg.get("config", {}) - subscriptions = plugin_cfg.get("subscriptions", []) - self._global_defaults = GlobalDefaults.from_dict(plugin_cfg, lists_dir=plugin_cfg.get("lists_dir")) - - self.enable_plugin_check.setChecked(bool(action_cfg.get("enabled", False))) + action_model = MutableActionConfig.from_action_dict(data, lists_dir=DEFAULT_LISTS_DIR) + self._global_defaults = action_model.defaults + self.enable_plugin_check.setChecked(action_model.enabled) self.lists_dir_edit.setText(normalize_lists_dir(self._global_defaults.lists_dir)) self._apply_defaults_to_widgets() - normalized_subs, fixed_count, migrated_legacy_group = self._normalize_loaded_subscriptions(subscriptions) + normalized_subs = action_model.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._append_row(sub) @@ -591,24 +599,25 @@ def load_action_file(self) -> None: return if fixed_count > 0: self._set_status( - QC.translate("stats", "Loaded configuration with {0} auto-corrected subscription field(s).").format(fixed_count), + QC.translate("stats", "Loaded configuration with normalized subscription fields."), error=False, ) else: self._set_status(QC.translate("stats", "List subscriptions configuration loaded."), error=False) - def create_action_file(self) -> None: + def create_action_file(self): try: os.makedirs(os.path.dirname(self._action_path), mode=0o700, exist_ok=True) if not os.path.exists(self._action_path): + action_model = MutableActionConfig.default(DEFAULT_LISTS_DIR) with open(self._action_path, "w", encoding="utf-8") as f: - json.dump(_template_action(), f, indent=2) + json.dump(action_model.to_action_dict(), f, indent=2) self.load_action_file() self._set_status(QC.translate("stats", "Action file created."), error=False) except Exception as e: self._set_status(QC.translate("stats", "Error creating action file: {0}").format(str(e)), error=True) - def save_action_file(self) -> None: + def save_action_file(self): if self._loading: return @@ -621,26 +630,29 @@ def save_action_file(self) -> None: if subscriptions is None: return - action = _template_action() - action["actions"]["list_subscriptions"]["enabled"] = self.enable_plugin_check.isChecked() lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) try: os.makedirs(lists_dir, mode=0o700, exist_ok=True) except Exception: pass - action["actions"]["list_subscriptions"]["config"]["lists_dir"] = lists_dir - action["actions"]["list_subscriptions"]["config"]["interval"] = int(self.default_interval_spin.value()) - action["actions"]["list_subscriptions"]["config"]["interval_units"] = self.default_interval_units.currentText() - action["actions"]["list_subscriptions"]["config"]["timeout"] = int(self.default_timeout_spin.value()) - action["actions"]["list_subscriptions"]["config"]["timeout_units"] = self.default_timeout_units.currentText() - action["actions"]["list_subscriptions"]["config"]["max_size"] = int(self.default_max_size_spin.value()) - action["actions"]["list_subscriptions"]["config"]["max_size_units"] = self.default_max_size_units.currentText() - action["actions"]["list_subscriptions"]["config"]["user_agent"] = self.default_user_agent.text().strip() - action["actions"]["list_subscriptions"]["config"]["subscriptions"] = subscriptions + defaults = GlobalDefaults( + lists_dir=lists_dir, + interval=max(1, int(self.default_interval_spin.value())), + interval_units=self.default_interval_units.currentText(), + timeout=max(1, int(self.default_timeout_spin.value())), + timeout_units=self.default_timeout_units.currentText(), + max_size=max(1, int(self.default_max_size_spin.value())), + max_size_units=self.default_max_size_units.currentText(), + user_agent=(self.default_user_agent.text() or "").strip(), + ) + action_model = MutableActionConfig.default(lists_dir) + action_model.enabled = self.enable_plugin_check.isChecked() + action_model.defaults = defaults + action_model.subscriptions = subscriptions + action = action_model.to_action_dict() action["updated"] = datetime.now().astimezone().isoformat() - cfg = action["actions"]["list_subscriptions"]["config"] - compiled_cfg = PluginConfig.from_dict(cfg, lists_dir=cfg.get("lists_dir")) + compiled_cfg = PluginConfig.from_dict(action_model.to_plugin_dict(), lists_dir=lists_dir) if len(compiled_cfg.subscriptions) != len(subscriptions): self._set_status(QC.translate("stats", "Invalid subscriptions: URL and filename are mandatory."), error=True) return @@ -656,11 +668,11 @@ def save_action_file(self) -> None: self._set_status(QC.translate("stats", "Error saving action file: {0}").format(str(e)), error=True) return - self._apply_runtime_state(action["actions"]["list_subscriptions"]["enabled"]) + self._apply_runtime_state(action_model.enabled) self.refresh_states() self._set_status(QC.translate("stats", "List subscriptions configuration saved."), error=False) - def refresh_states(self) -> None: + def refresh_states(self): lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) for row in range(self.table.rowCount()): filename_item = self.table.item(row, COL_FILENAME) @@ -728,31 +740,31 @@ def refresh_states(self) -> None: if item is not None: item.setBackground(color) - def add_subscription_row(self) -> None: + def add_subscription_row(self): dlg = SubscriptionDialog( self, self._global_defaults, groups=self._known_groups(), - sub={ - "enabled": True, - "name": "", - "url": "", - "filename": "", - "format": "hosts", - "groups": ["all"], - "interval": self._global_defaults.interval, - "interval_units": self._global_defaults.interval_units, - "timeout": self._global_defaults.timeout, - "timeout_units": self._global_defaults.timeout_units, - "max_size": self._global_defaults.max_size, - "max_size_units": self._global_defaults.max_size_units, - }, + sub=MutableSubscriptionSpec( + enabled=True, + name="", + url="", + filename="", + format="hosts", + groups=["all"], + interval=self._global_defaults.interval, + interval_units=self._global_defaults.interval_units, + timeout=self._global_defaults.timeout, + timeout_units=self._global_defaults.timeout_units, + max_size=self._global_defaults.max_size, + max_size_units=self._global_defaults.max_size_units, + ), title="New subscription", ) if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: return - sub = dlg.subscription_dict() + sub = dlg.subscription_spec() self._append_row(sub) row = self.table.rowCount() - 1 _, changed = self._ensure_row_final_filename(row) @@ -764,7 +776,7 @@ def add_subscription_row(self) -> None: self.save_action_file() self._update_selected_actions_state() - def edit_selected_subscription(self) -> None: + def edit_selected_subscription(self): row = self.table.currentRow() if row < 0: self._set_status(QC.translate("stats", "Select a subscription row first."), error=True) @@ -776,20 +788,23 @@ def edit_selected_subscription(self) -> None: enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) self.table.setItem(row, COL_ENABLED, enabled_item) - sub = { - "enabled": enabled_item.checkState() == QtCore.Qt.CheckState.Checked, - "name": self._cell_text(row, COL_NAME), - "url": self._cell_text(row, COL_URL), - "filename": self._cell_text(row, COL_FILENAME), - "format": self._cell_text(row, COL_FORMAT) or "hosts", - "groups": normalize_groups(self._cell_text(row, COL_GROUP) or "all"), - "interval": self._to_int_or_keep(self._cell_text(row, COL_INTERVAL)), - "interval_units": self._cell_text(row, COL_INTERVAL_UNITS) or self._global_defaults.interval_units, - "timeout": self._to_int_or_keep(self._cell_text(row, COL_TIMEOUT)), - "timeout_units": self._cell_text(row, COL_TIMEOUT_UNITS) or self._global_defaults.timeout_units, - "max_size": self._to_int_or_keep(self._cell_text(row, COL_MAX_SIZE)), - "max_size_units": self._cell_text(row, COL_MAX_SIZE_UNITS) or self._global_defaults.max_size_units, - } + interval_val = self._to_int_or_keep(self._cell_text(row, COL_INTERVAL)) + timeout_val = self._to_int_or_keep(self._cell_text(row, COL_TIMEOUT)) + max_size_val = self._to_int_or_keep(self._cell_text(row, COL_MAX_SIZE)) + sub = MutableSubscriptionSpec( + enabled=enabled_item.checkState() == QtCore.Qt.CheckState.Checked, + name=self._cell_text(row, COL_NAME), + url=self._cell_text(row, COL_URL), + filename=self._cell_text(row, COL_FILENAME), + format=self._cell_text(row, COL_FORMAT) or "hosts", + groups=normalize_groups(self._cell_text(row, COL_GROUP) or "all"), + interval=interval_val if isinstance(interval_val, int) else self._global_defaults.interval, + interval_units=self._cell_text(row, COL_INTERVAL_UNITS) or self._global_defaults.interval_units, + timeout=timeout_val if isinstance(timeout_val, int) else self._global_defaults.timeout, + timeout_units=self._cell_text(row, COL_TIMEOUT_UNITS) or self._global_defaults.timeout_units, + max_size=max_size_val if isinstance(max_size_val, int) else self._global_defaults.max_size, + max_size_units=self._cell_text(row, COL_MAX_SIZE_UNITS) or self._global_defaults.max_size_units, + ) meta = self._row_meta_snapshot(row) dlg = SubscriptionDialog( self, @@ -801,7 +816,7 @@ def edit_selected_subscription(self) -> None: ) if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: return - updated = dlg.subscription_dict() + updated = dlg.subscription_spec() enabled_item = self.table.item(row, COL_ENABLED) if enabled_item is None: @@ -809,21 +824,21 @@ def edit_selected_subscription(self) -> None: enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) self.table.setItem(row, COL_ENABLED, enabled_item) enabled_item.setCheckState( - QtCore.Qt.CheckState.Checked if bool(updated.get("enabled", True)) else QtCore.Qt.CheckState.Unchecked + QtCore.Qt.CheckState.Checked if bool(updated.enabled) else QtCore.Qt.CheckState.Unchecked ) - self._set_text_item(row, COL_NAME, str(updated.get("name", ""))) - self._set_text_item(row, COL_URL, str(updated.get("url", ""))) - self._set_text_item(row, COL_FILENAME, self._safe_filename(updated.get("filename", ""))) - self._set_text_item(row, COL_FORMAT, str(updated.get("format", "hosts"))) - self._set_text_item(row, COL_GROUP, ", ".join(normalize_groups(updated.get("groups")))) - self._set_text_item(row, COL_INTERVAL, self._to_str(updated.get("interval", self._global_defaults.interval))) - interval_units_val = self._to_str(updated.get("interval_units", self._global_defaults.interval_units)) + self._set_text_item(row, COL_NAME, updated.name) + self._set_text_item(row, COL_URL, updated.url) + self._set_text_item(row, COL_FILENAME, self._safe_filename(updated.filename)) + self._set_text_item(row, COL_FORMAT, updated.format) + self._set_text_item(row, COL_GROUP, ", ".join(normalize_groups(updated.groups))) + self._set_text_item(row, COL_INTERVAL, self._to_str(updated.interval)) + interval_units_val = self._to_str(updated.interval_units) self._set_text_item(row, COL_INTERVAL_UNITS, interval_units_val) - self._set_text_item(row, COL_TIMEOUT, self._to_str(updated.get("timeout", self._global_defaults.timeout))) - timeout_units_val = self._to_str(updated.get("timeout_units", self._global_defaults.timeout_units)) + self._set_text_item(row, COL_TIMEOUT, self._to_str(updated.timeout)) + timeout_units_val = self._to_str(updated.timeout_units) self._set_text_item(row, COL_TIMEOUT_UNITS, timeout_units_val) - self._set_text_item(row, COL_MAX_SIZE, self._to_str(updated.get("max_size", self._global_defaults.max_size))) - max_size_units_val = self._to_str(updated.get("max_size_units", self._global_defaults.max_size_units)) + self._set_text_item(row, COL_MAX_SIZE, self._to_str(updated.max_size)) + max_size_units_val = self._to_str(updated.max_size_units) self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units_val) self._set_units_combo(row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units_val) self._set_units_combo(row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units_val) @@ -837,7 +852,7 @@ def edit_selected_subscription(self) -> None: else: self._set_status(QC.translate("stats", "Subscription updated."), error=False) - def edit_action_clicked(self) -> None: + def edit_action_clicked(self): rows = self._selected_rows() if len(rows) == 0: self._set_status(QC.translate("stats", "Select one or more subscriptions first."), error=True) @@ -847,7 +862,7 @@ def edit_action_clicked(self) -> None: return self._bulk_edit(rows) - def remove_selected_subscription(self) -> None: + def remove_selected_subscription(self): rows = self._selected_rows() if not rows: row = self.table.currentRow() @@ -863,13 +878,13 @@ def remove_selected_subscription(self) -> None: self._update_selected_actions_state() self._set_status(QC.translate("stats", "Selected subscriptions removed."), error=False) - def _selected_rows(self) -> list[int]: + def _selected_rows(self): idx = self.table.selectionModel() if idx is None: return [] return sorted({i.row() for i in idx.selectedRows()}) - def _update_selected_actions_state(self) -> None: + def _update_selected_actions_state(self): count = len(self._selected_rows()) has_selection = count > 0 single = count == 1 @@ -878,7 +893,7 @@ def _update_selected_actions_state(self) -> None: self.refresh_now_button.setEnabled(single) self.create_rule_button.setEnabled(has_selection) - def _open_table_context_menu(self, pos: QtCore.QPoint) -> None: + def _open_table_context_menu(self, pos: QtCore.QPoint): rows = self._selected_rows() if not rows: row = self.table.rowAt(pos.y()) @@ -919,7 +934,7 @@ def _open_table_context_menu(self, pos: QtCore.QPoint) -> None: elif chosen is act_rule: self.create_rule_from_selected() - def _bulk_edit(self, rows: list[int]) -> None: + def _bulk_edit(self, rows: list[int]): if not rows: return dlg = BulkEditDialog(self, self._global_defaults, groups=self._known_groups()) @@ -963,7 +978,7 @@ def _bulk_edit(self, rows: list[int]) -> None: error=False, ) - def _known_groups(self) -> list[str]: + def _known_groups(self): groups: set[str] = {"all"} for row in range(self.table.rowCount()): for g in normalize_groups(self._cell_text(row, COL_GROUP) or "all"): @@ -971,7 +986,7 @@ def _known_groups(self) -> list[str]: groups.add(g) return sorted(groups) - def refresh_selected_now(self) -> None: + def refresh_selected_now(self): row = self.table.currentRow() if row < 0: self._set_status(QC.translate("stats", "Select a subscription row first."), error=True) @@ -991,7 +1006,7 @@ def refresh_selected_now(self) -> None: self._set_status(QC.translate("stats", "Plugin is not loaded. Save configuration first."), error=True) return - target_sub = None + target_sub: SubscriptionSpec | None = None try: for sub in plug._config.subscriptions: if sub.url == url and sub.filename == filename: @@ -1002,21 +1017,25 @@ def refresh_selected_now(self) -> None: if target_sub is None: try: + interval_val = self._to_int_or_keep(self._cell_text(row, COL_INTERVAL)) + timeout_val = self._to_int_or_keep(self._cell_text(row, COL_TIMEOUT)) + max_size_val = self._to_int_or_keep(self._cell_text(row, COL_MAX_SIZE)) + row_sub_edit = MutableSubscriptionSpec( + enabled=True, + name=self._cell_text(row, COL_NAME), + url=url, + filename=filename, + format=self._cell_text(row, COL_FORMAT) or "hosts", + groups=normalize_groups(self._cell_text(row, COL_GROUP) or "all"), + interval=interval_val if isinstance(interval_val, int) else self._global_defaults.interval, + interval_units=self._cell_text(row, COL_INTERVAL_UNITS), + timeout=timeout_val if isinstance(timeout_val, int) else self._global_defaults.timeout, + timeout_units=self._cell_text(row, COL_TIMEOUT_UNITS), + max_size=max_size_val if isinstance(max_size_val, int) else self._global_defaults.max_size, + max_size_units=self._cell_text(row, COL_MAX_SIZE_UNITS), + ) row_sub = SubscriptionSpec.from_dict( - { - "enabled": True, - "name": self._cell_text(row, COL_NAME), - "url": url, - "filename": filename, - "format": self._cell_text(row, COL_FORMAT) or "hosts", - "groups": normalize_groups(self._cell_text(row, COL_GROUP) or "all"), - "interval": self._to_int_or_keep(self._cell_text(row, COL_INTERVAL)), - "interval_units": self._cell_text(row, COL_INTERVAL_UNITS), - "timeout": self._to_int_or_keep(self._cell_text(row, COL_TIMEOUT)), - "timeout_units": self._cell_text(row, COL_TIMEOUT_UNITS), - "max_size": self._to_int_or_keep(self._cell_text(row, COL_MAX_SIZE)), - "max_size_units": self._cell_text(row, COL_MAX_SIZE_UNITS), - }, + row_sub_edit.to_dict(), plug._config.defaults, ) except Exception: @@ -1032,7 +1051,7 @@ def refresh_selected_now(self) -> None: key = plug._sub_key(target_sub) list_path, _ = plug._paths(target_sub) - def _run_refresh() -> None: + def _run_refresh(): try: logger.warning( "list_subscriptions.gui: manual refresh start key=%s name='%s' url='%s' file='%s'", @@ -1054,15 +1073,15 @@ def _run_refresh() -> None: error=False, ) - def refresh_all_now(self) -> None: + def refresh_all_now(self): _, _, plug = self._find_loaded_action() if plug is None: self._set_status(QC.translate("stats", "Plugin is not loaded. Save configuration first."), error=True) return - def _run_all_refresh() -> None: + def _run_all_refresh(): try: - subs = [] + subs: list[SubscriptionSpec] = [] try: subs = list(getattr(plug._config, "subscriptions", [])) except Exception: @@ -1085,7 +1104,7 @@ def _run_all_refresh() -> None: th.start() self._set_status(QC.translate("stats", "Bulk refresh triggered for all enabled subscriptions."), error=False) - def create_rule_from_selected(self) -> None: + def create_rule_from_selected(self): rows = self._selected_rows() if not rows: row = self.table.currentRow() @@ -1129,23 +1148,15 @@ def create_rule_from_selected(self) -> None: return desc = f"From list subscriptions group: {rule_group}" - try: - from opensnitch.dialogs.ruleseditor import RulesEditorDialog - except Exception as e: - self._set_status(QC.translate("stats", "Unable to open Rules Editor: {0}").format(str(e)), error=True) - return - if self._rules_dialog is None: appicon = self.windowIcon() if self.windowIcon() is not None else None try: - self._rules_dialog = RulesEditorDialog(parent=None, modal=False, appicon=appicon) + self._rules_dialog = RulesEditorDialog(parent=None, appicon=appicon) except TypeError: - try: - self._rules_dialog = RulesEditorDialog(parent=None, appicon=appicon) - except TypeError: - self._rules_dialog = RulesEditorDialog() + self._rules_dialog = RulesEditorDialog() self._rules_dialog.new_rule() + # Rules editor expects a directory containing one or more hosts files. self._rules_dialog.dstListsCheck.setChecked(True) self._rules_dialog.dstListsLine.setText(rule_dir) @@ -1155,7 +1166,7 @@ def create_rule_from_selected(self) -> None: self._rules_dialog.activateWindow() self._set_status(QC.translate("stats", "Rules Editor opened with prefilled list directory path."), error=False) - def create_global_rule(self) -> None: + def create_global_rule(self): lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) rule_dir = os.path.join(lists_dir, "rules.list.d", "all") try: @@ -1164,21 +1175,12 @@ def create_global_rule(self) -> None: self._set_status(QC.translate("stats", "Error preparing global rule directory: {0}").format(str(e)), error=True) return - try: - from opensnitch.dialogs.ruleseditor import RulesEditorDialog - except Exception as e: - self._set_status(QC.translate("stats", "Unable to open Rules Editor: {0}").format(str(e)), error=True) - return - if self._rules_dialog is None: appicon = self.windowIcon() if self.windowIcon() is not None else None try: - self._rules_dialog = RulesEditorDialog(parent=None, modal=False, appicon=appicon) + self._rules_dialog = RulesEditorDialog(parent=None, appicon=appicon) except TypeError: - try: - self._rules_dialog = RulesEditorDialog(parent=None, appicon=appicon) - except TypeError: - self._rules_dialog = RulesEditorDialog() + self._rules_dialog = RulesEditorDialog() self._rules_dialog.new_rule() self._rules_dialog.dstListsCheck.setChecked(True) @@ -1189,7 +1191,7 @@ def create_global_rule(self) -> None: self._rules_dialog.activateWindow() self._set_status(QC.translate("stats", "Rules Editor opened with global list directory path."), error=False) - def _choose_group_for_selected(self, rows: list[int]) -> str | None: + def _choose_group_for_selected(self, rows: list[int]): if not rows: return None selected_group_sets = [set(normalize_groups(self._cell_text(r, COL_GROUP) or "all")) for r in rows] @@ -1221,7 +1223,7 @@ def _choose_group_for_selected(self, rows: list[int]) -> str | None: return None return group - def _assign_group_to_rows(self, rows: list[int], group: str) -> bool: + def _assign_group_to_rows(self, rows: list[int], group: str): if not rows: return False target_group = normalize_group(group) @@ -1232,7 +1234,7 @@ def _assign_group_to_rows(self, rows: list[int], group: str) -> bool: self._set_text_item(row, COL_GROUP, ", ".join(groups)) return True - def _prepare_rule_dir(self, url: str, filename: str, list_path: str, lists_dir: str) -> str | None: + def _prepare_rule_dir(self, url: str, filename: str, list_path: str, lists_dir: str): _ = (url, filename, lists_dir) rule_dir = os.path.dirname(list_path) # Rules should point to the directory that already contains the @@ -1244,7 +1246,7 @@ def _prepare_rule_dir(self, url: str, filename: str, list_path: str, lists_dir: self._set_status(QC.translate("stats", "Error preparing list rule directory: {0}").format(str(e)), error=True) return None - def _list_file_path(self, lists_dir: str, filename: str, list_type: str) -> str: + def _list_file_path(self, lists_dir: str, filename: str, list_type: str): safe_name = self._safe_filename(filename) if safe_name == "": safe_name = "subscription.list" @@ -1256,71 +1258,7 @@ def _list_file_path(self, lists_dir: str, filename: str, list_type: str) -> str: sub_dirname = f"{sub_dirname}{suffix}" return os.path.join(lists_dir, "sources.list.d", sub_dirname, safe_name) - def _normalize_loaded_subscriptions(self, subscriptions: Any) -> tuple[list[dict[str, Any]], int, bool]: - out: list[dict[str, Any]] = [] - fixed_count = 0 - migrated_legacy_group = False - seen: dict[str, int] = {} - if not isinstance(subscriptions, list): - return out, fixed_count, migrated_legacy_group - - for idx, raw in enumerate(subscriptions): - if not isinstance(raw, dict): - continue - sub = dict(raw) - url = (str(sub.get("url", "")) or "").strip() - name = (str(sub.get("name", "")) or "").strip() - list_type = (str(sub.get("format", "hosts")) or "hosts").strip().lower() - had_legacy_group = ("groups" not in sub) and ("group" in sub) - groups = normalize_groups(sub.get("groups", [sub.get("group")] if had_legacy_group else None)) - filename = self._safe_filename(sub.get("filename", "")) - - if filename == "": - filename = self._guess_filename(name, url) - sub["filename"] = filename - fixed_count += 1 - typed_filename = ensure_filename_type_suffix(filename, list_type) - if typed_filename != filename: - filename = typed_filename - sub["filename"] = filename - fixed_count += 1 - - if name == "": - if filename != "": - name = filename - elif url != "": - name = self._filename_from_url(url) or f"subscription-{idx + 1}" - else: - name = f"subscription-{idx + 1}" - sub["name"] = name - fixed_count += 1 - if sub.get("groups") != groups: - sub["groups"] = groups - fixed_count += 1 - if had_legacy_group: - migrated_legacy_group = True - - key = os.path.normcase(filename) - if filename != "": - if key in seen: - base, ext = os.path.splitext(filename) - n = 2 - candidate = filename - while os.path.normcase(candidate) in seen: - suffix = f"-{n}" - candidate = f"{base}{suffix}{ext}" if ext else f"{base}{suffix}" - n += 1 - sub["filename"] = candidate - filename = candidate - key = os.path.normcase(filename) - fixed_count += 1 - seen[key] = idx - - out.append(sub) - - return out, fixed_count, migrated_legacy_group - - def _apply_runtime_state(self, enabled: bool) -> None: + def _apply_runtime_state(self, enabled: bool): old_key, old_action, old_plugin = self._find_loaded_action() if old_plugin is not None: try: @@ -1339,27 +1277,30 @@ def _apply_runtime_state(self, enabled: bool) -> None: self._set_status(QC.translate("stats", "Config saved but runtime reload failed. Restart UI."), error=True) return - # pylint: disable=protected-access + obj = cast(dict[str, Any], obj) + compiled = cast(dict[str, Any], compiled) self._actions._actions_list[obj["name"]] = compiled - plug = compiled.get("actions", {}).get("list_subscriptions") + compiled_actions: dict[str, Any] = compiled.get("actions", {}) + plug = cast(ListSubscriptions | None, compiled_actions.get("list_subscriptions")) if plug is not None: try: plug.run() except Exception: self._set_status(QC.translate("stats", "Plugin enabled but failed to start. Restart UI."), error=True) - def _find_loaded_action(self) -> tuple[Any, Any, Any]: + def _find_loaded_action(self): for action_key, action_obj in self._actions.getAll().items(): if action_obj is None: continue - act_cfg = action_obj.get("actions", {}) - plug = act_cfg.get("list_subscriptions") + action_obj_dict = cast(dict[str, Any], action_obj) + action_cfg: dict[str, Any] = action_obj_dict.get("actions", {}) + plug = cast(ListSubscriptions | None, action_cfg.get("list_subscriptions")) if plug is not None: - return action_key, action_obj, plug + return str(action_key), action_obj_dict, plug return None, None, None - def _collect_subscriptions(self) -> list[dict[str, Any]] | None: - out: list[dict[str, Any]] = [] + def _collect_subscriptions(self): + out: list[MutableSubscriptionSpec] = [] auto_filled = 0 seen_filenames: dict[str, int] = {} for row in range(self.table.rowCount()): @@ -1392,21 +1333,24 @@ def _collect_subscriptions(self) -> list[dict[str, Any]] | None: ) return None seen_filenames[file_key] = row - sub = { - "enabled": enabled_item is not None and enabled_item.checkState() == QtCore.Qt.CheckState.Checked, - "name": name, - "url": url, - "filename": filename, - "format": list_type, - "groups": groups, - "interval": self._to_int_or_keep(interval or self._global_defaults.interval), - "interval_units": interval_units or self._global_defaults.interval_units, - "timeout": self._to_int_or_keep(timeout or self._global_defaults.timeout), - "timeout_units": timeout_units or self._global_defaults.timeout_units, - "max_size": self._to_int_or_keep(max_size or self._global_defaults.max_size), - "max_size_units": max_size_units or self._global_defaults.max_size_units, - } - if sub["url"] == "" or sub["filename"] == "": + interval_val = self._to_int_or_keep(interval or self._global_defaults.interval) + timeout_val = self._to_int_or_keep(timeout or self._global_defaults.timeout) + max_size_val = self._to_int_or_keep(max_size or self._global_defaults.max_size) + sub = MutableSubscriptionSpec( + enabled=enabled_item is not None and enabled_item.checkState() == QtCore.Qt.CheckState.Checked, + name=name, + url=url, + filename=filename, + format=list_type, + groups=groups, + interval=interval_val if isinstance(interval_val, int) else self._global_defaults.interval, + interval_units=interval_units or self._global_defaults.interval_units, + timeout=timeout_val if isinstance(timeout_val, int) else self._global_defaults.timeout, + timeout_units=timeout_units or self._global_defaults.timeout_units, + max_size=max_size_val if isinstance(max_size_val, int) else self._global_defaults.max_size, + max_size_units=max_size_units or self._global_defaults.max_size_units, + ) + if sub.url == "" or sub.filename == "": self._set_status(QC.translate("stats", "URL and filename cannot be empty (row {0}).").format(row + 1), error=True) return None out.append(sub) @@ -1418,7 +1362,7 @@ def _collect_subscriptions(self) -> list[dict[str, Any]] | None: ) return out - def _row_meta_snapshot(self, row: int) -> dict[str, str]: + def _row_meta_snapshot(self, row: int): lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) filename = self._safe_filename(self._cell_text(row, COL_FILENAME)) list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() @@ -1447,7 +1391,7 @@ def _row_meta_snapshot(self, row: int) -> dict[str, str]: "meta_path": meta_path, } - def _ensure_row_final_filename(self, row: int) -> tuple[str, bool]: + def _ensure_row_final_filename(self, row: int): name = self._cell_text(row, COL_NAME) url = self._cell_text(row, COL_URL) list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() @@ -1486,27 +1430,27 @@ def _ensure_row_final_filename(self, row: int) -> tuple[str, bool]: self._set_text_item(row, COL_FILENAME, final_name) return final_name, changed - def _append_row(self, sub: dict[str, Any]) -> None: + def _append_row(self, sub: MutableSubscriptionSpec): row = self.table.rowCount() self.table.insertRow(row) enabled_item = QtWidgets.QTableWidgetItem("") enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) - enabled_item.setCheckState(QtCore.Qt.CheckState.Checked if bool(sub.get("enabled", True)) else QtCore.Qt.CheckState.Unchecked) + enabled_item.setCheckState(QtCore.Qt.CheckState.Checked if bool(sub.enabled) else QtCore.Qt.CheckState.Unchecked) self.table.setItem(row, COL_ENABLED, enabled_item) - self._set_text_item(row, COL_NAME, str(sub.get("name", ""))) - self._set_text_item(row, COL_URL, str(sub.get("url", ""))) - self._set_text_item(row, COL_FILENAME, self._safe_filename(sub.get("filename", ""))) - self._set_text_item(row, COL_FORMAT, str(sub.get("format", "hosts"))) - groups = normalize_groups(sub.get("groups")) + self._set_text_item(row, COL_NAME, str(sub.name)) + self._set_text_item(row, COL_URL, str(sub.url)) + self._set_text_item(row, COL_FILENAME, self._safe_filename(sub.filename)) + self._set_text_item(row, COL_FORMAT, str(sub.format)) + groups = normalize_groups(sub.groups) self._set_text_item(row, COL_GROUP, ", ".join(groups)) - interval = sub.get("interval") - timeout = sub.get("timeout") - max_size = sub.get("max_size") - interval_units = sub.get("interval_units") - timeout_units = sub.get("timeout_units") - max_size_units = sub.get("max_size_units") + interval = sub.interval + timeout = sub.timeout + max_size = sub.max_size + interval_units = sub.interval_units + timeout_units = sub.timeout_units + max_size_units = sub.max_size_units self._set_text_item( row, COL_INTERVAL, @@ -1564,14 +1508,14 @@ def _append_row(self, sub: dict[str, Any]) -> None: self._set_text_item(row, COL_FAILS, "", editable=False) self._set_text_item(row, COL_ERROR, "", editable=False) - def _reload_nodes(self) -> None: + def _reload_nodes(self): self.nodes_combo.blockSignals(True) self.nodes_combo.clear() for addr in self._nodes.get_nodes(): self.nodes_combo.addItem(addr, addr) self.nodes_combo.blockSignals(False) - def _apply_defaults_to_widgets(self) -> None: + def _apply_defaults_to_widgets(self): self.default_interval_spin.setValue(max(1, int(self._global_defaults.interval))) self.default_interval_units.setCurrentText( self._normalize_unit(self._global_defaults.interval_units, INTERVAL_UNITS, "hours") @@ -1586,23 +1530,23 @@ def _apply_defaults_to_widgets(self) -> None: ) self.default_user_agent.setText((self._global_defaults.user_agent or "").strip()) - def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str) -> str: + def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): normalized = (value or "").strip().lower() for unit in allowed: if unit.lower() == normalized: return unit return fallback - def _set_units_combo(self, row: int, col: int, allowed: tuple[str, ...], value: str) -> None: + def _set_units_combo(self, row: int, col: int, allowed: tuple[str, ...], value: str): combo = QtWidgets.QComboBox() combo.addItems(allowed) combo.setCurrentText(self._normalize_unit(value, allowed, allowed[0])) self.table.setCellWidget(row, col, combo) - def _safe_filename(self, value: Any) -> str: + def _safe_filename(self, value: Any): return os.path.basename((self._to_str(value) or "").strip()) - def _guess_filename(self, name: str, url: str) -> str: + def _guess_filename(self, name: str, url: str): from_header = self._filename_from_headers(url) if from_header != "": return self._safe_filename(from_header) @@ -1614,7 +1558,7 @@ def _guess_filename(self, name: str, url: str) -> str: slug = self._slugify_name(name) return self._safe_filename(slug) - def _filename_from_headers(self, url: str) -> str: + def _filename_from_headers(self, url: str): if (url or "").strip() == "": return "" try: @@ -1637,7 +1581,7 @@ def _filename_from_headers(self, url: str) -> str: return "" return "" - def _filename_from_url(self, url: str) -> str: + def _filename_from_url(self, url: str): u = (url or "").strip() if u == "": return "" @@ -1648,7 +1592,7 @@ def _filename_from_url(self, url: str) -> str: except Exception: return "" - def _slugify_name(self, name: str) -> str: + def _slugify_name(self, name: str): raw = (name or "").strip().lower() if raw == "": return "subscription.list" @@ -1659,7 +1603,7 @@ def _slugify_name(self, name: str) -> str: slug += ".list" return slug - def _set_text_item(self, row: int, col: int, text: str, editable: bool = True) -> None: + def _set_text_item(self, row: int, col: int, text: str, editable: bool = True): item = self.table.item(row, col) if item is None: item = QtWidgets.QTableWidgetItem() @@ -1670,7 +1614,7 @@ def _set_text_item(self, row: int, col: int, text: str, editable: bool = True) - else: item.setFlags(item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) - def _cell_text(self, row: int, col: int) -> str: + def _cell_text(self, row: int, col: int): w = self.table.cellWidget(row, col) if isinstance(w, QtWidgets.QComboBox): return (w.currentText() or "").strip() @@ -1679,7 +1623,7 @@ def _cell_text(self, row: int, col: int) -> str: return "" return (item.text() or "").strip() - def _to_int_or_keep(self, value: Any) -> Any: + def _to_int_or_keep(self, value: Any): if value == "": return value try: @@ -1687,15 +1631,15 @@ def _to_int_or_keep(self, value: Any) -> Any: except Exception: return value - def _to_str(self, value: Any) -> str: + def _to_str(self, value: Any): if value is None: return "" return str(value) - def _set_status(self, msg: str, error: bool = False) -> None: + def _set_status(self, msg: str, error: bool = False): self.status_label.setStyleSheet("color: red;" if error else "color: green;") self.status_label.setText(msg) - def _refresh_states_if_visible(self) -> None: + def _refresh_states_if_visible(self): if self.isVisible() and not self._loading: self.refresh_states() diff --git a/ui/opensnitch/plugins/list_subscriptions/_models.py b/ui/opensnitch/plugins/list_subscriptions/_models.py index 3f7bc45277..c932e800eb 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_models.py +++ b/ui/opensnitch/plugins/list_subscriptions/_models.py @@ -256,6 +256,62 @@ def _opt_str(x: Any): ) +@dataclass +class MutableSubscriptionSpec: + name: str = "" + url: str = "" + filename: str = "" + groups: list[str] = field(default_factory=lambda: ["all"]) + enabled: bool = True + format: str = "hosts" + interval: int = 24 + interval_units: str = "hours" + timeout: int = 60 + timeout_units: str = "seconds" + max_size: int = 20 + max_size_units: str = "MB" + + @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], defaults: GlobalDefaults): + spec = SubscriptionSpec.from_dict(d, defaults) + if spec is None: + return None + return MutableSubscriptionSpec.from_spec(spec) + + def to_dict(self): + return { + "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), + "interval": int(self.interval), + "interval_units": (self.interval_units or "hours").strip().lower(), + "timeout": int(self.timeout), + "timeout_units": (self.timeout_units or "seconds").strip().lower(), + "max_size": int(self.max_size), + "max_size_units": (self.max_size_units or "MB").strip(), + } + + @dataclass(frozen=True) class PluginConfig: defaults: GlobalDefaults = field(default_factory=lambda: GlobalDefaults.from_dict({})) @@ -292,6 +348,99 @@ def from_dict(raw_cfg: dict[str, Any], lists_dir: str | None = None): return PluginConfig(defaults=defaults, subscriptions=subs) +@dataclass +class MutableActionConfig: + enabled: bool = False + defaults: GlobalDefaults = field(default_factory=lambda: GlobalDefaults.from_dict({})) + subscriptions: list[MutableSubscriptionSpec] = field(default_factory=list) + 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 = str(raw_action.get("created", "")) + updated = str(raw_action.get("updated", "")) + 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 {} + compiled_cfg = PluginConfig.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, + defaults=compiled_cfg.defaults, + subscriptions=[MutableSubscriptionSpec.from_spec(s) for s in compiled_cfg.subscriptions], + action_name=action_name, + created=created, + updated=updated, + description=description, + types=action_types, + ) + + @staticmethod + def default(lists_dir: str | None = None): + defaults = GlobalDefaults.from_dict( + { + "interval": 24, + "interval_units": "hours", + "timeout": 20, + "timeout_units": "seconds", + "max_size": 50, + "max_size_units": "MB", + }, + lists_dir=lists_dir, + ) + return MutableActionConfig( + enabled=True, + defaults=defaults, + subscriptions=[], + ) + + def to_plugin_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": { + "success": {"desktop": "Lists subscriptions updated"}, + "error": {"desktop": "Error updating lists subscriptions"}, + }, + } + + def to_action_dict(self): + return { + "name": self.action_name, + "created": self.created, + "updated": self.updated, + "description": self.description, + "type": list(self.types), + "actions": { + "list_subscriptions": { + "enabled": bool(self.enabled), + "config": self.to_plugin_dict(), + } + }, + } + + @dataclass class ListMetadata: version: int = 1 From 73a4ace427285649b20d309b7572a65a498ec1f4 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Tue, 10 Mar 2026 09:54:03 +0100 Subject: [PATCH 07/13] defaults to PyQt6 --- ui/opensnitch/plugins/list_subscriptions/_gui.py | 8 ++++---- .../plugins/list_subscriptions/list_subscriptions.py | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ui/opensnitch/plugins/list_subscriptions/_gui.py b/ui/opensnitch/plugins/list_subscriptions/_gui.py index f72588e1db..0761891a63 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_gui.py +++ b/ui/opensnitch/plugins/list_subscriptions/_gui.py @@ -14,12 +14,12 @@ from PyQt6 import QtCore, QtGui, QtWidgets, uic from PyQt6.QtCore import QCoreApplication as QC else: - if "PyQt5" in sys.modules: - from PyQt5 import QtCore, QtGui, QtWidgets, uic - from PyQt5.QtCore import QCoreApplication as QC - elif "PyQt6" in sys.modules: + if "PyQt6" in sys.modules: from PyQt6 import QtCore, QtGui, QtWidgets, uic from PyQt6.QtCore import QCoreApplication as QC + elif "PyQt5" in sys.modules: + from PyQt5 import QtCore, QtGui, QtWidgets, uic + from PyQt5.QtCore import QCoreApplication as QC else: try: from PyQt6 import QtCore, QtGui, QtWidgets, uic diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index f2faf3bbdf..1c3d2f4e33 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -9,10 +9,11 @@ from queue import Queue import requests -if "PyQt5" in sys.modules: - from PyQt5 import QtCore, QtGui -elif "PyQt6" in sys.modules: + +if "PyQt6" in sys.modules: from PyQt6 import QtCore, QtGui +elif "PyQt5" in sys.modules: + from PyQt5 import QtCore, QtGui else: try: from PyQt6 import QtCore, QtGui From fca8c07bacae7dfdcfadbed7a4ade83aa48a7840 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Tue, 10 Mar 2026 22:17:08 +0100 Subject: [PATCH 08/13] decoupling UI and runtime with signaling + rule signaling upon refresh --- .../plugins/list_subscriptions/__init__.py | 15 + .../plugins/list_subscriptions/_gui.py | 1903 +++++++++++++---- .../plugins/list_subscriptions/_models.py | 536 +++-- .../plugins/list_subscriptions/_utils.py | 255 ++- .../list_subscriptions/list_subscriptions.py | 587 ++++- .../list_subscriptions/res/__init__.py | 0 .../{ => res}/blocklist.svg | 0 .../{ => res}/bulk_edit_dialog.ui | 0 .../{ => res}/list_subscriptions_dialog.ui | 25 +- .../{ => res}/subscription_dialog.ui | 71 +- 10 files changed, 2645 insertions(+), 747 deletions(-) create mode 100644 ui/opensnitch/plugins/list_subscriptions/res/__init__.py rename ui/opensnitch/plugins/list_subscriptions/{ => res}/blocklist.svg (100%) rename ui/opensnitch/plugins/list_subscriptions/{ => res}/bulk_edit_dialog.ui (100%) rename ui/opensnitch/plugins/list_subscriptions/{ => res}/list_subscriptions_dialog.ui (91%) rename ui/opensnitch/plugins/list_subscriptions/{ => res}/subscription_dialog.ui (89%) diff --git a/ui/opensnitch/plugins/list_subscriptions/__init__.py b/ui/opensnitch/plugins/list_subscriptions/__init__.py index e69de29bb2..85b3702a9f 100644 --- a/ui/opensnitch/plugins/list_subscriptions/__init__.py +++ b/ui/opensnitch/plugins/list_subscriptions/__init__.py @@ -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 . + diff --git a/ui/opensnitch/plugins/list_subscriptions/_gui.py b/ui/opensnitch/plugins/list_subscriptions/_gui.py index 0761891a63..981c328644 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_gui.py +++ b/ui/opensnitch/plugins/list_subscriptions/_gui.py @@ -5,7 +5,6 @@ import sys import threading from urllib.parse import urlparse, unquote -from datetime import datetime from typing import cast, Any, TYPE_CHECKING if TYPE_CHECKING: @@ -30,6 +29,7 @@ from opensnitch.actions import Actions from opensnitch.nodes import Nodes +from opensnitch.plugins import PluginSignal from opensnitch.utils.xdg import xdg_config_home from opensnitch.plugins.list_subscriptions._models import ( GlobalDefaults, @@ -37,22 +37,32 @@ MutableSubscriptionSpec, PluginConfig, SubscriptionSpec, +) +from opensnitch.plugins.list_subscriptions._utils import ( + RuntimeEvent, ensure_filename_type_suffix, normalize_group, normalize_groups, normalize_lists_dir, + read_json_locked, + write_json_atomic_locked, ) from opensnitch.dialogs.ruleseditor import RulesEditorDialog import requests from .list_subscriptions import ListSubscriptions -ACTION_FILE = os.path.join(xdg_config_home, "opensnitch", "actions", "list_subscriptions.json") +ACTION_FILE = os.path.join( + xdg_config_home, "opensnitch", "actions", "list_subscriptions.json" +) DEFAULT_LISTS_DIR = os.path.join(xdg_config_home, "opensnitch", "list_subscriptions") PLUGIN_DIR = os.path.abspath(os.path.dirname(__file__)) -LIST_SUBSCRIPTIONS_DIALOG_UI_PATH = os.path.join(PLUGIN_DIR, "list_subscriptions_dialog.ui") -SUBSCRIPTION_DIALOG_UI_PATH = os.path.join(PLUGIN_DIR, "subscription_dialog.ui") -BULK_EDIT_DIALOG_UI_PATH = os.path.join(PLUGIN_DIR, "bulk_edit_dialog.ui") +RES_DIR = os.path.join(PLUGIN_DIR, "res") +LIST_SUBSCRIPTIONS_DIALOG_UI_PATH = os.path.join( + RES_DIR, "list_subscriptions_dialog.ui" +) +SUBSCRIPTION_DIALOG_UI_PATH = os.path.join(RES_DIR, "subscription_dialog.ui") +BULK_EDIT_DIALOG_UI_PATH = os.path.join(RES_DIR, "bulk_edit_dialog.ui") SubscriptionDialogUI = uic.loadUiType(SUBSCRIPTION_DIALOG_UI_PATH)[0] # type: ignore BulkEditDialogUI = uic.loadUiType(BULK_EDIT_DIALOG_UI_PATH)[0] # type: ignore @@ -85,12 +95,36 @@ logger = logging.getLogger(__name__) +class KeepForegroundOnSelectionDelegate(QtWidgets.QStyledItemDelegate): + def initStyleOption(self, option, index): + 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 SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): + _url_test_finished = QtCore.pyqtSignal(bool, str) + if TYPE_CHECKING: enabled_check: QtWidgets.QCheckBox name_edit: QtWidgets.QLineEdit + name_error_label: QtWidgets.QLabel url_edit: QtWidgets.QLineEdit + url_error_label: QtWidgets.QLabel filename_edit: QtWidgets.QLineEdit + filename_error_label: QtWidgets.QLabel format_combo: QtWidgets.QComboBox group_combo: QtWidgets.QComboBox interval_spin: QtWidgets.QSpinBox @@ -110,6 +144,7 @@ class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): meta_list_path: QtWidgets.QLabel meta_meta_path: QtWidgets.QLabel error_label: QtWidgets.QLabel + test_url_button: QtWidgets.QPushButton cancel_button: QtWidgets.QPushButton add_button: QtWidgets.QPushButton _title: str @@ -123,7 +158,7 @@ def __init__( parent: QtWidgets.QWidget | None, defaults: GlobalDefaults, groups: list[str] | None = None, - sub: MutableSubscriptionSpec | None = None, + sub: MutableSubscriptionSpec | dict[str, Any] | None = None, meta: dict[str, str] | None = None, title: str = "Subscription", ): @@ -131,25 +166,64 @@ def __init__( self.setWindowTitle(QC.translate("stats", title)) self._title = title self._defaults = defaults - self._groups = groups or ["all"] - self._sub = sub or MutableSubscriptionSpec( - enabled=True, - groups=["all"], - interval=self._defaults.interval, - interval_units=self._defaults.interval_units, - timeout=self._defaults.timeout, - timeout_units=self._defaults.timeout_units, - max_size=self._defaults.max_size, - max_size_units=self._defaults.max_size_units, - ) + 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._build_ui() def _build_ui(self): self.setupUi(self) - self.error_label.setStyleSheet("color: red;") + self._set_dialog_message("", error=False) + 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._handle_url_test_finished) self.add_button.clicked.connect(self._validate_then_accept) + self.test_url_button.clicked.connect(self._test_url) self.cancel_button.clicked.connect(self.reject) self.enabled_check.setChecked(bool(self._sub.enabled)) @@ -161,34 +235,72 @@ def _build_ui(self): self.format_combo.setCurrentText(str(self._sub.format or "hosts")) for g in self._groups: ng = normalize_group(g) - if ng != "": + if ng not in ("", "all"): self.group_combo.addItem(ng) current_groups = normalize_groups(self._sub.groups) current_group_text = ", ".join(current_groups) - if self.group_combo.findText(current_group_text) < 0: + 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) - self.interval_spin.setRange(1, 999999) - self.interval_spin.setValue(max(1, int(self._sub.interval))) + self.interval_spin.setRange(0, 999999) + self.interval_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.interval, + self._defaults.interval_units, + ) + ) + self.interval_spin.setValue(max(0, int(self._sub.interval or 0))) self.interval_units.clear() self.interval_units.addItems(INTERVAL_UNITS) self.interval_units.setCurrentText( - self._normalize_unit(str(self._sub.interval_units), INTERVAL_UNITS, "hours") + self._normalize_unit( + str(self._sub.interval_units or self._defaults.interval_units), + INTERVAL_UNITS, + "hours", + ) ) - self.timeout_spin.setRange(1, 999999) - self.timeout_spin.setValue(max(1, int(self._sub.timeout))) + self.timeout_spin.setRange(0, 999999) + self.timeout_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.timeout, + self._defaults.timeout_units, + ) + ) + self.timeout_spin.setValue(max(0, int(self._sub.timeout or 0))) self.timeout_units.clear() self.timeout_units.addItems(TIMEOUT_UNITS) self.timeout_units.setCurrentText( - self._normalize_unit(str(self._sub.timeout_units), TIMEOUT_UNITS, "seconds") + self._normalize_unit( + str(self._sub.timeout_units or self._defaults.timeout_units), + TIMEOUT_UNITS, + "seconds", + ) ) - self.max_size_spin.setRange(1, 999999) - self.max_size_spin.setValue(max(1, int(self._sub.max_size))) + self.max_size_spin.setRange(0, 999999) + self.max_size_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.max_size, + self._defaults.max_size_units, + ) + ) + self.max_size_spin.setValue(max(0, int(self._sub.max_size or 0))) self.max_size_units.clear() self.max_size_units.addItems(SIZE_UNITS) self.max_size_units.setCurrentText( - self._normalize_unit(str(self._sub.max_size_units), SIZE_UNITS, "MB") + self._normalize_unit( + str(self._sub.max_size_units or self._defaults.max_size_units), + SIZE_UNITS, + "MB", + ) ) + 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) + self._apply_optional_field_tooltips() + self._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", ""))) @@ -209,17 +321,146 @@ def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): return unit return fallback - def _validate_then_accept(self): + def _apply_optional_field_tooltips(self): + self.interval_spin.setToolTip( + QC.translate("stats", "Set to 0 to inherit the global interval.") + ) + self.interval_units.setToolTip( + QC.translate("stats", "Used only when the interval override is set.") + ) + self.timeout_spin.setToolTip( + QC.translate("stats", "Set to 0 to inherit the global timeout.") + ) + self.timeout_units.setToolTip( + QC.translate("stats", "Used only when the timeout override is set.") + ) + self.max_size_spin.setToolTip( + QC.translate("stats", "Set to 0 to inherit the global max size.") + ) + self.max_size_units.setToolTip( + QC.translate("stats", "Used only when the max size override is set.") + ) + + def _sync_optional_fields_state(self): + self.interval_units.setEnabled(self.interval_spin.value() > 0) + self.timeout_units.setEnabled(self.timeout_spin.value() > 0) + self.max_size_units.setEnabled(self.max_size_spin.value() > 0) + + def _clear_field_errors(self): + self._set_dialog_message("", error=False) + self.name_error_label.setText("") + self.url_error_label.setText("") + self.filename_error_label.setText("") + + def _set_dialog_message(self, message: str, error: bool): + color = "red" if error else "#2e7d32" + self.error_label.setStyleSheet(f"color: {color};") + self.error_label.setText(message) + + def _is_valid_url(self, value: str): + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and parsed.netloc != "" + + def _test_url(self): + self.url_error_label.setText("") + self._set_dialog_message("", error=False) url = (self.url_edit.text() or "").strip() if url == "": - self.error_label.setText(QC.translate("stats", "URL is required.")) + self.url_error_label.setText(QC.translate("stats", "URL is required.")) + self._set_dialog_message( + QC.translate("stats", "Fix the highlighted fields."), error=True + ) return - name = (self.name_edit.text() or "").strip() - filename = os.path.basename((self.filename_edit.text() or "").strip()) + if not self._is_valid_url(url): + self.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.test_url_button.setEnabled(False) + self._set_dialog_message(QC.translate("stats", "Testing URL..."), error=False) + + def _run_test(): + try: + response = requests.head(url, allow_redirects=True, timeout=5) + 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 url + response.close() + if response.status_code in (403, 405): + response = requests.get( + url, allow_redirects=True, timeout=5, stream=True + ) + if response.status_code >= 400: + raise requests.HTTPError(f"HTTP {response.status_code}") + final_url = response.url or final_url + response.close() + message = QC.translate("stats", "URL reachable.") + if final_url != url: + message = QC.translate( + "stats", "URL reachable via redirect to {0}" + ).format(final_url) + self._url_test_finished.emit(True, message) + except requests.RequestException as exc: + self._url_test_finished.emit(False, str(exc)) + + threading.Thread(target=_run_test, daemon=True).start() + + def _handle_url_test_finished(self, success: bool, message: str): + self.test_url_button.setEnabled(True) + if success: + self.url_error_label.setText("") + self._set_dialog_message(message, error=False) + return + self.url_error_label.setText(QC.translate("stats", "URL check failed.")) + self._set_dialog_message( + QC.translate("stats", "URL test failed: {0}").format(message), + error=True, + ) + + def _validate_then_accept(self): + self._clear_field_errors() + raw_url = (self.url_edit.text() or "").strip() + raw_name = (self.name_edit.text() or "").strip() + raw_filename = (self.filename_edit.text() or "").strip() list_type = (self.format_combo.currentText() or "hosts").strip().lower() + name = raw_name + filename = os.path.basename(raw_filename) + has_error = False + + if raw_url == "": + self.url_error_label.setText(QC.translate("stats", "URL is required.")) + has_error = True + elif not self._is_valid_url(raw_url): + self.url_error_label.setText( + QC.translate("stats", "Enter a valid http:// or https:// URL.") + ) + has_error = True + + if raw_name == "" and raw_filename == "": + self.name_error_label.setText( + QC.translate("stats", "Provide a name or filename.") + ) + self.filename_error_label.setText( + QC.translate("stats", "Provide a filename or name.") + ) + has_error = True + elif raw_filename != "" and filename != raw_filename: + self.filename_error_label.setText( + QC.translate("stats", "Filename must not include directory components.") + ) + has_error = True - if name == "" and filename == "": - self.error_label.setText(QC.translate("stats", "Provide at least a name or a filename.")) + if has_error: + self._set_dialog_message( + QC.translate("stats", "Fix the highlighted fields."), error=True + ) return if filename == "" and name != "": @@ -231,12 +472,6 @@ def _validate_then_accept(self): self.name_edit.setText(name) self.filename_edit.setText(filename) - - groups = normalize_groups(self.group_combo.currentText()) - if not groups: - self.error_label.setText(QC.translate("stats", "At least one group is required.")) - return - self.error_label.setText("") self.accept() def _slugify_name(self, name: str): @@ -263,7 +498,7 @@ def _deslugify_filename(self, filename: str, list_type: str): return pretty.title() def subscription_spec(self): - groups = normalize_groups((self.group_combo.currentText() or "all").strip()) + groups = normalize_groups((self.group_combo.currentText() or "").strip()) return MutableSubscriptionSpec( enabled=self.enabled_check.isChecked(), name=(self.name_edit.text() or "").strip(), @@ -271,12 +506,24 @@ def subscription_spec(self): filename=(self.filename_edit.text() or "").strip(), format=(self.format_combo.currentText() or "hosts").strip().lower(), groups=groups, - interval=int(self.interval_spin.value()), - interval_units=self.interval_units.currentText(), - timeout=int(self.timeout_spin.value()), - timeout_units=self.timeout_units.currentText(), - max_size=int(self.max_size_spin.value()), - max_size_units=self.max_size_units.currentText(), + 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 + ), ) @@ -312,13 +559,19 @@ def __init__( super().__init__(parent) self.setWindowTitle(QC.translate("stats", "Edit selected subscriptions")) self._defaults = defaults - self._groups = groups or ["all"] + self._groups = groups or [] self._build_ui() def _build_ui(self): self.setupUi(self) self.error_label.setStyleSheet("color: red;") 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.cancel_button.clicked.connect(self.reject) self.save_button.clicked.connect(self._validate_then_accept) @@ -326,28 +579,55 @@ def _build_ui(self): self.group_value.clear() for g in self._groups: ng = normalize_group(g) - if ng != "": + if ng not in ("", "all"): self.group_value.addItem(ng) - if self.group_value.findText("all") < 0: - self.group_value.addItem("all") - self.group_value.setCurrentText("all") + self.group_value.setCurrentText("") self.format_value.clear() self.format_value.addItems(("hosts",)) - self.interval_spin.setRange(1, 999999) - self.interval_spin.setValue(max(1, int(self._defaults.interval))) + self.interval_spin.setRange(0, 999999) + self.interval_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.interval, + self._defaults.interval_units, + ) + ) + self.interval_spin.setValue(0) self.interval_units.clear() self.interval_units.addItems(INTERVAL_UNITS) - self.interval_units.setCurrentText(self._normalize_unit(self._defaults.interval_units, INTERVAL_UNITS, "hours")) - self.timeout_spin.setRange(1, 999999) - self.timeout_spin.setValue(max(1, int(self._defaults.timeout))) + self.interval_units.setCurrentText( + self._normalize_unit(self._defaults.interval_units, INTERVAL_UNITS, "hours") + ) + self.timeout_spin.setRange(0, 999999) + self.timeout_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.timeout, + self._defaults.timeout_units, + ) + ) + self.timeout_spin.setValue(0) self.timeout_units.clear() self.timeout_units.addItems(TIMEOUT_UNITS) - self.timeout_units.setCurrentText(self._normalize_unit(self._defaults.timeout_units, TIMEOUT_UNITS, "seconds")) - self.max_size_spin.setRange(1, 999999) - self.max_size_spin.setValue(max(1, int(self._defaults.max_size))) + self.timeout_units.setCurrentText( + self._normalize_unit(self._defaults.timeout_units, TIMEOUT_UNITS, "seconds") + ) + self.max_size_spin.setRange(0, 999999) + self.max_size_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.max_size, + self._defaults.max_size_units, + ) + ) + self.max_size_spin.setValue(0) self.max_size_units.clear() self.max_size_units.addItems(SIZE_UNITS) - self.max_size_units.setCurrentText(self._normalize_unit(self._defaults.max_size_units, SIZE_UNITS, "MB")) + self.max_size_units.setCurrentText( + self._normalize_unit(self._defaults.max_size_units, SIZE_UNITS, "MB") + ) + 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) + self._apply_optional_field_tooltips() + self._sync_optional_fields_state() self.resize(640, 360) def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): @@ -357,6 +637,40 @@ def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): return unit return fallback + def _apply_optional_field_tooltips(self): + self.interval_spin.setToolTip( + QC.translate( + "stats", + "Set to 0 to clear the interval override and use the global default.", + ) + ) + self.interval_units.setToolTip( + QC.translate("stats", "Used only when an interval override is applied.") + ) + self.timeout_spin.setToolTip( + QC.translate( + "stats", + "Set to 0 to clear the timeout override and use the global default.", + ) + ) + self.timeout_units.setToolTip( + QC.translate("stats", "Used only when a timeout override is applied.") + ) + self.max_size_spin.setToolTip( + QC.translate( + "stats", + "Set to 0 to clear the max size override and use the global default.", + ) + ) + self.max_size_units.setToolTip( + QC.translate("stats", "Used only when a max size override is applied.") + ) + + def _sync_optional_fields_state(self): + self.interval_units.setEnabled(self.interval_spin.value() > 0) + self.timeout_units.setEnabled(self.timeout_spin.value() > 0) + self.max_size_units.setEnabled(self.max_size_spin.value() > 0) + def _validate_then_accept(self): if not any( ( @@ -368,22 +682,51 @@ def _validate_then_accept(self): self.apply_max_size.isChecked(), ) ): - self.error_label.setText(QC.translate("stats", "Select at least one field to apply.")) + self.error_label.setText( + QC.translate("stats", "Select at least one field to apply.") + ) return self.error_label.setText("") self.accept() def values(self): return { - "enabled": self.enabled_value.isChecked() if self.apply_enabled.isChecked() else None, - "groups": normalize_groups(self.group_value.currentText()) if self.apply_group.isChecked() else None, - "format": (self.format_value.currentText() or "hosts").strip().lower() if self.apply_format.isChecked() else None, - "interval": int(self.interval_spin.value()) if self.apply_interval.isChecked() else None, - "interval_units": self.interval_units.currentText() if self.apply_interval.isChecked() else None, - "timeout": int(self.timeout_spin.value()) if self.apply_timeout.isChecked() else None, - "timeout_units": self.timeout_units.currentText() if self.apply_timeout.isChecked() else None, - "max_size": int(self.max_size_spin.value()) if self.apply_max_size.isChecked() else None, - "max_size_units": self.max_size_units.currentText() if self.apply_max_size.isChecked() else None, + "enabled": ( + self.enabled_value.isChecked() + if self.apply_enabled.isChecked() + else None + ), + "groups": ( + normalize_groups(self.group_value.currentText()) + if self.apply_group.isChecked() + else None + ), + "format": ( + (self.format_value.currentText() or "hosts").strip().lower() + if self.apply_format.isChecked() + else None + ), + "apply_interval": self.apply_interval.isChecked(), + "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.apply_timeout.isChecked(), + "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.apply_max_size.isChecked(), + "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 + ), } @@ -393,6 +736,9 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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_label: QtWidgets.QLabel lists_dir_edit: QtWidgets.QLineEdit default_interval_spin: QtWidgets.QSpinBox default_interval_units: QtWidgets.QComboBox @@ -417,6 +763,8 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): _loading: bool _global_defaults: GlobalDefaults _state_poll_timer: QtCore.QTimer + _runtime_plugin: ListSubscriptions | None + _pending_runtime_reload: str | None _download_finished = QtCore.pyqtSignal() @@ -435,8 +783,12 @@ def __init__( 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._global_defaults: GlobalDefaults = GlobalDefaults.from_dict( + {}, lists_dir=DEFAULT_LISTS_DIR + ) self._rules_dialog: RulesEditorDialog | None = None + self._runtime_plugin: ListSubscriptions | None = None + self._pending_runtime_reload: str | None = None self._state_poll_timer = QtCore.QTimer(self) self._state_poll_timer.setInterval(2000) self._state_poll_timer.timeout.connect(self._refresh_states_if_visible) @@ -475,35 +827,86 @@ def _build_ui(self): self.default_max_size_units.addItems(SIZE_UNITS) self.table.setColumnCount(19) - self.table.setHorizontalHeaderLabels([ - QC.translate("stats", "Enabled"), - 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", "Last checked"), - QC.translate("stats", "Last updated"), - QC.translate("stats", "Failures"), - QC.translate("stats", "Error"), - ]) - self.table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers) - self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows) - self.table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection) + 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", "Last checked"), + QC.translate("stats", "Last updated"), + QC.translate("stats", "Failures"), + QC.translate("stats", "Error"), + ] + ) + 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 + ) + state_delegate = KeepForegroundOnSelectionDelegate(self.table) + for col in ( + COL_STATE, + COL_LAST_CHECKED, + COL_LAST_UPDATED, + ): + self.table.setItemDelegateForColumn(col, state_delegate) header = self.table.horizontalHeader() if header is not None: header.setStretchLastSection(True) - header.setSectionResizeMode(COL_URL, QtWidgets.QHeaderView.ResizeMode.Stretch) - header.setSectionResizeMode(COL_ERROR, QtWidgets.QHeaderView.ResizeMode.Stretch) + 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) + 12) + header.setSectionResizeMode( + COL_URL, QtWidgets.QHeaderView.ResizeMode.Stretch + ) + header.setSectionResizeMode( + COL_ERROR, QtWidgets.QHeaderView.ResizeMode.Stretch + ) # Keep advanced tuning + verbose metadata available internally but # reduce visible table complexity; edit dialog exposes full details. for col in ( @@ -522,7 +925,9 @@ def _build_ui(self): self.create_file_button.clicked.connect(self.create_action_file) self.save_button.clicked.connect(self.save_action_file) - self.reload_button.clicked.connect(self.load_action_file) + self.reload_button.clicked.connect(self.reload_runtime_and_config) + self.start_runtime_button.clicked.connect(self.start_runtime_clicked) + self.stop_runtime_button.clicked.connect(self.stop_runtime_clicked) self.add_sub_button.clicked.connect(self.add_subscription_row) self.create_global_rule_button.clicked.connect(self.create_global_rule) self.edit_sub_button.clicked.connect(self.edit_action_clicked) @@ -530,14 +935,36 @@ def _build_ui(self): self.refresh_state_button.clicked.connect(self.refresh_all_now) self.refresh_now_button.clicked.connect(self.refresh_selected_now) self.create_rule_button.clicked.connect(self.create_rule_from_selected) - self.table.itemDoubleClicked.connect(lambda *_: self.edit_selected_subscription()) + self.table.itemDoubleClicked.connect( + lambda *_: self.edit_selected_subscription() + ) self.table.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self._open_table_context_menu) sel_model = self.table.selectionModel() if sel_model is not None: - sel_model.selectionChanged.connect(lambda *_: self._update_selected_actions_state()) + sel_model.selectionChanged.connect( + lambda *_: self._update_selected_actions_state() + ) + self._set_runtime_state(active=False) self._update_selected_actions_state() + def _sync_runtime_binding_state(self): + runtime_plugin = ListSubscriptions.get_instance() + if runtime_plugin is None: + _action_key, _action_obj, loaded_plugin = self._find_loaded_action() + runtime_plugin = loaded_plugin + + 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._runtime_plugin = None + self._set_runtime_state(active=False) + return None + def load_action_file(self): self._loading = True self._set_status("") @@ -546,42 +973,71 @@ def load_action_file(self): self.create_file_button.setVisible(True) self.lists_dir_edit.setText(DEFAULT_LISTS_DIR) self.enable_plugin_check.setChecked(False) - self._global_defaults = GlobalDefaults.from_dict({}, lists_dir=DEFAULT_LISTS_DIR) + self._set_runtime_state(active=False) + self._global_defaults = GlobalDefaults.from_dict( + {}, lists_dir=DEFAULT_LISTS_DIR + ) self._apply_defaults_to_widgets() if not os.path.exists(self._action_path): - self._set_status(QC.translate("stats", "Action file not found. Click 'Create action file'."), error=False) + self._set_status( + QC.translate( + "stats", "Action file not found. Click 'Create action file'." + ), + error=False, + ) self._loading = False return try: - with open(self._action_path, "r", encoding="utf-8") as f: - data = json.load(f) + data = read_json_locked(self._action_path) except Exception as e: - self._set_status(QC.translate("stats", "Error reading action file: {0}").format(str(e)), error=True) + self._set_status( + QC.translate("stats", "Error reading action file: {0}").format(str(e)), + error=True, + ) self._loading = False return - action_model = MutableActionConfig.from_action_dict(data, lists_dir=DEFAULT_LISTS_DIR) - self._global_defaults = action_model.defaults + action_model = MutableActionConfig.from_action_dict( + data, lists_dir=DEFAULT_LISTS_DIR + ) + self._global_defaults = action_model.plugin.defaults self.enable_plugin_check.setChecked(action_model.enabled) - self.lists_dir_edit.setText(normalize_lists_dir(self._global_defaults.lists_dir)) + self._sync_runtime_binding_state() + self.lists_dir_edit.setText( + normalize_lists_dir(self._global_defaults.lists_dir) + ) self._apply_defaults_to_widgets() - normalized_subs = action_model.subscriptions + 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 {} + 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): + 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 + fixed_count = ( + 1 + if (isinstance(raw_subs, list) and raw_subs != normalized_subs_dicts) + else 0 + ) for sub in normalized_subs: self._append_row(sub) @@ -593,29 +1049,147 @@ def load_action_file(self): if migrated_legacy_group: self.save_action_file() self._set_status( - QC.translate("stats", "Migrated legacy 'group' entries to 'groups' and auto-saved configuration."), + QC.translate( + "stats", + "Migrated legacy 'group' entries to 'groups' and auto-saved configuration.", + ), error=False, ) return if fixed_count > 0: self._set_status( - QC.translate("stats", "Loaded configuration with normalized subscription fields."), + QC.translate( + "stats", "Loaded configuration with normalized subscription fields." + ), error=False, ) else: - self._set_status(QC.translate("stats", "List subscriptions configuration loaded."), error=False) + self._set_status( + QC.translate("stats", "List subscriptions configuration loaded."), + error=False, + ) + + 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._set_status(QC.translate("stats", "Runtime is already active.")) + return + + if not os.path.exists(self._action_path): + self._set_status( + QC.translate( + "stats", "Action file not found. Create and save the configuration first." + ), + error=True, + ) + return + + if runtime_plugin is not None: + self._bind_runtime_plugin(runtime_plugin) + self._set_runtime_state(active=None, text=QC.translate("stats", "Runtime: starting")) + try: + runtime_plugin.signal_in.emit( + { + "plugin": runtime_plugin.get_name(), + "signal": PluginSignal.ENABLE, + "action_path": self._action_path, + } + ) + except Exception: + self._set_runtime_state(active=False) + self._set_status( + QC.translate("stats", "Failed to start runtime."), + error=True, + ) + return + + plug = ListSubscriptions({}) + self._bind_runtime_plugin(plug) + self._set_runtime_state( + active=None, + text=QC.translate("stats", "Runtime: starting"), + ) + try: + plug.signal_in.emit( + { + "plugin": plug.get_name(), + "signal": PluginSignal.ENABLE, + "action_path": self._action_path, + } + ) + except Exception: + self._set_runtime_state(active=False) + self._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._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")) + try: + runtime_plugin.signal_in.emit( + { + "plugin": runtime_plugin.get_name(), + "signal": PluginSignal.DISABLE, + "action_path": self._action_path, + } + ) + except Exception: + self._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.load_action_file() + return + + self._bind_runtime_plugin(runtime_plugin) + self._pending_runtime_reload = "waiting_config_reload" + try: + runtime_plugin.signal_in.emit( + { + "plugin": runtime_plugin.get_name(), + "signal": PluginSignal.CONFIG_UPDATE, + "action_path": self._action_path, + } + ) + except Exception: + self._pending_runtime_reload = None + self._set_status( + QC.translate( + "stats", "Runtime reload failed to start. Restart UI." + ), + error=True, + ) def create_action_file(self): try: os.makedirs(os.path.dirname(self._action_path), mode=0o700, exist_ok=True) if not os.path.exists(self._action_path): action_model = MutableActionConfig.default(DEFAULT_LISTS_DIR) - with open(self._action_path, "w", encoding="utf-8") as f: - json.dump(action_model.to_action_dict(), f, indent=2) + write_json_atomic_locked( + self._action_path, + action_model.to_action_dict(), + ) self.load_action_file() self._set_status(QC.translate("stats", "Action file created."), error=False) except Exception as e: - self._set_status(QC.translate("stats", "Error creating action file: {0}").format(str(e)), error=True) + self._set_status( + QC.translate("stats", "Error creating action file: {0}").format(str(e)), + error=True, + ) def save_action_file(self): if self._loading: @@ -630,7 +1204,9 @@ def save_action_file(self): if subscriptions is None: return - lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) + lists_dir = normalize_lists_dir( + self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) try: os.makedirs(lists_dir, mode=0o700, exist_ok=True) except Exception: @@ -647,33 +1223,60 @@ def save_action_file(self): ) action_model = MutableActionConfig.default(lists_dir) action_model.enabled = self.enable_plugin_check.isChecked() - action_model.defaults = defaults - action_model.subscriptions = subscriptions + 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._set_status( + QC.translate( + "stats", + "Invalid subscriptions: duplicate filename for the same URL.", + ), + error=True, + ) + return action = action_model.to_action_dict() - action["updated"] = datetime.now().astimezone().isoformat() - compiled_cfg = PluginConfig.from_dict(action_model.to_plugin_dict(), lists_dir=lists_dir) - if len(compiled_cfg.subscriptions) != len(subscriptions): - self._set_status(QC.translate("stats", "Invalid subscriptions: URL and filename are mandatory."), error=True) + 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._set_status( + QC.translate( + "stats", "Invalid subscriptions: URL and filename are mandatory." + ), + error=True, + ) return - tmp_path = self._action_path + ".tmp" + for row, sub in enumerate(normalized_subscriptions): + self._set_text_item(row, COL_NAME, sub.name) + self._set_text_item(row, COL_FILENAME, self._safe_filename(sub.filename)) + try: - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(action, f, indent=2) - f.flush() - os.fsync(f.fileno()) - os.replace(tmp_path, self._action_path) + write_json_atomic_locked(self._action_path, action) except Exception as e: - self._set_status(QC.translate("stats", "Error saving action file: {0}").format(str(e)), error=True) + self._set_status( + QC.translate("stats", "Error saving action file: {0}").format(str(e)), + error=True, + ) return self._apply_runtime_state(action_model.enabled) self.refresh_states() - self._set_status(QC.translate("stats", "List subscriptions configuration saved."), error=False) + self._set_status( + QC.translate("stats", "List subscriptions configuration saved."), + error=False, + ) def refresh_states(self): - lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) + lists_dir = normalize_lists_dir( + self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) for row in range(self.table.rowCount()): filename_item = self.table.item(row, COL_FILENAME) enabled_item = self.table.item(row, COL_ENABLED) @@ -701,63 +1304,114 @@ def refresh_states(self): 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 "" + fg_color: QtGui.QColor if not enabled: state = "disabled" - color = QtGui.QColor("lightgray") + fg_color = self._state_text_color("disabled") elif not file_exists: # New/manual subscriptions may not be downloaded yet. # Expose that as pending instead of an error-like missing state. if not meta_exists or last_result in ("never", "", "busy"): state = "pending" - color = QtGui.QColor("khaki") + fg_color = self._state_text_color("pending") else: state = "missing" - color = QtGui.QColor("tomato") + fg_color = self._state_text_color("missing") elif last_result in ("updated", "not_modified"): state = last_result - color = QtGui.QColor("lightgreen") - elif last_result in ("error", "write_error", "request_error", "unexpected_error", "bad_format", "too_large"): + fg_color = self._state_text_color(last_result) + elif last_result in ( + "error", + "write_error", + "request_error", + "unexpected_error", + "bad_format", + "too_large", + ): state = last_result - color = QtGui.QColor("salmon") + fg_color = self._state_text_color(last_result) elif last_result == "busy": state = "busy" - color = QtGui.QColor("khaki") + fg_color = self._state_text_color("busy") else: state = last_result - color = QtGui.QColor("lightyellow") + fg_color = self._state_text_color("other") - self._set_text_item(row, COL_FILE, "yes" if file_exists else "no", editable=False) - self._set_text_item(row, COL_META, "yes" if meta_exists else "no", editable=False) + self._set_text_item( + row, COL_FILE, "yes" if file_exists else "no", editable=False + ) + self._set_text_item( + row, COL_META, "yes" if meta_exists else "no", editable=False + ) self._set_text_item(row, COL_STATE, state, editable=False) self._set_text_item(row, COL_LAST_CHECKED, last_checked, editable=False) self._set_text_item(row, COL_LAST_UPDATED, last_updated, editable=False) self._set_text_item(row, COL_FAILS, fail_count, editable=False) self._set_text_item(row, COL_ERROR, last_error, editable=False) - for col in (COL_FILE, COL_META, COL_STATE, COL_LAST_CHECKED, COL_LAST_UPDATED, COL_FAILS, COL_ERROR): + for col in ( + COL_FILE, + COL_META, + COL_STATE, + COL_LAST_CHECKED, + COL_LAST_UPDATED, + COL_FAILS, + COL_ERROR, + ): item = self.table.item(row, col) if item is not None: - item.setBackground(color) + item.setForeground(fg_color) + + def _state_text_color(self, state: str): + palette = self.table.palette() + dark_theme = palette.base().color().lightness() < 128 + + if dark_theme: + colors = { + "disabled": "#B8C0CC", + "pending": "#F5D76E", + "busy": "#F5D76E", + "missing": "#FF8A80", + "updated": "#7CE3A1", + "not_modified": "#86C5FF", + "error": "#FF8A80", + "write_error": "#FF8A80", + "request_error": "#FF8A80", + "unexpected_error": "#FF8A80", + "bad_format": "#FF8A80", + "too_large": "#FF8A80", + "other": "#F7E37A", + } + else: + colors = { + "disabled": "#6B7280", + "pending": "#9A6700", + "busy": "#9A6700", + "missing": "#C62828", + "updated": "#0F8A4B", + "not_modified": "#1565C0", + "error": "#C62828", + "write_error": "#C62828", + "request_error": "#C62828", + "unexpected_error": "#C62828", + "bad_format": "#C62828", + "too_large": "#C62828", + "other": "#8D6E00", + } + + return QtGui.QColor(colors.get(state, colors["other"])) def add_subscription_row(self): dlg = SubscriptionDialog( self, self._global_defaults, groups=self._known_groups(), - sub=MutableSubscriptionSpec( - enabled=True, - name="", - url="", - filename="", - format="hosts", - groups=["all"], - interval=self._global_defaults.interval, - interval_units=self._global_defaults.interval_units, - timeout=self._global_defaults.timeout, - timeout_units=self._global_defaults.timeout_units, - max_size=self._global_defaults.max_size, - max_size_units=self._global_defaults.max_size_units, + sub=MutableSubscriptionSpec.from_dict( + {"enabled": True}, + defaults=self._global_defaults, + require_url=False, + ensure_suffix=False, ), title="New subscription", ) @@ -779,31 +1433,49 @@ def add_subscription_row(self): def edit_selected_subscription(self): row = self.table.currentRow() if row < 0: - self._set_status(QC.translate("stats", "Select a subscription row first."), error=True) + self._set_status( + QC.translate("stats", "Select a subscription row first."), error=True + ) return enabled_item = self.table.item(row, COL_ENABLED) if enabled_item is None: enabled_item = QtWidgets.QTableWidgetItem("") - enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + enabled_item.setFlags( + enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable + ) self.table.setItem(row, COL_ENABLED, enabled_item) - interval_val = self._to_int_or_keep(self._cell_text(row, COL_INTERVAL)) - timeout_val = self._to_int_or_keep(self._cell_text(row, COL_TIMEOUT)) - max_size_val = self._to_int_or_keep(self._cell_text(row, COL_MAX_SIZE)) + interval_ok, interval_val = self._optional_int_from_text( + self._cell_text(row, COL_INTERVAL), "Interval", row=row + ) + timeout_ok, timeout_val = self._optional_int_from_text( + self._cell_text(row, COL_TIMEOUT), "Timeout", row=row + ) + max_size_ok, max_size_val = self._optional_int_from_text( + self._cell_text(row, 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._cell_text(row, COL_NAME), url=self._cell_text(row, COL_URL), filename=self._cell_text(row, COL_FILENAME), format=self._cell_text(row, COL_FORMAT) or "hosts", - groups=normalize_groups(self._cell_text(row, COL_GROUP) or "all"), - interval=interval_val if isinstance(interval_val, int) else self._global_defaults.interval, - interval_units=self._cell_text(row, COL_INTERVAL_UNITS) or self._global_defaults.interval_units, - timeout=timeout_val if isinstance(timeout_val, int) else self._global_defaults.timeout, - timeout_units=self._cell_text(row, COL_TIMEOUT_UNITS) or self._global_defaults.timeout_units, - max_size=max_size_val if isinstance(max_size_val, int) else self._global_defaults.max_size, - max_size_units=self._cell_text(row, COL_MAX_SIZE_UNITS) or self._global_defaults.max_size_units, + groups=normalize_groups(self._cell_text(row, COL_GROUP)), + interval=interval_val, + interval_units=self._optional_unit_from_text( + self._cell_text(row, COL_INTERVAL_UNITS) + ), + timeout=timeout_val, + timeout_units=self._optional_unit_from_text( + self._cell_text(row, COL_TIMEOUT_UNITS) + ), + max_size=max_size_val, + max_size_units=self._optional_unit_from_text( + self._cell_text(row, COL_MAX_SIZE_UNITS) + ), ) meta = self._row_meta_snapshot(row) dlg = SubscriptionDialog( @@ -821,10 +1493,14 @@ def edit_selected_subscription(self): enabled_item = self.table.item(row, COL_ENABLED) if enabled_item is None: enabled_item = QtWidgets.QTableWidgetItem("") - enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + enabled_item.setFlags( + enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable + ) self.table.setItem(row, COL_ENABLED, enabled_item) enabled_item.setCheckState( - QtCore.Qt.CheckState.Checked if bool(updated.enabled) else QtCore.Qt.CheckState.Unchecked + QtCore.Qt.CheckState.Checked + if bool(updated.enabled) + else QtCore.Qt.CheckState.Unchecked ) self._set_text_item(row, COL_NAME, updated.name) self._set_text_item(row, COL_URL, updated.url) @@ -840,7 +1516,9 @@ def edit_selected_subscription(self): self._set_text_item(row, COL_MAX_SIZE, self._to_str(updated.max_size)) max_size_units_val = self._to_str(updated.max_size_units) self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units_val) - self._set_units_combo(row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units_val) + self._set_units_combo( + row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units_val + ) self._set_units_combo(row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units_val) self._set_units_combo(row, COL_MAX_SIZE_UNITS, SIZE_UNITS, max_size_units_val) @@ -848,14 +1526,22 @@ def edit_selected_subscription(self): self.save_action_file() self.refresh_states() if changed: - self._set_status(QC.translate("stats", "Subscription updated and filename normalized."), error=False) + self._set_status( + QC.translate("stats", "Subscription updated and filename normalized."), + error=False, + ) else: - self._set_status(QC.translate("stats", "Subscription updated."), error=False) + self._set_status( + QC.translate("stats", "Subscription updated."), error=False + ) def edit_action_clicked(self): rows = self._selected_rows() if len(rows) == 0: - self._set_status(QC.translate("stats", "Select one or more subscriptions first."), error=True) + self._set_status( + QC.translate("stats", "Select one or more subscriptions first."), + error=True, + ) return if len(rows) == 1: self.edit_selected_subscription() @@ -869,14 +1555,19 @@ def remove_selected_subscription(self): if row >= 0: rows = [row] if not rows: - self._set_status(QC.translate("stats", "Select one or more subscription rows first."), error=True) + self._set_status( + QC.translate("stats", "Select one or more subscription rows first."), + error=True, + ) return for row in sorted(rows, reverse=True): self.table.removeRow(row) self.save_action_file() self.refresh_states() self._update_selected_actions_state() - self._set_status(QC.translate("stats", "Selected subscriptions removed."), error=False) + self._set_status( + QC.translate("stats", "Selected subscriptions removed."), error=False + ) def _selected_rows(self): idx = self.table.selectionModel() @@ -887,10 +1578,9 @@ def _selected_rows(self): def _update_selected_actions_state(self): count = len(self._selected_rows()) has_selection = count > 0 - single = count == 1 self.edit_sub_button.setEnabled(has_selection) self.remove_sub_button.setEnabled(has_selection) - self.refresh_now_button.setEnabled(single) + self.refresh_now_button.setEnabled(has_selection) self.create_rule_button.setEnabled(has_selection) def _open_table_context_menu(self, pos: QtCore.QPoint): @@ -910,7 +1600,7 @@ def _open_table_context_menu(self, pos: QtCore.QPoint): if len(rows) == 1: act_edit = menu.addAction(QC.translate("stats", "Edit")) act_remove = menu.addAction(QC.translate("stats", "Delete")) - act_refresh = menu.addAction(QC.translate("stats", "Refresh now")) + act_refresh = menu.addAction(QC.translate("stats", "Refresh")) act_rule = menu.addAction(QC.translate("stats", "Create rule")) chosen = menu.exec(viewport.mapToGlobal(pos)) if chosen is act_edit: @@ -925,12 +1615,15 @@ def _open_table_context_menu(self, pos: QtCore.QPoint): 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")) chosen = menu.exec(viewport.mapToGlobal(pos)) if chosen is act_edit: self._bulk_edit(rows) elif chosen is act_remove: self.remove_selected_subscription() + elif chosen is act_refresh: + self.refresh_selected_now() elif chosen is act_rule: self.create_rule_from_selected() @@ -946,137 +1639,224 @@ def _bulk_edit(self, rows: list[int]): enabled_item = self.table.item(row, COL_ENABLED) if enabled_item is None: enabled_item = QtWidgets.QTableWidgetItem("") - enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + enabled_item.setFlags( + enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable + ) self.table.setItem(row, COL_ENABLED, enabled_item) enabled_item.setCheckState( - QtCore.Qt.CheckState.Checked if bool(values["enabled"]) else QtCore.Qt.CheckState.Unchecked + QtCore.Qt.CheckState.Checked + if bool(values["enabled"]) + else QtCore.Qt.CheckState.Unchecked ) if values.get("groups") is not None: - self._set_text_item(row, COL_GROUP, ", ".join(normalize_groups(values["groups"]))) + self._set_text_item( + row, COL_GROUP, ", ".join(normalize_groups(values["groups"])) + ) if values.get("format") is not None: self._set_text_item(row, COL_FORMAT, str(values["format"])) - if values.get("interval") is not None: - self._set_text_item(row, COL_INTERVAL, str(values["interval"])) - if values.get("interval_units") is not None: - self._set_text_item(row, COL_INTERVAL_UNITS, str(values["interval_units"])) - self._set_units_combo(row, COL_INTERVAL_UNITS, INTERVAL_UNITS, str(values["interval_units"])) - if values.get("timeout") is not None: - self._set_text_item(row, COL_TIMEOUT, str(values["timeout"])) - if values.get("timeout_units") is not None: - self._set_text_item(row, COL_TIMEOUT_UNITS, str(values["timeout_units"])) - self._set_units_combo(row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, str(values["timeout_units"])) - if values.get("max_size") is not None: - self._set_text_item(row, COL_MAX_SIZE, str(values["max_size"])) - if values.get("max_size_units") is not None: - self._set_text_item(row, COL_MAX_SIZE_UNITS, str(values["max_size_units"])) - self._set_units_combo(row, COL_MAX_SIZE_UNITS, SIZE_UNITS, str(values["max_size_units"])) + if values.get("apply_interval"): + self._set_text_item( + row, COL_INTERVAL, self._to_str(values.get("interval")) + ) + interval_units = self._to_str(values.get("interval_units")) + self._set_text_item(row, COL_INTERVAL_UNITS, interval_units) + self._set_units_combo( + row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units + ) + if values.get("apply_timeout"): + self._set_text_item( + row, COL_TIMEOUT, self._to_str(values.get("timeout")) + ) + timeout_units = self._to_str(values.get("timeout_units")) + self._set_text_item(row, COL_TIMEOUT_UNITS, timeout_units) + self._set_units_combo( + row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units + ) + if values.get("apply_max_size"): + self._set_text_item( + row, COL_MAX_SIZE, self._to_str(values.get("max_size")) + ) + max_size_units = self._to_str(values.get("max_size_units")) + self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units) + self._set_units_combo( + row, COL_MAX_SIZE_UNITS, SIZE_UNITS, max_size_units + ) self._ensure_row_final_filename(row) self.save_action_file() self.refresh_states() self._set_status( - QC.translate("stats", "Updated {0} selected subscriptions.").format(len(rows)), + QC.translate("stats", "Updated {0} selected subscriptions.").format( + len(rows) + ), error=False, ) def _known_groups(self): - groups: set[str] = {"all"} + groups: set[str] = set() for row in range(self.table.rowCount()): - for g in normalize_groups(self._cell_text(row, COL_GROUP) or "all"): - if g != "": + for g in normalize_groups(self._cell_text(row, COL_GROUP)): + if g not in ("", "all"): groups.add(g) return sorted(groups) def refresh_selected_now(self): - row = self.table.currentRow() - if row < 0: - self._set_status(QC.translate("stats", "Select a subscription row first."), error=True) - return - - url = self._cell_text(row, COL_URL) - filename, filename_changed = self._ensure_row_final_filename(row) - if url == "" or filename == "": - self._set_status(QC.translate("stats", "URL and filename cannot be empty."), error=True) + rows = self._selected_rows() + if not rows: + row = self.table.currentRow() + if row >= 0: + rows = [row] + if not rows: + self._set_status( + QC.translate("stats", "Select one or more subscription rows first."), + error=True, + ) return - if filename_changed: - # Persist the resolved filename to action/config immediately. - self.save_action_file() _, _, plug = self._find_loaded_action() if plug is None: - self._set_status(QC.translate("stats", "Plugin is not loaded. Save configuration first."), error=True) + self._set_status( + QC.translate( + "stats", "Plugin is not loaded. Save configuration first." + ), + error=True, + ) return - target_sub: SubscriptionSpec | None = None - try: - for sub in plug._config.subscriptions: - if sub.url == url and sub.filename == filename: - target_sub = sub - break - except Exception: - target_sub = None - - if target_sub is None: - try: - interval_val = self._to_int_or_keep(self._cell_text(row, COL_INTERVAL)) - timeout_val = self._to_int_or_keep(self._cell_text(row, COL_TIMEOUT)) - max_size_val = self._to_int_or_keep(self._cell_text(row, COL_MAX_SIZE)) - row_sub_edit = MutableSubscriptionSpec( - enabled=True, - name=self._cell_text(row, COL_NAME), - url=url, - filename=filename, - format=self._cell_text(row, COL_FORMAT) or "hosts", - groups=normalize_groups(self._cell_text(row, COL_GROUP) or "all"), - interval=interval_val if isinstance(interval_val, int) else self._global_defaults.interval, - interval_units=self._cell_text(row, COL_INTERVAL_UNITS), - timeout=timeout_val if isinstance(timeout_val, int) else self._global_defaults.timeout, - timeout_units=self._cell_text(row, COL_TIMEOUT_UNITS), - max_size=max_size_val if isinstance(max_size_val, int) else self._global_defaults.max_size, - max_size_units=self._cell_text(row, COL_MAX_SIZE_UNITS), - ) - row_sub = SubscriptionSpec.from_dict( - row_sub_edit.to_dict(), - plug._config.defaults, - ) - except Exception: - row_sub = None - if row_sub is None: + refresh_targets: list[tuple[SubscriptionSpec, str]] = [] + filename_changed = False + for row in rows: + url = self._cell_text(row, COL_URL) + filename, row_filename_changed = self._ensure_row_final_filename(row) + if url == "" or filename == "": self._set_status( - QC.translate("stats", "Subscription not found in runtime config. Save first, then retry."), + QC.translate( + "stats", "URL and filename cannot be empty (row {0})." + ).format(row + 1), error=True, ) return - target_sub = row_sub + filename_changed = filename_changed or row_filename_changed - key = plug._sub_key(target_sub) - list_path, _ = plug._paths(target_sub) + if filename_changed: + self.save_action_file() + + for row in rows: + url = self._cell_text(row, COL_URL) + filename = self._cell_text(row, COL_FILENAME) + target_sub: SubscriptionSpec | None = None + try: + for sub in plug._config.subscriptions: + if sub.url == url and sub.filename == filename: + target_sub = sub + break + except Exception: + target_sub = None + + if target_sub is None: + try: + interval_ok, interval_val = self._optional_int_from_text( + self._cell_text(row, COL_INTERVAL), "Interval", row=row + ) + timeout_ok, timeout_val = self._optional_int_from_text( + self._cell_text(row, COL_TIMEOUT), "Timeout", row=row + ) + max_size_ok, max_size_val = self._optional_int_from_text( + self._cell_text(row, COL_MAX_SIZE), "Max size", row=row + ) + if not interval_ok or not timeout_ok or not max_size_ok: + return + row_sub_edit = MutableSubscriptionSpec( + enabled=True, + name=self._cell_text(row, COL_NAME), + url=url, + filename=filename, + format=self._cell_text(row, COL_FORMAT) or "hosts", + groups=normalize_groups(self._cell_text(row, COL_GROUP)), + interval=interval_val, + interval_units=self._optional_unit_from_text( + self._cell_text(row, COL_INTERVAL_UNITS) + ), + timeout=timeout_val, + timeout_units=self._optional_unit_from_text( + self._cell_text(row, COL_TIMEOUT_UNITS) + ), + max_size=max_size_val, + max_size_units=self._optional_unit_from_text( + self._cell_text(row, COL_MAX_SIZE_UNITS) + ), + ) + row_sub = SubscriptionSpec.from_dict( + row_sub_edit.to_dict(), + plug._config.defaults, + ) + except Exception: + row_sub = None + if row_sub is None: + self._set_status( + QC.translate( + "stats", + "Subscription not found in runtime config. Save first, then retry.", + ), + error=True, + ) + return + target_sub = row_sub + + list_path, _ = plug._paths(target_sub) + refresh_targets.append((target_sub, list_path)) def _run_refresh(): try: - logger.warning( - "list_subscriptions.gui: manual refresh start key=%s name='%s' url='%s' file='%s'", - key, target_sub.name, target_sub.url, target_sub.filename - ) - if hasattr(plug, "force_refresh_subscription"): - plug.force_refresh_subscription(target_sub) - else: - # fallback for older plugin objects - plug.download(key, target_sub) + for target_sub, _list_path in refresh_targets: + key = plug._sub_key(target_sub) + logger.warning( + "list_subscriptions.gui: manual refresh start key=%s name='%s' url='%s' file='%s'", + key, + target_sub.name, + target_sub.url, + target_sub.filename, + ) + try: + if hasattr(plug, "force_refresh_subscription"): + plug.force_refresh_subscription(target_sub) + else: + # fallback for older plugin objects + plug.download(key, target_sub) + finally: + logger.warning( + "list_subscriptions.gui: manual refresh finished key=%s", + key, + ) finally: - logger.warning("list_subscriptions.gui: manual refresh finished key=%s", key) self._download_finished.emit() th = threading.Thread(target=_run_refresh, daemon=True) th.start() + if len(refresh_targets) == 1: + self._set_status( + QC.translate( + "stats", "Subscription refresh triggered. Destination: {0}" + ).format(refresh_targets[0][1]), + error=False, + ) + return + self._set_status( - QC.translate("stats", "Subscription refresh triggered. Destination: {0}").format(list_path), + QC.translate( + "stats", "Bulk refresh triggered for {0} selected subscriptions." + ).format(len(refresh_targets)), error=False, ) def refresh_all_now(self): _, _, plug = self._find_loaded_action() if plug is None: - self._set_status(QC.translate("stats", "Plugin is not loaded. Save configuration first."), error=True) + self._set_status( + QC.translate( + "stats", "Plugin is not loaded. Save configuration first." + ), + error=True, + ) return def _run_all_refresh(): @@ -1102,7 +1882,12 @@ def _run_all_refresh(): th = threading.Thread(target=_run_all_refresh, daemon=True) th.start() - self._set_status(QC.translate("stats", "Bulk refresh triggered for all enabled subscriptions."), error=False) + self._set_status( + QC.translate( + "stats", "Bulk refresh triggered for all enabled subscriptions." + ), + error=False, + ) def create_rule_from_selected(self): rows = self._selected_rows() @@ -1111,28 +1896,43 @@ def create_rule_from_selected(self): if row >= 0: rows = [row] if not rows: - self._set_status(QC.translate("stats", "Select one or more subscriptions first."), error=True) + self._set_status( + QC.translate("stats", "Select one or more subscriptions first."), + error=True, + ) return - lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) + lists_dir = normalize_lists_dir( + self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) if len(rows) == 1: row = rows[0] url = self._cell_text(row, COL_URL) filename, filename_changed = self._ensure_row_final_filename(row) if url == "" or filename == "": - self._set_status(QC.translate("stats", "URL and filename cannot be empty."), error=True) + self._set_status( + QC.translate("stats", "URL and filename cannot be empty."), + error=True, + ) return if filename_changed: # Persist resolved filename so subsequent plugin runs keep the same path. self.save_action_file() - name = self._cell_text(row, COL_NAME) or filename list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() list_path = self._list_file_path(lists_dir, filename, list_type) - rule_dir = self._prepare_rule_dir(url, filename, list_path, lists_dir) + rule_dir = self._prepare_rule_dir( + url, + filename, + list_path, + lists_dir, + list_type, + ) if rule_dir is None: return - desc = f"From list subscription: {name}" + rule_token = os.path.splitext(self._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: @@ -1144,9 +1944,15 @@ def create_rule_from_selected(self): try: os.makedirs(rule_dir, mode=0o700, exist_ok=True) except Exception as e: - self._set_status(QC.translate("stats", "Error preparing grouped rule directory: {0}").format(str(e)), error=True) + self._set_status( + QC.translate( + "stats", "Error preparing grouped rule directory: {0}" + ).format(str(e)), + error=True, + ) return - desc = f"From list subscriptions group: {rule_group}" + rule_name = f"00-blocklist-{rule_group}" + desc = f"From list subscription : {rule_group}" if self._rules_dialog is None: appicon = self.windowIcon() if self.windowIcon() is not None else None @@ -1156,23 +1962,39 @@ def create_rule_from_selected(self): self._rules_dialog = RulesEditorDialog() self._rules_dialog.new_rule() + if not self._configure_rules_dialog_for_local_user(): + return # Rules editor expects a directory containing one or more hosts files. self._rules_dialog.dstListsCheck.setChecked(True) self._rules_dialog.dstListsLine.setText(rule_dir) + if self._rules_dialog.ruleNameEdit.text().strip() == "": + self._rules_dialog.ruleNameEdit.setText(rule_name) if self._rules_dialog.ruleDescEdit.toPlainText().strip() == "": self._rules_dialog.ruleDescEdit.setPlainText(desc) self._rules_dialog.raise_() self._rules_dialog.activateWindow() - self._set_status(QC.translate("stats", "Rules Editor opened with prefilled list directory path."), error=False) + self._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.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) + lists_dir = normalize_lists_dir( + self.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._set_status(QC.translate("stats", "Error preparing global rule directory: {0}").format(str(e)), error=True) + self._set_status( + QC.translate( + "stats", "Error preparing global rule directory: {0}" + ).format(str(e)), + error=True, + ) return if self._rules_dialog is None: @@ -1183,26 +2005,86 @@ def create_global_rule(self): self._rules_dialog = RulesEditorDialog() self._rules_dialog.new_rule() + if not self._configure_rules_dialog_for_local_user(): + return + rule_name = "00-blocklist-all" self._rules_dialog.dstListsCheck.setChecked(True) self._rules_dialog.dstListsLine.setText(rule_dir) + if self._rules_dialog.ruleNameEdit.text().strip() == "": + self._rules_dialog.ruleNameEdit.setText(rule_name) if self._rules_dialog.ruleDescEdit.toPlainText().strip() == "": - self._rules_dialog.ruleDescEdit.setPlainText("From list subscriptions group: all") + self._rules_dialog.ruleDescEdit.setPlainText( + "From list subscription : all" + ) self._rules_dialog.raise_() self._rules_dialog.activateWindow() - self._set_status(QC.translate("stats", "Rules Editor opened with global list directory path."), error=False) + self._set_status( + QC.translate( + "stats", "Rules Editor opened with global list directory path." + ), + error=False, + ) + + def _configure_rules_dialog_for_local_user(self): + if self._rules_dialog is None: + return False + + local_addr = None + for addr in self._nodes.get().keys(): + try: + if self._nodes.is_local(addr): + local_addr = addr + break + except Exception: + continue + + if local_addr is None: + self._set_status( + QC.translate( + "stats", + "No local OpenSnitch node is connected. Rules can only be created for the local user.", + ), + error=True, + ) + self._rules_dialog.hide() + return False + + nodes_combo = self._rules_dialog.nodesCombo + node_idx = nodes_combo.findData(local_addr) + if node_idx != -1: + nodes_combo.setCurrentIndex(node_idx) + nodes_combo.setEnabled(False) + self._rules_dialog.nodeApplyAllCheck.setChecked(False) + self._rules_dialog.nodeApplyAllCheck.setEnabled(False) + self._rules_dialog.nodeApplyAllCheck.setVisible(False) + + uid_text = str(os.getuid()) + uid_combo = self._rules_dialog.uidCombo + uid_idx = uid_combo.findData(int(uid_text)) + self._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 _choose_group_for_selected(self, rows: list[int]): if not rows: return None - selected_group_sets = [set(normalize_groups(self._cell_text(r, COL_GROUP) or "all")) for r in rows] - common = set.intersection(*selected_group_sets) if selected_group_sets else {"all"} + selected_group_sets = [ + set(normalize_groups(self._cell_text(r, COL_GROUP))) for r in rows + ] + common = ( + set.intersection(*selected_group_sets) if selected_group_sets else set() + ) known = self._known_groups() - default_group = "all" + default_group = "" if common: default_group = sorted(common)[0] - if default_group not in known: + if default_group != "" and default_group not in known: known.append(default_group) - known = sorted(set(known)) + known = sorted(set(known)) or [""] try: default_idx = known.index(default_group) except ValueError: @@ -1210,7 +2092,9 @@ def _choose_group_for_selected(self, rows: list[int]): value, ok = QtWidgets.QInputDialog.getItem( self, QC.translate("stats", "Create rule from multiple subscriptions"), - QC.translate("stats", "Select or enter a group to aggregate selected subscriptions:"), + QC.translate( + "stats", "Select or enter a group to aggregate selected subscriptions:" + ), known, default_idx, True, @@ -1218,8 +2102,10 @@ def _choose_group_for_selected(self, rows: list[int]): if not ok: return None group = normalize_group(value) - if group == "": - self._set_status(QC.translate("stats", "Group cannot be empty."), error=True) + if group in ("", "all"): + self._set_status( + QC.translate("stats", "Group cannot be empty."), error=True + ) return None return group @@ -1228,25 +2114,39 @@ def _assign_group_to_rows(self, rows: list[int], group: str): return False target_group = normalize_group(group) for row in rows: - groups = normalize_groups(self._cell_text(row, COL_GROUP) or "all") + groups = normalize_groups(self._cell_text(row, COL_GROUP)) groups.append(target_group) groups = normalize_groups(groups) self._set_text_item(row, COL_GROUP, ", ".join(groups)) return True - def _prepare_rule_dir(self, url: str, filename: str, list_path: str, lists_dir: str): - _ = (url, filename, lists_dir) - rule_dir = os.path.dirname(list_path) - # Rules should point to the directory that already contains the - # subscription list file. Do not rewrite/copy/symlink the file here. + def _prepare_rule_dir( + self, + url: str, + filename: str, + list_path: str, + lists_dir: str, + list_type: str, + ): + _ = (url, list_path) + rule_dir = self._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._set_status(QC.translate("stats", "Error preparing list rule directory: {0}").format(str(e)), error=True) + self._set_status( + QC.translate( + "stats", "Error preparing list rule directory: {0}" + ).format(str(e)), + error=True, + ) return None - def _list_file_path(self, lists_dir: str, filename: str, list_type: str): + def _subscription_dirname(self, filename: str, list_type: str): safe_name = self._safe_filename(filename) if safe_name == "": safe_name = "subscription.list" @@ -1256,37 +2156,188 @@ def _list_file_path(self, lists_dir: str, filename: str, list_type: str): sub_dirname = base if base else "subscription" if not sub_dirname.lower().endswith(suffix): sub_dirname = f"{sub_dirname}{suffix}" - return os.path.join(lists_dir, "sources.list.d", sub_dirname, safe_name) + return sub_dirname + + def _subscription_rule_dir(self, lists_dir: str, filename: str, list_type: str): + return os.path.join( + lists_dir, + "rules.list.d", + self._subscription_dirname(filename, list_type), + ) + + def _list_file_path(self, lists_dir: str, filename: str, list_type: str): + safe_name = self._safe_filename(filename) + if safe_name == "": + safe_name = "subscription.list" + safe_name = ensure_filename_type_suffix(safe_name, list_type) + return os.path.join(lists_dir, "sources.list.d", safe_name) def _apply_runtime_state(self, enabled: bool): - old_key, old_action, old_plugin = self._find_loaded_action() - if old_plugin is not None: + old_key, _old_action, old_plugin = self._find_loaded_action() + 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: - old_plugin.stop() + 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._action_path, + } + ) except Exception: - pass - - if old_key is not None: - self._actions.delete(old_key) + self._set_status( + QC.translate( + "stats", "Config saved but runtime reload failed. Restart UI." + ), + error=True, + ) + return + if not enabled and old_key is not None: + self._actions.delete(old_key) + return if not enabled: + if old_key is not None: + self._actions.delete(old_key) return obj, compiled = self._actions.load(self._action_path) if obj is None or compiled is None: - self._set_status(QC.translate("stats", "Config saved but runtime reload failed. Restart UI."), error=True) + self._set_status( + QC.translate( + "stats", "Config saved but runtime reload failed. Restart UI." + ), + error=True, + ) return obj = cast(dict[str, Any], obj) compiled = cast(dict[str, Any], compiled) - self._actions._actions_list[obj["name"]] = compiled - compiled_actions: dict[str, Any] = compiled.get("actions", {}) - plug = cast(ListSubscriptions | None, compiled_actions.get("list_subscriptions")) - if plug is not None: + action_name = obj.get("name") + if old_key is not None and old_key != action_name: + self._actions.delete(old_key) + if isinstance(action_name, str) and action_name != "": + self._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._set_status( + QC.translate( + "stats", "Config saved but runtime reload failed. Restart UI." + ), + error=True, + ) + return + self._bind_runtime_plugin(plug) + try: + plug.signal_in.emit( + { + "plugin": plug.get_name(), + "signal": PluginSignal.ENABLE, + "action_path": self._action_path, + } + ) + except Exception: + self._set_status( + QC.translate( + "stats", "Config saved but runtime reload failed. Restart UI." + ), + error=True, + ) + + def _bind_runtime_plugin(self, plug: ListSubscriptions | None): + if plug is None: + return + try: + plug.signal_out.disconnect(self._handle_runtime_event) + except Exception: + pass + try: + plug.signal_out.connect(self._handle_runtime_event) + self._runtime_plugin = plug + except Exception: + self._runtime_plugin = None + + 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_value = payload.get("event") + if isinstance(event_value, int): try: - plug.run() + event_name = RuntimeEvent(event_value) except Exception: - self._set_status(QC.translate("stats", "Plugin enabled but failed to start. Restart UI."), error=True) + event_name = None + else: + event_name = None + is_error = event_name == RuntimeEvent.RUNTIME_ERROR + if event_name == RuntimeEvent.RUNTIME_ENABLED: + self._set_runtime_state(active=True) + elif event_name in ( + RuntimeEvent.RUNTIME_DISABLED, + RuntimeEvent.RUNTIME_STOPPED, + ): + self._set_runtime_state(active=False) + elif self._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._pending_runtime_reload == "waiting_config_reload": + if event_name == RuntimeEvent.CONFIG_RELOADED: + self._pending_runtime_reload = None + self.load_action_file() + return + if is_error: + self._pending_runtime_reload = None + if message == "": + message = QC.translate("stats", "Plugin runtime event: {0}").format( + str(event_value or "unknown") + ) + if is_error and error_detail != "": + message = f"{message} {error_detail}".strip() + self._set_status(message, error=is_error) + + 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.runtime_status_label.setStyleSheet(style) + self.runtime_status_label.setText(text) + self.start_runtime_button.setEnabled(active is not True) + self.stop_runtime_button.setEnabled(active is not False) def _find_loaded_action(self): for action_key, action_obj in self._actions.getAll().items(): @@ -1302,7 +2353,6 @@ def _find_loaded_action(self): def _collect_subscriptions(self): out: list[MutableSubscriptionSpec] = [] auto_filled = 0 - seen_filenames: dict[str, int] = {} for row in range(self.table.rowCount()): enabled_item = self.table.item(row, COL_ENABLED) interval = self._cell_text(row, COL_INTERVAL) @@ -1314,7 +2364,7 @@ def _collect_subscriptions(self): name = self._cell_text(row, COL_NAME) url = self._cell_text(row, COL_URL) list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() - groups = normalize_groups(self._cell_text(row, COL_GROUP) or "all") + groups = normalize_groups(self._cell_text(row, COL_GROUP)) filename = self._safe_filename(self._cell_text(row, COL_FILENAME)) if filename == "": filename = self._guess_filename(name, url) @@ -1322,48 +2372,55 @@ def _collect_subscriptions(self): auto_filled += 1 filename = ensure_filename_type_suffix(filename, list_type) self._set_text_item(row, COL_FILENAME, filename) - file_key = os.path.normcase(filename) - if file_key in seen_filenames: - first_row = seen_filenames[file_key] + 1 - self._set_status( - QC.translate("stats", "Conflicting filename '{0}' on rows {1} and {2}.").format( - filename, first_row, row + 1 - ), - error=True, - ) + interval_ok, interval_val = self._optional_int_from_text( + interval, "Interval", row=row + ) + timeout_ok, timeout_val = self._optional_int_from_text( + timeout, "Timeout", row=row + ) + max_size_ok, max_size_val = self._optional_int_from_text( + max_size, "Max size", row=row + ) + if not interval_ok or not timeout_ok or not max_size_ok: return None - seen_filenames[file_key] = row - interval_val = self._to_int_or_keep(interval or self._global_defaults.interval) - timeout_val = self._to_int_or_keep(timeout or self._global_defaults.timeout) - max_size_val = self._to_int_or_keep(max_size or self._global_defaults.max_size) sub = MutableSubscriptionSpec( - enabled=enabled_item is not None and enabled_item.checkState() == QtCore.Qt.CheckState.Checked, + enabled=enabled_item is not None + and enabled_item.checkState() == QtCore.Qt.CheckState.Checked, name=name, url=url, filename=filename, format=list_type, groups=groups, - interval=interval_val if isinstance(interval_val, int) else self._global_defaults.interval, - interval_units=interval_units or self._global_defaults.interval_units, - timeout=timeout_val if isinstance(timeout_val, int) else self._global_defaults.timeout, - timeout_units=timeout_units or self._global_defaults.timeout_units, - max_size=max_size_val if isinstance(max_size_val, int) else self._global_defaults.max_size, - max_size_units=max_size_units or self._global_defaults.max_size_units, + interval=interval_val, + interval_units=self._optional_unit_from_text(interval_units), + timeout=timeout_val, + timeout_units=self._optional_unit_from_text(timeout_units), + max_size=max_size_val, + max_size_units=self._optional_unit_from_text(max_size_units), ) if sub.url == "" or sub.filename == "": - self._set_status(QC.translate("stats", "URL and filename cannot be empty (row {0}).").format(row + 1), error=True) + self._set_status( + QC.translate( + "stats", "URL and filename cannot be empty (row {0})." + ).format(row + 1), + error=True, + ) return None out.append(sub) if auto_filled > 0: self._set_status( - QC.translate("stats", "Auto-filled filename for {0} subscription(s).").format(auto_filled), + QC.translate( + "stats", "Auto-filled filename for {0} subscription(s)." + ).format(auto_filled), error=False, ) return out def _row_meta_snapshot(self, row: int): - lists_dir = normalize_lists_dir(self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR) + lists_dir = normalize_lists_dir( + self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) filename = self._safe_filename(self._cell_text(row, COL_FILENAME)) list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() list_path = self._list_file_path(lists_dir, filename, list_type) @@ -1382,10 +2439,18 @@ def _row_meta_snapshot(self, row: int): return { "file_present": "yes" if file_exists else "no", "meta_present": "yes" if meta_exists else "no", - "state": str(meta.get("last_result", self._cell_text(row, COL_STATE) or "never")), - "last_checked": str(meta.get("last_checked", self._cell_text(row, COL_LAST_CHECKED) or "")), - "last_updated": str(meta.get("last_updated", self._cell_text(row, COL_LAST_UPDATED) or "")), - "failures": str(meta.get("fail_count", self._cell_text(row, COL_FAILS) or "0")), + "state": str( + meta.get("last_result", self._cell_text(row, COL_STATE) or "never") + ), + "last_checked": str( + meta.get("last_checked", self._cell_text(row, COL_LAST_CHECKED) or "") + ), + "last_updated": str( + meta.get("last_updated", self._cell_text(row, COL_LAST_UPDATED) or "") + ), + "failures": str( + meta.get("fail_count", self._cell_text(row, COL_FAILS) or "0") + ), "error": str(meta.get("last_error", self._cell_text(row, COL_ERROR) or "")), "list_path": list_path, "meta_path": meta_path, @@ -1407,19 +2472,19 @@ def _ensure_row_final_filename(self, row: int): changed = True if final_name != "": - key = os.path.normcase(final_name) + key = final_name existing: set[str] = set() for i in range(self.table.rowCount()): if i == row: continue other = self._safe_filename(self._cell_text(i, COL_FILENAME)) if other != "": - existing.add(os.path.normcase(other)) + existing.add(other) if key in existing: base, ext = os.path.splitext(final_name) n = 2 candidate = final_name - while os.path.normcase(candidate) in existing: + while candidate in existing: suffix = f"-{n}" candidate = f"{base}{suffix}{ext}" if ext else f"{base}{suffix}" n += 1 @@ -1435,8 +2500,14 @@ def _append_row(self, sub: MutableSubscriptionSpec): self.table.insertRow(row) enabled_item = QtWidgets.QTableWidgetItem("") - enabled_item.setFlags(enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) - enabled_item.setCheckState(QtCore.Qt.CheckState.Checked if bool(sub.enabled) else QtCore.Qt.CheckState.Unchecked) + enabled_item.setFlags( + enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable + ) + enabled_item.setCheckState( + QtCore.Qt.CheckState.Checked + if bool(sub.enabled) + else QtCore.Qt.CheckState.Unchecked + ) self.table.setItem(row, COL_ENABLED, enabled_item) self._set_text_item(row, COL_NAME, str(sub.name)) @@ -1451,53 +2522,20 @@ def _append_row(self, sub: MutableSubscriptionSpec): interval_units = sub.interval_units timeout_units = sub.timeout_units max_size_units = sub.max_size_units - self._set_text_item( - row, - COL_INTERVAL, - self._to_str(interval if interval not in ("", None) else self._global_defaults.interval), - ) - self._set_text_item( - row, - COL_INTERVAL_UNITS, - self._to_str(interval_units if interval_units not in ("", None) else self._global_defaults.interval_units), - ) - self._set_text_item( - row, - COL_TIMEOUT, - self._to_str(timeout if timeout not in ("", None) else self._global_defaults.timeout), - ) - self._set_text_item( - row, - COL_TIMEOUT_UNITS, - self._to_str(timeout_units if timeout_units not in ("", None) else self._global_defaults.timeout_units), - ) - self._set_text_item( - row, - COL_MAX_SIZE, - self._to_str(max_size if max_size not in ("", None) else self._global_defaults.max_size), - ) - self._set_text_item( - row, - COL_MAX_SIZE_UNITS, - self._to_str(max_size_units if max_size_units not in ("", None) else self._global_defaults.max_size_units), - ) + self._set_text_item(row, COL_INTERVAL, self._to_str(interval)) + self._set_text_item(row, COL_INTERVAL_UNITS, self._to_str(interval_units)) + self._set_text_item(row, COL_TIMEOUT, self._to_str(timeout)) + self._set_text_item(row, COL_TIMEOUT_UNITS, self._to_str(timeout_units)) + self._set_text_item(row, COL_MAX_SIZE, self._to_str(max_size)) + self._set_text_item(row, COL_MAX_SIZE_UNITS, self._to_str(max_size_units)) self._set_units_combo( - row, - COL_INTERVAL_UNITS, - INTERVAL_UNITS, - self._to_str(interval_units if interval_units not in ("", None) else self._global_defaults.interval_units), + row, COL_INTERVAL_UNITS, INTERVAL_UNITS, self._to_str(interval_units) ) self._set_units_combo( - row, - COL_TIMEOUT_UNITS, - TIMEOUT_UNITS, - self._to_str(timeout_units if timeout_units not in ("", None) else self._global_defaults.timeout_units), + row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, self._to_str(timeout_units) ) self._set_units_combo( - row, - COL_MAX_SIZE_UNITS, - SIZE_UNITS, - self._to_str(max_size_units if max_size_units not in ("", None) else self._global_defaults.max_size_units), + row, COL_MAX_SIZE_UNITS, SIZE_UNITS, self._to_str(max_size_units) ) self._set_text_item(row, COL_FILE, "", editable=False) @@ -1518,17 +2556,23 @@ def _reload_nodes(self): def _apply_defaults_to_widgets(self): self.default_interval_spin.setValue(max(1, int(self._global_defaults.interval))) self.default_interval_units.setCurrentText( - self._normalize_unit(self._global_defaults.interval_units, INTERVAL_UNITS, "hours") + self._normalize_unit( + self._global_defaults.interval_units, INTERVAL_UNITS, "hours" + ) ) self.default_timeout_spin.setValue(max(1, int(self._global_defaults.timeout))) self.default_timeout_units.setCurrentText( - self._normalize_unit(self._global_defaults.timeout_units, TIMEOUT_UNITS, "seconds") + self._normalize_unit( + self._global_defaults.timeout_units, TIMEOUT_UNITS, "seconds" + ) ) self.default_max_size_spin.setValue(max(1, int(self._global_defaults.max_size))) self.default_max_size_units.setCurrentText( self._normalize_unit(self._global_defaults.max_size_units, SIZE_UNITS, "MB") ) - self.default_user_agent.setText((self._global_defaults.user_agent or "").strip()) + self.default_user_agent.setText( + (self._global_defaults.user_agent or "").strip() + ) def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): normalized = (value or "").strip().lower() @@ -1537,10 +2581,22 @@ def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): return unit return fallback - def _set_units_combo(self, row: int, col: int, allowed: tuple[str, ...], value: str): + def _set_units_combo( + self, row: int, col: int, allowed: tuple[str, ...], value: str | None + ): combo = QtWidgets.QComboBox() + combo.addItem("") combo.addItems(allowed) - combo.setCurrentText(self._normalize_unit(value, allowed, allowed[0])) + 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(self._normalize_unit(value, allowed, allowed[0])) self.table.setCellWidget(row, col, combo) def _safe_filename(self, value: Any): @@ -1567,11 +2623,15 @@ def _filename_from_headers(self, url: str): if cd: # Prefer RFC 5987 filename*; fallback to filename filename = "" - m_star = re.search(r'filename\*\s*=\s*[^\'";]+\'[^\'";]*\'([^;]+)', cd, re.IGNORECASE) + 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 = requests.utils.parse_dict_header(";".join(cd.split(";")[1:])) + params = requests.utils.parse_dict_header( + ";".join(cd.split(";")[1:]) + ) raw = params.get("filename") if raw: filename = requests.utils.unquote_header_value(str(raw)).strip() @@ -1623,13 +2683,50 @@ def _cell_text(self, row: int, col: int): return "" return (item.text() or "").strip() - def _to_int_or_keep(self, value: Any): + def _optional_int_from_text( + self, value: Any, field_name: str, row: int | None = None + ): if value == "": - return value + return True, None + parsed = self._to_int_or_keep(value, field_name, row=row) + if parsed is None: + return False, None + return True, parsed + + def _optional_unit_from_text(self, value: Any): + text = (str(value or "")).strip() + return text or None + + def _to_int_or_keep(self, value: Any, field_name: str, row: int | None = None): try: - return int(value) + parsed = int(value) except Exception: - return value + row_suffix = ( + QC.translate("stats", " (row {0})").format(row + 1) + if row is not None + else "" + ) + self._set_status( + QC.translate("stats", "{0} must be a positive integer{1}.").format( + field_name, row_suffix + ), + error=True, + ) + return None + if parsed < 1: + row_suffix = ( + QC.translate("stats", " (row {0})").format(row + 1) + if row is not None + else "" + ) + self._set_status( + QC.translate("stats", "{0} must be a positive integer{1}.").format( + field_name, row_suffix + ), + error=True, + ) + return None + return parsed def _to_str(self, value: Any): if value is None: diff --git a/ui/opensnitch/plugins/list_subscriptions/_models.py b/ui/opensnitch/plugins/list_subscriptions/_models.py index c932e800eb..8a3ffef3ed 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_models.py +++ b/ui/opensnitch/plugins/list_subscriptions/_models.py @@ -1,101 +1,68 @@ -import os -import re from dataclasses import dataclass, field, asdict, replace -from typing import Any -from urllib.parse import urlparse, unquote +from typing import Any, TypeVar +from collections.abc import Callable -from opensnitch.utils.xdg import xdg_config_home from opensnitch.plugins.list_subscriptions._utils import ( - to_seconds, + dedupe_subscription_identity, + derive_filename, + ensure_filename_type_suffix, + normalize_groups, + normalize_lists_dir, + now_iso, + normalize_iso_timestamp, + opt_int, + opt_str, parse_compact_duration, + safe_filename, + to_seconds, to_max_bytes, ) -DEFAULT_UA = ( - "Mozilla/5.0 (X11; Linux x86_64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/120.0 Safari/537.36" +DEFAULT_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0" + +DEFAULT_NOTIFY_CONFIG = { + "success": {"desktop": "Lists subscriptions updated"}, + "error": {"desktop": "Error updating lists subscriptions"}, +} + +SubscriptionLike = TypeVar( + "SubscriptionLike", "SubscriptionSpec", "MutableSubscriptionSpec" ) -def normalize_lists_dir(path: str | None) -> str: - 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 safe_filename(value: Any) -> str: - return os.path.basename((str(value or "")).strip()) - - -def filename_from_url(url: str | None) -> str: - try: - parsed = urlparse((url or "").strip()) - return safe_filename(unquote(parsed.path or "")) - except Exception: - return "" - - -def slugify_name(name: str | None) -> str: - 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 derive_filename(name: str | None, url: str | None, filename: str | None) -> str: - 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) -> str: - fn = safe_filename(filename) - base, ext = os.path.splitext(fn) - ltype = (list_type or "hosts").strip().lower() - 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 normalize_group(group: str | None) -> str: - raw = (group or "all").strip().lower() - raw = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-._") - return raw if raw else "all" - - -def normalize_groups(groups: Any) -> list[str]: - 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 in seen: - continue - seen.add(g) - out.append(g) - return out if out else ["all"] +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 @dataclass(frozen=True) @@ -140,29 +107,37 @@ class SubscriptionSpec: name: str url: str filename: str - groups: tuple[str, ...] = ("all",) + groups: tuple[str, ...] = () enabled: bool = True format: str = "hosts" - interval: int = 24 - interval_units: str = "hours" - timeout: int = 60 - timeout_units: str = "seconds" - max_size: int = 20 - max_size_units: str = "MB" + 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], defaults: GlobalDefaults): - if not isinstance(d, dict): - return None + 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")) - filename = ensure_filename_type_suffix(filename, list_type) + 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") @@ -173,45 +148,41 @@ def from_dict(d: dict[str, Any], defaults: GlobalDefaults): else: groups_raw = [groups_raw, legacy_group] groups = normalize_groups(groups_raw) - if "all" not in groups: - groups.insert(0, "all") - if not url: + if require_url and not url: return None - if not name: + if require_url and not name: name = filename - def _opt_int(x: Any): - try: - return int(x) if x is not None else None - except Exception: - return None - - def _opt_str(x: Any): - try: - if x is None: - return None - x = (str(x) or "").strip().lower() - return x if x != "" else None - except Exception: - return None - 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) or defaults.interval - interval_units_opt = _opt_str(interval_units_raw) - interval_units = interval_units_opt or defaults.interval_units - timeout = _opt_int(timeout_raw) or defaults.timeout - timeout_units_opt = _opt_str(timeout_units_raw) - timeout_units = timeout_units_opt or defaults.timeout_units - max_size = _opt_int(d.get("max_size")) or defaults.max_size - max_size_units = _opt_str(d.get("max_size_units")) or defaults.max_size_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 + ) - 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 @@ -219,7 +190,9 @@ def _opt_str(x: Any): interval_seconds = parse_compact_duration(interval_raw) interval_is_composite = interval_seconds is not None if interval_seconds is None: - interval_seconds = to_seconds(interval, interval_units, default_interval_seconds) + interval_seconds = to_seconds( + effective_interval, effective_interval_units, default_interval_seconds + ) elif interval_is_composite: interval = interval_seconds interval_units = "composite" @@ -230,12 +203,16 @@ def _opt_str(x: Any): timeout_seconds = parse_compact_duration(timeout_raw) timeout_is_composite = timeout_seconds is not None if timeout_seconds is None: - timeout_seconds = to_seconds(timeout, timeout_units, default_timeout_seconds) + 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(max_size, max_size_units, default_max_bytes) + max_bytes = to_max_bytes( + effective_max_size, effective_max_size_units, default_max_bytes + ) return SubscriptionSpec( name=name, @@ -261,15 +238,15 @@ class MutableSubscriptionSpec: name: str = "" url: str = "" filename: str = "" - groups: list[str] = field(default_factory=lambda: ["all"]) + groups: list[str] = field(default_factory=list) enabled: bool = True format: str = "hosts" - interval: int = 24 - interval_units: str = "hours" - timeout: int = 60 - timeout_units: str = "seconds" - max_size: int = 20 - max_size_units: str = "MB" + 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): @@ -289,70 +266,211 @@ def from_spec(spec: SubscriptionSpec): ) @staticmethod - def from_dict(d: dict[str, Any], defaults: GlobalDefaults): - spec = SubscriptionSpec.from_dict(d, defaults) + 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 - return MutableSubscriptionSpec.from_spec(spec) + + 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): - return { + 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), - "interval": int(self.interval), - "interval_units": (self.interval_units or "hours").strip().lower(), - "timeout": int(self.timeout), - "timeout_units": (self.timeout_units or "seconds").strip().lower(), - "max_size": int(self.max_size), - "max_size_units": (self.max_size_units or "MB").strip(), } + 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 @dataclass(frozen=True) class PluginConfig: - defaults: GlobalDefaults = field(default_factory=lambda: GlobalDefaults.from_dict({})) + 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): + 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] = [] - seen_filenames: set[str] = set() - for item in (raw_cfg.get("subscriptions") or []): - sub = SubscriptionSpec.from_dict(item, defaults) + for item in raw_cfg.get("subscriptions") or []: + sub = SubscriptionSpec.from_dict( + item, + defaults, + ) if sub is not None: - key = os.path.normcase(sub.filename) - if key in seen_filenames: - base, ext = os.path.splitext(sub.filename) - n = 2 - candidate = sub.filename - while os.path.normcase(candidate) in seen_filenames: - suffix = f"-{n}" - candidate = f"{base}{suffix}{ext}" if ext else f"{base}{suffix}" - n += 1 - sub = replace(sub, filename=candidate) - if sub.name.strip() == "" or sub.name == sub.filename: - sub = replace(sub, name=candidate) - key = os.path.normcase(sub.filename) - seen_filenames.add(key) subs.append(sub) - return PluginConfig(defaults=defaults, subscriptions=subs) + 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, + } @dataclass class MutableActionConfig: enabled: bool = False - defaults: GlobalDefaults = field(default_factory=lambda: GlobalDefaults.from_dict({})) - subscriptions: list[MutableSubscriptionSpec] = field(default_factory=list) + plugin: MutablePluginConfig = field(default_factory=MutablePluginConfig.default) action_name: str = "listSubscriptionsActions" created: str = "" updated: str = "" @@ -362,9 +480,14 @@ class MutableActionConfig: @staticmethod def from_action_dict(raw_action: dict[str, Any], lists_dir: str | None = None): action_name = str(raw_action.get("name", "listSubscriptionsActions")) - created = str(raw_action.get("created", "")) - updated = str(raw_action.get("updated", "")) - description = str(raw_action.get("description", "Manage and auto-update blocklist subscriptions (hosts format)")) + 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] @@ -372,16 +495,27 @@ def from_action_dict(raw_action: dict[str, Any], lists_dir: str | None = None): 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 {} + 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 {} - compiled_cfg = PluginConfig.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 + 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, - defaults=compiled_cfg.defaults, - subscriptions=[MutableSubscriptionSpec.from_spec(s) for s in compiled_cfg.subscriptions], + plugin=mutable_plugin, action_name=action_name, created=created, updated=updated, @@ -391,51 +525,29 @@ def from_action_dict(raw_action: dict[str, Any], lists_dir: str | None = None): @staticmethod def default(lists_dir: str | None = None): - defaults = GlobalDefaults.from_dict( - { - "interval": 24, - "interval_units": "hours", - "timeout": 20, - "timeout_units": "seconds", - "max_size": 50, - "max_size_units": "MB", - }, - lists_dir=lists_dir, - ) + created = now_iso() return MutableActionConfig( enabled=True, - defaults=defaults, - subscriptions=[], + plugin=MutablePluginConfig.default(lists_dir), + created=created, + updated=created, ) - def to_plugin_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": { - "success": {"desktop": "Lists subscriptions updated"}, - "error": {"desktop": "Error updating lists subscriptions"}, - }, - } - 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": self.created, - "updated": self.updated, + "created": created, + "updated": updated, "description": self.description, "type": list(self.types), "actions": { "list_subscriptions": { "enabled": bool(self.enabled), - "config": self.to_plugin_dict(), + "config": self.plugin.to_dict(), } }, } diff --git a/ui/opensnitch/plugins/list_subscriptions/_utils.py b/ui/opensnitch/plugins/list_subscriptions/_utils.py index 398ce9be20..b596964076 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_utils.py +++ b/ui/opensnitch/plugins/list_subscriptions/_utils.py @@ -2,8 +2,13 @@ import json import os import re +import time +from enum import IntEnum from datetime import datetime from typing import Any +from urllib.parse import urlparse, unquote + +from opensnitch.utils.xdg import xdg_config_home TIME_MULT = { @@ -34,6 +39,14 @@ } +class RuntimeEvent(IntEnum): + RUNTIME_ENABLED = 1 + CONFIG_RELOADED = 2 + RUNTIME_DISABLED = 3 + RUNTIME_STOPPED = 4 + RUNTIME_ERROR = 5 + + def now_iso(): return datetime.now().astimezone().isoformat() @@ -45,6 +58,151 @@ def parse_iso(ts: str): 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 normalize_lists_dir(path: str | None) -> str: + 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 safe_filename(value: Any) -> str: + return os.path.basename((str(value or "")).strip()) + + +def filename_from_url(url: str | None) -> str: + try: + parsed = urlparse((url or "").strip()) + return safe_filename(unquote(parsed.path or "")) + except Exception: + return "" + + +def slugify_name(name: str | None) -> str: + 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 derive_filename(name: str | None, url: str | None, filename: str | None) -> str: + 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) -> str: + fn = safe_filename(filename) + base, ext = os.path.splitext(fn) + ltype = (list_type or "hosts").strip().lower() + 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 normalize_group(group: str | None) -> str: + raw = (group or "").strip().lower() + if raw == "": + return "" + raw = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-._") + return raw + + +def normalize_groups(groups: Any) -> list[str]: + 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"-{(list_type or 'hosts').strip().lower()}" + 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: @@ -109,18 +267,113 @@ def write_json_atomic(path: str, obj: dict[str, Any]): os.replace(tmp, path) +def json_lock_path(path: str) -> 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() + + 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: + stat = os.stat(self.lock_path) + except FileNotFoundError: + return False + + pid = self._read_owner_pid() + if pid is None: + return False + if pid > 0: + return not self._pid_is_alive(pid) + + age = time.time() - stat.st_mtime + return age >= max(max_age, 0.0) + + 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): try: - self.fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + self.fd = os.open( + self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600 + ) os.write(self.fd, str(os.getpid()).encode("utf-8")) return True except OSError as e: if e.errno == errno.EEXIST: + if self.break_stale(): + return self.acquire() return False raise diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index 1c3d2f4e33..39aee9ee25 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -5,10 +5,12 @@ import shutil import sys from typing import Any +from abc import ABCMeta from datetime import datetime, timedelta from queue import Queue import requests +from opensnitch.proto import ui_pb2 if "PyQt6" in sys.modules: from PyQt6 import QtCore, QtGui @@ -21,8 +23,12 @@ from PyQt5 import QtCore, QtGui from opensnitch.dialogs.stats import StatsDialog +from opensnitch.config import Config +from opensnitch.nodes import Nodes from opensnitch.notifications import DesktopNotifications from opensnitch.plugins import PluginBase, PluginSignal +from opensnitch.rules import Rule +from opensnitch.database import Database from opensnitch.utils import GenericTimer from opensnitch.utils.xdg import xdg_config_home from opensnitch.plugins.list_subscriptions._models import ( @@ -30,22 +36,23 @@ ListMetadata, PluginConfig, SubscriptionSpec, - ensure_filename_type_suffix, - normalize_group, - normalize_lists_dir, ) from opensnitch.plugins.list_subscriptions._utils import ( FileLock, + RuntimeEvent, + ensure_filename_type_suffix, is_hosts_file_like, + normalize_groups, + normalize_lists_dir, now_iso, parse_iso, - read_json, - write_json_atomic, + read_json_locked, + write_json_atomic_locked, ) ch = logging.StreamHandler() -#ch.setLevel(logging.ERROR) -formatter = logging.Formatter('%(asctime)s - %(name)s - [%(levelname)s] %(message)s') +# ch.setLevel(logging.ERROR) +formatter = logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s") ch.setFormatter(formatter) logger = logging.getLogger(__name__) logger.addHandler(ch) @@ -54,15 +61,28 @@ # -------------------- plugin core -------------------- -class ListSubscriptions(PluginBase): - """ A plugin to manage list subscriptions (e.g. blocklists). + +class SingletonABCMeta(ABCMeta): + _instances: dict[type, object] = {} + _lock = threading.Lock() + + def __call__(cls, *args, **kwargs): + with cls._lock: + if cls not in cls._instances: + cls._instances[cls] = super().__call__(*args, **kwargs) + return cls._instances[cls] + + +class ListSubscriptions(PluginBase, metaclass=SingletonABCMeta): + """A plugin to manage list subscriptions (e.g. blocklists). The plugin is configured via a JSON file specifying a list of subscriptions. Each subscription has a URL and a local filename to save to. The plugin periodically checks each URL for updates, using HTTP cache validators to avoid unnecessary downloads. - Metadata about each subscription is stored in a sidecar JSON file (same name + .meta.json) to track last update time, errors, backoff, etc. + Metadata about each subscription is stored in a metadata JSON file (same name + .meta.json) to track last update time, errors, backoff, etc. The plugin exposes a results queue for the UI to display subscription status and errors. """ + # fields overriden from parent class name = "List_subscriptions" version = 0 @@ -77,33 +97,90 @@ class ListSubscriptions(PluginBase): # runtime state scheduled_tasks: dict[str, GenericTimer] = {} - default_conf = "{0}/{1}".format(xdg_config_home, "opensnitch/actions/list_subscriptions.json") - default_lists_dir = os.path.join(xdg_config_home, "opensnitch", "list_subscriptions") + default_conf = "{0}/{1}".format( + xdg_config_home, "opensnitch/actions/list_subscriptions.json" + ) + default_lists_dir = os.path.join( + xdg_config_home, "opensnitch", "list_subscriptions" + ) + + @classmethod + def get_instance(cls) -> "ListSubscriptions | None": + instance = SingletonABCMeta._instances.get(cls) + if isinstance(instance, cls): + return instance + return None def __init__(self, config: dict[str, Any] | None = None): config = config or {} - self._log = logger + if getattr(self, "_initialized", False): + self._load_action_config(config) + return + + self._initialized = True self.signal_in.connect(self.cb_signal) self._desktop_notifications = DesktopNotifications() + self._db = Database.instance() + self._nodes = Nodes.instance() self._ok_msg = "" self._err_msg = "" self._notify: dict[str, Any] | None = None self._notify_title = "[OpenSnitch] List subscriptions downloader" self._resultsQueue: Queue[tuple[str, bool, str]] = Queue() - self._running = False - self._app_icon = os.path.join(os.path.abspath(os.path.dirname(__file__)), "../../res/icon-white.svg") + self._app_icon = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "../../res/icon-white.svg" + ) self._cfg_dialog = None self._cfg_action = None + self.scheduled_tasks = {} + self._startup_recheck_lock = threading.Lock() + self._startup_recheck_pending = False + self._startup_recheck_scheduled = False + self._nodes.nodesUpdated.connect(self._on_nodes_updated) + self._load_action_config(config) - if config.get("enabled") is True: - self.enabled = True + # Set up requests session with default UA + self._session: requests.Session = requests.Session() + if self._config.defaults.user_agent: + self._session.headers.update( + {"User-Agent": self._config.defaults.user_agent} + ) + else: + self._session.headers.update({"User-Agent": DEFAULT_UA}) - # Load config - plugin_cfg: Any = config.get("config", {}) + def _emit_runtime_event( + self, + event: RuntimeEvent, + message: str, + *, + error: str | None = None, + action_path: str | None = None, + ): + payload: dict[str, Any] = { + "plugin": self.get_name(), + "event": event, + "message": message, + } + if action_path: + payload["action_path"] = action_path + if error: + payload["error"] = error + self.signal_out.emit(payload) + + def _load_action_config(self, action_cfg: dict[str, Any] | None = None): + action_cfg = action_cfg or {} + self.enabled = bool(action_cfg.get("enabled") is True) + + plugin_cfg: Any = action_cfg.get("config", {}) if not isinstance(plugin_cfg, dict): plugin_cfg = {} - self._config = PluginConfig.from_dict(plugin_cfg, lists_dir=self.default_lists_dir) + self._config = PluginConfig.from_dict( + plugin_cfg, + lists_dir=self.default_lists_dir, + ) self._notify = plugin_cfg.get("notify") + self._ok_msg = "" + self._err_msg = "" if isinstance(self._notify, dict): ok = self._notify.get("success") err = self._notify.get("error") @@ -118,14 +195,104 @@ def __init__(self, config: dict[str, Any] | None = None): else: self._notify = None - # Set up requests session with default UA - self._session: requests.Session = requests.Session() - if self._config.defaults.user_agent: - self._session.headers.update({"User-Agent": self._config.defaults.user_agent}) - else: - self._session.headers.update({"User-Agent": DEFAULT_UA}) + def _start_runtime(self, *, recheck: bool): + if not self.enabled: + return + + for t in self.scheduled_tasks.values(): + try: + t.start() + except Exception: + pass + + if recheck: + if self._has_ready_local_node(): + self._schedule_startup_recheck(delay=0.5) + else: + with self._startup_recheck_lock: + self._startup_recheck_pending = True + logger.warning( + "deferring startup refresh until a local node is connected" + ) + + def disable_runtime(self): + self.enabled = False + with self._startup_recheck_lock: + self._startup_recheck_pending = False + self.stop() + + def _has_ready_local_node(self) -> bool: + for addr in self._nodes.get().keys(): + if not self._nodes.is_local(addr): + continue + if self._nodes.is_connected(addr): + return True + return False + + def _schedule_startup_recheck(self, *, delay: float): + with self._startup_recheck_lock: + if self._startup_recheck_scheduled: + return + self._startup_recheck_pending = False + self._startup_recheck_scheduled = True - # -------- metadata sidecar -------- + def _run(): + try: + self._startup_recheck_all() + finally: + with self._startup_recheck_lock: + self._startup_recheck_scheduled = False + + timer = threading.Timer(delay, _run) + timer.daemon = True + timer.start() + + def _on_nodes_updated(self, total: int): + if total <= 0 or not self.enabled: + return + with self._startup_recheck_lock: + pending = self._startup_recheck_pending + if pending and self._has_ready_local_node(): + logger.warning( + "local node connected, running deferred startup refresh" + ) + self._schedule_startup_recheck(delay=0.5) + + def _reload_from_action_file(self, action_path: str | None = None): + action_path = (action_path or self.default_conf).strip() or self.default_conf + try: + raw_action = read_json_locked(action_path) + except Exception as exc: + logger.warning( + "failed to read action file %s: %r", + action_path, + exc, + ) + return False, str(exc) + + if not isinstance(raw_action, dict): + logger.warning( + "invalid action payload in %s: %r", + action_path, + type(raw_action).__name__, + ) + return False, f"invalid action payload type: {type(raw_action).__name__}" + + actions_obj = raw_action.get("actions", {}) + if not isinstance(actions_obj, dict): + actions_obj = {} + action_cfg = actions_obj.get("list_subscriptions", {}) + if not isinstance(action_cfg, dict): + action_cfg = {} + self._load_action_config(action_cfg) + + self._session.headers.update( + {"User-Agent": self._config.defaults.user_agent or DEFAULT_UA} + ) + self.compile() + return True, None + + # -------- metadata/files handling -------- def _paths(self, sub: SubscriptionSpec): if self._config is None: @@ -134,6 +301,15 @@ def _paths(self, sub: SubscriptionSpec): os.makedirs(lists_dir, mode=0o700, exist_ok=True) sources_dir = os.path.join(lists_dir, "sources.list.d") os.makedirs(sources_dir, mode=0o700, exist_ok=True) + safe_filename = os.path.basename((sub.filename or "").strip()) + if safe_filename == "": + safe_filename = "subscription.list" + safe_filename = ensure_filename_type_suffix(safe_filename, sub.format) + list_path = os.path.join(sources_dir, safe_filename) + meta_path = list_path + ".meta.json" + return list_path, meta_path + + def _subscription_dirname(self, sub: SubscriptionSpec): safe_filename = os.path.basename((sub.filename or "").strip()) if safe_filename == "": safe_filename = "subscription.list" @@ -144,21 +320,21 @@ def _paths(self, sub: SubscriptionSpec): sub_dirname = base if base else "subscription" if not sub_dirname.lower().endswith(suffix): sub_dirname = f"{sub_dirname}{suffix}" - sub_dir = os.path.join(sources_dir, sub_dirname) - os.makedirs(sub_dir, mode=0o700, exist_ok=True) - list_path = os.path.join(sub_dir, safe_filename) - meta_path = list_path + ".meta.json" - return list_path, meta_path + return sub_dirname def _rules_root_dir(self): if self._config is None: return os.path.join(self.default_lists_dir, "rules.list.d") - return os.path.join(normalize_lists_dir(self._config.defaults.lists_dir), "rules.list.d") + return os.path.join( + normalize_lists_dir(self._config.defaults.lists_dir), "rules.list.d" + ) def _sources_root_dir(self): if self._config is None: return os.path.join(self.default_lists_dir, "sources.list.d") - return os.path.join(normalize_lists_dir(self._config.defaults.lists_dir), "sources.list.d") + return os.path.join( + normalize_lists_dir(self._config.defaults.lists_dir), "sources.list.d" + ) def _sync_sources_dirs(self): if self._config is None: @@ -166,18 +342,16 @@ def _sync_sources_dirs(self): sources_dir = self._sources_root_dir() os.makedirs(sources_dir, mode=0o700, exist_ok=True) - desired_dirs: set[str] = set() + desired_paths: set[str] = set() for sub in self._config.subscriptions: - list_path, _ = self._paths(sub) - desired_dirs.add(os.path.dirname(list_path)) + list_path, meta_path = self._paths(sub) + desired_paths.add(list_path) + desired_paths.add(meta_path) for entry in os.listdir(sources_dir): p = os.path.join(sources_dir, entry) try: - if os.path.isdir(p) and not os.path.islink(p): - if p not in desired_dirs: - shutil.rmtree(p) - else: + if p not in desired_paths: os.unlink(p) except Exception: pass @@ -195,9 +369,7 @@ def _sync_global_symlinks(self): if not os.path.exists(list_path): continue raw_groups: tuple[str, ...] = getattr(sub, "groups", tuple()) - groups: list[str] = list(raw_groups) - groups.append("all") - groups = sorted(normalize_group(g) for g in set(groups)) + groups = [self._subscription_dirname(sub), "all", *normalize_groups(raw_groups)] link_name = f"{idx:02d}-{os.path.basename(list_path)}" for group in groups: desired.setdefault(group, {})[link_name] = list_path @@ -218,7 +390,7 @@ def _sync_global_symlinks(self): except Exception: pass - for group_name in (existing_groups | set(desired.keys())): + for group_name in existing_groups | set(desired.keys()): group_dir = os.path.join(rules_dir, group_name) desired_links = desired.get(group_name, {}) if desired_links: @@ -244,7 +416,9 @@ def _sync_global_symlinks(self): in_sync = False try: if os.path.islink(entry_path): - in_sync = os.path.realpath(entry_path) == os.path.realpath(expected_target) + in_sync = os.path.realpath(entry_path) == os.path.realpath( + expected_target + ) except Exception: in_sync = False @@ -271,12 +445,91 @@ def _sync_global_symlinks(self): def _load_meta(self, meta_path: str): try: - return ListMetadata.from_dict(read_json(meta_path)) + return ListMetadata.from_dict(read_json_locked(meta_path)) except Exception: return ListMetadata() def _save_meta(self, meta_path: str, meta: ListMetadata): - write_json_atomic(meta_path, meta.to_dict()) + write_json_atomic_locked(meta_path, meta.to_dict()) + + def _fsync_parent_dir(self, path: str): + parent = os.path.dirname(path) + if parent == "": + return + try: + dir_fd = os.open(parent, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)) + except Exception: + return + try: + os.fsync(dir_fd) + except Exception: + pass + finally: + os.close(dir_fd) + + def _affected_rule_dirs(self, sub: SubscriptionSpec): + affected_dirs = {os.path.join(self._rules_root_dir(), self._subscription_dirname(sub))} + rules_root = self._rules_root_dir() + affected_dirs.add(os.path.join(rules_root, "all")) + for group in normalize_groups(sub.groups): + affected_dirs.add(os.path.join(rules_root, group)) + return { + os.path.normpath(path) + for path in affected_dirs + if path.strip() != "" + } + + def _reload_rules_for_updated_subscription(self, sub: SubscriptionSpec): + try: + affected_dirs = self._affected_rule_dirs(sub) + found_match = False + for addr in self._nodes.get().keys(): + if not self._nodes.is_local(addr): + continue + records = self._db.get_rules(addr) + if records is None or records == -1: + continue + matched = False + while records.next(): + rule = Rule.new_from_records(records) + if rule.operator.operand == Config.OPERAND_LIST_DOMAINS: + direct_dir = os.path.normpath(str(rule.operator.data or "").strip()) + if direct_dir in affected_dirs: + matched = True + if not matched: + for operator in getattr(rule.operator, "list", []): + if operator.operand != Config.OPERAND_LIST_DOMAINS: + continue + nested_dir = os.path.normpath(str(operator.data or "").strip()) + if nested_dir in affected_dirs: + matched = True + break + if not matched: + continue + + notification = ui_pb2.Notification( + type=ui_pb2.CHANGE_RULE, + rules=[rule], + ) + self._nodes.send_notification(addr, notification, None) + found_match = True + logger.warning( + "signaling affected rule '%s' for updated subscription '%s'", + rule.name, + sub.name, + ) + break + if found_match is False: + logger.warning( + "no matching rules found for updated subscription '%s'", + sub.name, + ) + except Exception as e: + logger.warning( + "reload rules after updating '%s' failed: %s", + sub.name, + repr(e), + ) # -------- timer lifecycle -------- @@ -285,7 +538,7 @@ def _sub_key(self, sub: SubscriptionSpec): return hashlib.sha1(base.encode("utf-8")).hexdigest()[:16] def configure(self, parent: Any = None): - if type(parent) == StatsDialog: + if type(parent) == StatsDialog: # noqa: E721 if self._cfg_action is not None: return @@ -293,8 +546,12 @@ def configure(self, parent: Any = None): if menu is None: return - icon_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "blocklist.svg") - icon = QtGui.QIcon(icon_path) if os.path.exists(icon_path) else QtGui.QIcon() + icon_path = os.path.join( + os.path.abspath(os.path.dirname(__file__)), "res", "blocklist.svg" + ) + icon = ( + QtGui.QIcon(icon_path) if os.path.exists(icon_path) else QtGui.QIcon() + ) quit_action = self._find_quit_action(menu) if quit_action is not None: @@ -312,7 +569,9 @@ def configure(self, parent: Any = None): else: self._cfg_action = menu.addAction("List subscriptions") - self._cfg_action.triggered.connect(lambda *_: self._open_config_dialog(parent)) + self._cfg_action.triggered.connect( + lambda *_: self._open_config_dialog(parent) + ) def _find_quit_action(self, menu: Any): qt_key = getattr(getattr(QtCore, "Qt", object()), "Key", None) @@ -324,7 +583,11 @@ def _find_quit_action(self, menu: Any): if txt == "quit": return act shortcut = act.shortcut() - if key_q is not None and shortcut and shortcut.matches(QtGui.QKeySequence(key_q)): + if ( + key_q is not None + and shortcut + and shortcut.matches(QtGui.QKeySequence(key_q)) + ): return act # In OpenSnitch main actions menu, Quit is typically the last entry. acts = [a for a in menu.actions() if not a.isSeparator()] @@ -344,7 +607,9 @@ def _open_config_dialog(self, parent): if self._cfg_dialog is None: # Some wrapped dialog types are not accepted as QWidget parents by # PyQt6 constructors in plugin context. Use a top-level dialog. - self._cfg_dialog = _gui.ListSubscriptionsDialog(parent=None, appicon=appicon) + self._cfg_dialog = _gui.ListSubscriptionsDialog( + parent=None, appicon=appicon + ) self._cfg_dialog.show() self._cfg_dialog.raise_() self._cfg_dialog.activateWindow() @@ -399,21 +664,15 @@ def run(self, parent: Any = None, args: tuple[Any, ...] = ()): # type: ignore[o if parent == StatsDialog: pass - - self._running = True - - for t in self.scheduled_tasks.values(): - try: - t.start() - except Exception: - pass - - # Validate + force download all subscriptions at startup. - th = threading.Thread(target=self._startup_recheck_all, daemon=True) - th.start() + self._start_runtime(recheck=True) def _startup_recheck_all(self): - if self._config is None: + if self._config is None or not self.enabled: + return + if not self._has_ready_local_node(): + with self._startup_recheck_lock: + self._startup_recheck_pending = True + logger.warning("startup refresh skipped, no local node is ready yet") return for sub in self._config.subscriptions: if not sub.enabled: @@ -421,12 +680,16 @@ def _startup_recheck_all(self): try: self.force_refresh_subscription(sub) except Exception as e: - logger.warning("list_subscriptions: startup recheck error name='%s' err=%s", sub.name, repr(e)) + logger.warning( + "startup recheck error name='%s' err=%s", + sub.name, + repr(e), + ) self._sync_global_symlinks() def stop(self): """ - Stop timers. + Stop timers and clear them from memory. """ for t in self.scheduled_tasks.values(): try: @@ -434,7 +697,6 @@ def stop(self): except Exception: pass self.scheduled_tasks.clear() - self._running = False # -------- scheduled execution -------- @@ -448,15 +710,15 @@ def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): sub: SubscriptionSpec key, sub = args - # due/backoff gate via sidecar meta + # due/backoff gate via metadata _, meta_path = self._paths(sub) meta = self._load_meta(meta_path) if self._in_backoff(meta): - logger.warning("list_subscriptions: skip '%s' (in backoff)", sub.name) + logger.warning("skip '%s' (in backoff)", sub.name) return if not self._is_due(meta, sub): - logger.warning("list_subscriptions: skip '%s' (not due yet)", sub.name) + logger.warning("skip '%s' (not due yet)", sub.name) return th = threading.Thread(target=self.download, args=(key, sub)) @@ -488,34 +750,103 @@ def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): else: result_msg = self._err_msg or f"{sub.name} failed: {', '.join(statuses)}" - if self._notify is not None and self._desktop_notifications.is_available() and self._desktop_notifications.are_enabled(): - self._desktop_notifications.show(self._notify_title, result_msg, self._app_icon) + if ( + self._notify is not None + and self._desktop_notifications.is_available() + and self._desktop_notifications.are_enabled() + ): + self._desktop_notifications.show( + self._notify_title, result_msg, self._app_icon + ) def force_refresh_subscription(self, sub: SubscriptionSpec): key = self._sub_key(sub) - logger.warning( - "list_subscriptions: force refresh requested name='%s' url='%s' file='%s'", - sub.name, sub.url, sub.filename - ) - ok = self.download(key, sub) - logger.warning( - "list_subscriptions: force refresh finished name='%s' result=%s", - sub.name, "ok" if ok else "error" - ) + ok = self.download(key, sub, force=True) self._sync_global_symlinks() return ok - def cb_signal(self, signal: Any): - logger.debug("cb_signal: %s, %s", self.name, signal) + def cb_signal(self, signal: dict[str, Any]): try: - if signal == PluginSignal.ENABLE: - self.enabled = True + sig = signal.get("signal") + action_path = signal.get("action_path") - if signal['signal'] == PluginSignal.DISABLE or signal['signal'] == PluginSignal.STOP: #type: ignore[union-attr] - for t in self.scheduled_tasks: - logger.debug("cb_signal.stopping task: %s, %s", self.name, signal) - self.scheduled_tasks[t].stop() + if sig == PluginSignal.ENABLE: + logger.warning( + "cb_signal: ENABLE action_path=%r", + action_path, + ) + ok, err = self._reload_from_action_file(action_path) + if ok: + self.enabled = True + self.run() + self._emit_runtime_event( + RuntimeEvent.RUNTIME_ENABLED, + "Plugin runtime enabled.", + action_path=action_path, + ) + else: + self._emit_runtime_event( + RuntimeEvent.RUNTIME_ERROR, + "Failed to enable plugin runtime.", + error=err, + action_path=action_path, + ) + return + if sig == PluginSignal.CONFIG_UPDATE: + logger.warning( + "cb_signal: CONFIG_UPDATE action_path=%r", + action_path, + ) + self.stop() + ok, err = self._reload_from_action_file(action_path) + if ok: + if self.enabled: + self.run() + self._emit_runtime_event( + RuntimeEvent.CONFIG_RELOADED, + "Plugin runtime configuration reloaded.", + action_path=action_path, + ) + else: + self._emit_runtime_event( + RuntimeEvent.RUNTIME_ERROR, + "Failed to reload plugin runtime configuration.", + error=err, + action_path=action_path, + ) + return + + if sig == PluginSignal.DISABLE or sig == PluginSignal.STOP: + logger.warning( + "cb_signal: %s action_path=%r", + "DISABLE" if sig == PluginSignal.DISABLE else "STOP", + action_path, + ) + self.enabled = False + self.stop() + self._emit_runtime_event( + RuntimeEvent.RUNTIME_DISABLED + if sig == PluginSignal.DISABLE + else RuntimeEvent.RUNTIME_STOPPED, + "Plugin runtime disabled." + if sig == PluginSignal.DISABLE + else "Plugin runtime stopped.", + action_path=action_path, + ) + return + + if sig == PluginSignal.ERROR: + err = str(signal.get("error") or signal.get("message") or "") + self._emit_runtime_event( + RuntimeEvent.RUNTIME_ERROR, + "Plugin runtime reported an error.", + error=err or None, + action_path=action_path, + ) + return + + raise ValueError(f"unrecognized signal: {sig}") except Exception as e: logger.warning("cb_signal() exception: %s", repr(e)) @@ -533,7 +864,9 @@ def _is_due(self, meta: ListMetadata, sub: SubscriptionSpec): lc = parse_iso(meta.last_checked) if not lc: return True - return (datetime.now().astimezone() - lc).total_seconds() >= sub.interval_seconds + return ( + datetime.now().astimezone() - lc + ).total_seconds() >= sub.interval_seconds # -------- worker: download + update metadata -------- @@ -543,15 +876,13 @@ def _mark_failure(self, meta: ListMetadata, err: str): meta.last_result = "error" seconds = min((2 ** max(0, meta.fail_count)) * 60, 6 * 3600) - meta.backoff_until = (datetime.now().astimezone() + timedelta(seconds=seconds)).isoformat() + meta.backoff_until = ( + datetime.now().astimezone() + timedelta(seconds=seconds) + ).isoformat() - def download(self, key: str, sub: SubscriptionSpec): + def download(self, key: str, sub: SubscriptionSpec, force: bool = False): list_path, meta_path = self._paths(sub) os.makedirs(os.path.dirname(list_path), exist_ok=True) - logger.warning( - "list_subscriptions: download start key=%s name='%s' dst='%s'", - key, sub.name, list_path - ) meta = self._load_meta(meta_path) @@ -565,9 +896,9 @@ def download(self, key: str, sub: SubscriptionSpec): # conditional headers headers: dict[str, str] = {} - if meta.etag: + if not force and meta.etag: headers["If-None-Match"] = meta.etag - if meta.last_modified: + if not force and meta.last_modified: headers["If-Modified-Since"] = meta.last_modified headers["User-Agent"] = self._config.defaults.user_agent or DEFAULT_UA @@ -591,6 +922,7 @@ def download(self, key: str, sub: SubscriptionSpec): self._resultsQueue.put((key, False, "request_error")) return False + response_closed = False try: if r.status_code == 304: meta.fail_count = 0 @@ -598,14 +930,18 @@ def download(self, key: str, sub: SubscriptionSpec): meta.last_result = "not_modified" self._save_meta(meta_path, meta) self._resultsQueue.put((key, True, "not_modified")) - logger.warning("list_subscriptions: download not-modified name='%s'", sub.name) + logger.warning("subscription not-modified name='%s'", sub.name) return True if r.status_code != 200: self._mark_failure(meta, f"http_{r.status_code}") self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, f"http_{r.status_code}")) - logger.warning("list_subscriptions: download http error name='%s' code=%s", sub.name, r.status_code) + logger.warning( + "subscription download http-error name='%s' code=%s", + sub.name, + r.status_code, + ) return False cl: str | None = r.headers.get("Content-Length") @@ -615,7 +951,11 @@ def download(self, key: str, sub: SubscriptionSpec): self._mark_failure(meta, f"too_large:{cl}") self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, "too_large")) - logger.warning("list_subscriptions: download too-large name='%s' len=%s", sub.name, cl) + logger.warning( + "subscription download too-large name='%s' len=%s", + sub.name, + cl, + ) return False except Exception: pass @@ -634,7 +974,10 @@ def download(self, key: str, sub: SubscriptionSpec): raise RuntimeError("too_large_streamed") f.write(chunk) - if sub.format.lower() == "hosts" and len(sample_lines) < 200: + if ( + sub.format.lower() == "hosts" + and len(sample_lines) < 200 + ): txt = chunk.decode("utf-8", errors="ignore") for ln in txt.splitlines(): if len(sample_lines) < 200: @@ -645,7 +988,9 @@ def download(self, key: str, sub: SubscriptionSpec): f.flush() os.fsync(f.fileno()) - if sub.format.lower() == "hosts" and not is_hosts_file_like(sample_lines): + if sub.format.lower() == "hosts" and not is_hosts_file_like( + sample_lines + ): try: os.remove(tmp) except Exception: @@ -653,10 +998,14 @@ def download(self, key: str, sub: SubscriptionSpec): self._mark_failure(meta, "bad_format_hosts") self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, "bad_format")) - logger.warning("list_subscriptions: download bad-format name='%s'", sub.name) + logger.warning( + "subscription file bad-format name='%s'", + sub.name, + ) return False os.replace(tmp, list_path) + self._fsync_parent_dir(list_path) except Exception as e: try: @@ -667,7 +1016,11 @@ def download(self, key: str, sub: SubscriptionSpec): self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, "write_error")) - logger.warning("list_subscriptions: download write-error name='%s' err=%s", sub.name, repr(e)) + logger.warning( + "subscription file write-error name='%s' err=%s", + sub.name, + repr(e), + ) return False # update cache validators @@ -684,16 +1037,28 @@ def download(self, key: str, sub: SubscriptionSpec): meta.backoff_until = "" meta.last_result = "updated" self._save_meta(meta_path, meta) + logger.warning( + "subscription updated name='%s' bytes=%s", + sub.name, + downloaded, + ) + r.close() + response_closed = True + self._reload_rules_for_updated_subscription(sub) self._resultsQueue.put((key, True, "updated")) - logger.warning("list_subscriptions: download updated name='%s' bytes=%s", sub.name, downloaded) return True finally: - r.close() + if not response_closed: + r.close() except Exception as e: self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) self._resultsQueue.put((key, False, "unexpected_error")) - logger.warning("list_subscriptions: download unexpected-error name='%s' err=%s", sub.name, repr(e)) + logger.warning( + "subscription download unexpected-error name='%s' err=%s", + sub.name, + repr(e), + ) return False finally: diff --git a/ui/opensnitch/plugins/list_subscriptions/res/__init__.py b/ui/opensnitch/plugins/list_subscriptions/res/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/blocklist.svg b/ui/opensnitch/plugins/list_subscriptions/res/blocklist.svg similarity index 100% rename from ui/opensnitch/plugins/list_subscriptions/blocklist.svg rename to ui/opensnitch/plugins/list_subscriptions/res/blocklist.svg diff --git a/ui/opensnitch/plugins/list_subscriptions/bulk_edit_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/bulk_edit_dialog.ui similarity index 100% rename from ui/opensnitch/plugins/list_subscriptions/bulk_edit_dialog.ui rename to ui/opensnitch/plugins/list_subscriptions/res/bulk_edit_dialog.ui diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui similarity index 91% rename from ui/opensnitch/plugins/list_subscriptions/list_subscriptions_dialog.ui rename to ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui index c191cfc6b8..574b88674b 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions_dialog.ui +++ b/ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui @@ -57,6 +57,27 @@ + + + + Start + + + + + + + Stop + + + + + + + Runtime: inactive + + + @@ -183,7 +204,7 @@ - Rule actions + Selected subscription(s) actions @@ -203,7 +224,7 @@ - Refresh now + Refresh diff --git a/ui/opensnitch/plugins/list_subscriptions/subscription_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui similarity index 89% rename from ui/opensnitch/plugins/list_subscriptions/subscription_dialog.ui rename to ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui index e7971b8c2d..1fea6d33ec 100644 --- a/ui/opensnitch/plugins/list_subscriptions/subscription_dialog.ui +++ b/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui @@ -39,54 +39,82 @@ - - + + - URL + - + + + + URL + + + + - - + + - Filename + - + + + + Filename + + + + - + + + + + + + + Format - + - + Groups - + - + + + + + + + + Interval - + @@ -96,14 +124,14 @@ - + Timeout - + @@ -113,14 +141,14 @@ - + Max size - + @@ -298,6 +326,13 @@ + + + + Test URL + + + From 9077fbb48483d212c114d0844e638aa3a096a950 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Wed, 11 Mar 2026 09:50:13 +0100 Subject: [PATCH 09/13] restore orginal proto bindings and remove annotation stub --- .../list_subscriptions/list_subscriptions.py | 13 +- ui/opensnitch/proto/ui_pb2.py | 217 ++++---- ui/opensnitch/proto/ui_pb2.pyi | 494 ------------------ ui/opensnitch/proto/ui_pb2_grpc.py | 113 +--- 4 files changed, 132 insertions(+), 705 deletions(-) delete mode 100644 ui/opensnitch/proto/ui_pb2.pyi diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index 39aee9ee25..8cc15fe6ac 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -22,7 +22,10 @@ except Exception: from PyQt5 import QtCore, QtGui -from opensnitch.dialogs.stats import StatsDialog +try: + from opensnitch.dialogs.events import StatsDialog +except ImportError: + from opensnitch.dialogs.stats import StatsDialog from opensnitch.config import Config from opensnitch.nodes import Nodes from opensnitch.notifications import DesktopNotifications @@ -507,8 +510,8 @@ def _reload_rules_for_updated_subscription(self, sub: SubscriptionSpec): if not matched: continue - notification = ui_pb2.Notification( - type=ui_pb2.CHANGE_RULE, + notification = ui_pb2.Notification( # type: ignore + type=ui_pb2.CHANGE_RULE, # type: ignore rules=[rule], ) self._nodes.send_notification(addr, notification, None) @@ -538,7 +541,7 @@ def _sub_key(self, sub: SubscriptionSpec): return hashlib.sha1(base.encode("utf-8")).hexdigest()[:16] def configure(self, parent: Any = None): - if type(parent) == StatsDialog: # noqa: E721 + if isinstance(parent, StatsDialog): if self._cfg_action is not None: return @@ -662,7 +665,7 @@ def run(self, parent: Any = None, args: tuple[Any, ...] = ()): # type: ignore[o Start timers. """ - if parent == StatsDialog: + if isinstance(parent, StatsDialog): pass self._start_runtime(recheck=True) diff --git a/ui/opensnitch/proto/ui_pb2.py b/ui/opensnitch/proto/ui_pb2.py index b75655f25a..a864f586a7 100644 --- a/ui/opensnitch/proto/ui_pb2.py +++ b/ui/opensnitch/proto/ui_pb2.py @@ -1,22 +1,11 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! -# NO CHECKED-IN PROTOBUF GENCODE # source: ui.proto -# Protobuf Python Version: 6.33.1 """Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import runtime_version as _runtime_version from google.protobuf import symbol_database as _symbol_database -from google.protobuf.internal import builder as _builder -_runtime_version.ValidateProtobufRuntimeVersion( - _runtime_version.Domain.PUBLIC, - 6, - 33, - 1, - '', - 'ui.proto' -) # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() @@ -26,106 +15,106 @@ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x08ui.proto\x12\x08protocol\"\xcb\x04\n\x05\x41lert\x12\n\n\x02id\x18\x01 \x01(\x04\x12\"\n\x04type\x18\x02 \x01(\x0e\x32\x14.protocol.Alert.Type\x12&\n\x06\x61\x63tion\x18\x03 \x01(\x0e\x32\x16.protocol.Alert.Action\x12*\n\x08priority\x18\x04 \x01(\x0e\x32\x18.protocol.Alert.Priority\x12\"\n\x04what\x18\x05 \x01(\x0e\x32\x14.protocol.Alert.What\x12\x0e\n\x04text\x18\x06 \x01(\tH\x00\x12!\n\x04proc\x18\x08 \x01(\x0b\x32\x11.protocol.ProcessH\x00\x12$\n\x04\x63onn\x18\t \x01(\x0b\x32\x14.protocol.ConnectionH\x00\x12\x1e\n\x04rule\x18\n \x01(\x0b\x32\x0e.protocol.RuleH\x00\x12\"\n\x06\x66wrule\x18\x0b \x01(\x0b\x32\x10.protocol.FwRuleH\x00\")\n\x08Priority\x12\x07\n\x03LOW\x10\x00\x12\n\n\x06MEDIUM\x10\x01\x12\x08\n\x04HIGH\x10\x02\"(\n\x04Type\x12\t\n\x05\x45RROR\x10\x00\x12\x0b\n\x07WARNING\x10\x01\x12\x08\n\x04INFO\x10\x02\"2\n\x06\x41\x63tion\x12\x08\n\x04NONE\x10\x00\x12\x0e\n\nSHOW_ALERT\x10\x01\x12\x0e\n\nSAVE_TO_DB\x10\x02\"l\n\x04What\x12\x0b\n\x07GENERIC\x10\x00\x12\x10\n\x0cPROC_MONITOR\x10\x01\x12\x0c\n\x08\x46IREWALL\x10\x02\x12\x0e\n\nCONNECTION\x10\x03\x12\x08\n\x04RULE\x10\x04\x12\x0b\n\x07NETLINK\x10\x05\x12\x10\n\x0cKERNEL_EVENT\x10\x06\x42\x06\n\x04\x64\x61ta\"\x19\n\x0bMsgResponse\x12\n\n\x02id\x18\x01 \x01(\x04\"o\n\x05\x45vent\x12\x0c\n\x04time\x18\x01 \x01(\t\x12(\n\nconnection\x18\x02 \x01(\x0b\x32\x14.protocol.Connection\x12\x1c\n\x04rule\x18\x03 \x01(\x0b\x32\x0e.protocol.Rule\x12\x10\n\x08unixnano\x18\x04 \x01(\x03\"\xd3\x06\n\nStatistics\x12\x16\n\x0e\x64\x61\x65mon_version\x18\x01 \x01(\t\x12\r\n\x05rules\x18\x02 \x01(\x04\x12\x0e\n\x06uptime\x18\x03 \x01(\x04\x12\x15\n\rdns_responses\x18\x04 \x01(\x04\x12\x13\n\x0b\x63onnections\x18\x05 \x01(\x04\x12\x0f\n\x07ignored\x18\x06 \x01(\x04\x12\x10\n\x08\x61\x63\x63\x65pted\x18\x07 \x01(\x04\x12\x0f\n\x07\x64ropped\x18\x08 \x01(\x04\x12\x11\n\trule_hits\x18\t \x01(\x04\x12\x13\n\x0brule_misses\x18\n \x01(\x04\x12\x33\n\x08\x62y_proto\x18\x0b \x03(\x0b\x32!.protocol.Statistics.ByProtoEntry\x12\x37\n\nby_address\x18\x0c \x03(\x0b\x32#.protocol.Statistics.ByAddressEntry\x12\x31\n\x07\x62y_host\x18\r \x03(\x0b\x32 .protocol.Statistics.ByHostEntry\x12\x31\n\x07\x62y_port\x18\x0e \x03(\x0b\x32 .protocol.Statistics.ByPortEntry\x12/\n\x06\x62y_uid\x18\x0f \x03(\x0b\x32\x1f.protocol.Statistics.ByUidEntry\x12=\n\rby_executable\x18\x10 \x03(\x0b\x32&.protocol.Statistics.ByExecutableEntry\x12\x1f\n\x06\x65vents\x18\x11 \x03(\x0b\x32\x0f.protocol.Event\x1a.\n\x0c\x42yProtoEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a\x30\n\x0e\x42yAddressEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a-\n\x0b\x42yHostEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a-\n\x0b\x42yPortEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a,\n\nByUidEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\x1a\x33\n\x11\x42yExecutableEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\x04:\x02\x38\x01\">\n\x0bPingRequest\x12\n\n\x02id\x18\x01 \x01(\x04\x12#\n\x05stats\x18\x02 \x01(\x0b\x32\x14.protocol.Statistics\"\x17\n\tPingReply\x12\n\n\x02id\x18\x01 \x01(\x04\"\'\n\tStringInt\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\r\"\x9b\x03\n\x07Process\x12\x0b\n\x03pid\x18\x01 \x01(\x04\x12\x0c\n\x04ppid\x18\x02 \x01(\x04\x12\x0b\n\x03uid\x18\x03 \x01(\x04\x12\x0c\n\x04\x63omm\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\x0c\n\x04\x61rgs\x18\x06 \x03(\t\x12\'\n\x03\x65nv\x18\x07 \x03(\x0b\x32\x1a.protocol.Process.EnvEntry\x12\x0b\n\x03\x63wd\x18\x08 \x01(\t\x12\x33\n\tchecksums\x18\t \x03(\x0b\x32 .protocol.Process.ChecksumsEntry\x12\x10\n\x08io_reads\x18\n \x01(\x04\x12\x11\n\tio_writes\x18\x0b \x01(\x04\x12\x11\n\tnet_reads\x18\x0c \x01(\x04\x12\x12\n\nnet_writes\x18\r \x01(\x04\x12)\n\x0cprocess_tree\x18\x0e \x03(\x0b\x32\x13.protocol.StringInt\x1a*\n\x08\x45nvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x30\n\x0e\x43hecksumsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xf3\x03\n\nConnection\x12\x10\n\x08protocol\x18\x01 \x01(\t\x12\x0e\n\x06src_ip\x18\x02 \x01(\t\x12\x10\n\x08src_port\x18\x03 \x01(\r\x12\x0e\n\x06\x64st_ip\x18\x04 \x01(\t\x12\x10\n\x08\x64st_host\x18\x05 \x01(\t\x12\x10\n\x08\x64st_port\x18\x06 \x01(\r\x12\x0f\n\x07user_id\x18\x07 \x01(\r\x12\x12\n\nprocess_id\x18\x08 \x01(\r\x12\x14\n\x0cprocess_path\x18\t \x01(\t\x12\x13\n\x0bprocess_cwd\x18\n \x01(\t\x12\x14\n\x0cprocess_args\x18\x0b \x03(\t\x12\x39\n\x0bprocess_env\x18\x0c \x03(\x0b\x32$.protocol.Connection.ProcessEnvEntry\x12\x45\n\x11process_checksums\x18\r \x03(\x0b\x32*.protocol.Connection.ProcessChecksumsEntry\x12)\n\x0cprocess_tree\x18\x0e \x03(\x0b\x32\x13.protocol.StringInt\x1a\x31\n\x0fProcessEnvEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x37\n\x15ProcessChecksumsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"l\n\x08Operator\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\x0f\n\x07operand\x18\x02 \x01(\t\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\t\x12\x11\n\tsensitive\x18\x04 \x01(\x08\x12 \n\x04list\x18\x05 \x03(\x0b\x32\x12.protocol.Operator\"\xb6\x01\n\x04Rule\x12\x0f\n\x07\x63reated\x18\x01 \x01(\x03\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\x12\x12\n\nprecedence\x18\x05 \x01(\x08\x12\r\n\x05nolog\x18\x06 \x01(\x08\x12\x0e\n\x06\x61\x63tion\x18\x07 \x01(\t\x12\x10\n\x08\x64uration\x18\x08 \x01(\t\x12$\n\x08operator\x18\t \x01(\x0b\x32\x12.protocol.Operator\"-\n\x0fStatementValues\x12\x0b\n\x03Key\x18\x01 \x01(\t\x12\r\n\x05Value\x18\x02 \x01(\t\"P\n\tStatement\x12\n\n\x02Op\x18\x01 \x01(\t\x12\x0c\n\x04Name\x18\x02 \x01(\t\x12)\n\x06Values\x18\x03 \x03(\x0b\x32\x19.protocol.StatementValues\"5\n\x0b\x45xpressions\x12&\n\tStatement\x18\x01 \x01(\x0b\x32\x13.protocol.Statement\"\xd6\x01\n\x06\x46wRule\x12\r\n\x05Table\x18\x01 \x01(\t\x12\r\n\x05\x43hain\x18\x02 \x01(\t\x12\x0c\n\x04UUID\x18\x03 \x01(\t\x12\x0f\n\x07\x45nabled\x18\x04 \x01(\x08\x12\x10\n\x08Position\x18\x05 \x01(\x04\x12\x13\n\x0b\x44\x65scription\x18\x06 \x01(\t\x12\x12\n\nParameters\x18\x07 \x01(\t\x12*\n\x0b\x45xpressions\x18\x08 \x03(\x0b\x32\x15.protocol.Expressions\x12\x0e\n\x06Target\x18\t \x01(\t\x12\x18\n\x10TargetParameters\x18\n \x01(\t\"\x95\x01\n\x07\x46wChain\x12\x0c\n\x04Name\x18\x01 \x01(\t\x12\r\n\x05Table\x18\x02 \x01(\t\x12\x0e\n\x06\x46\x61mily\x18\x03 \x01(\t\x12\x10\n\x08Priority\x18\x04 \x01(\t\x12\x0c\n\x04Type\x18\x05 \x01(\t\x12\x0c\n\x04Hook\x18\x06 \x01(\t\x12\x0e\n\x06Policy\x18\x07 \x01(\t\x12\x1f\n\x05Rules\x18\x08 \x03(\x0b\x32\x10.protocol.FwRule\"M\n\x08\x46wChains\x12\x1e\n\x04Rule\x18\x01 \x01(\x0b\x32\x10.protocol.FwRule\x12!\n\x06\x43hains\x18\x02 \x03(\x0b\x32\x11.protocol.FwChain\"X\n\x0bSysFirewall\x12\x0f\n\x07\x45nabled\x18\x01 \x01(\x08\x12\x0f\n\x07Version\x18\x02 \x01(\r\x12\'\n\x0bSystemRules\x18\x03 \x03(\x0b\x32\x12.protocol.FwChains\"\xc4\x01\n\x0c\x43lientConfig\x12\n\n\x02id\x18\x01 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0f\n\x07version\x18\x03 \x01(\t\x12\x19\n\x11isFirewallRunning\x18\x04 \x01(\x08\x12\x0e\n\x06\x63onfig\x18\x05 \x01(\t\x12\x10\n\x08logLevel\x18\x06 \x01(\r\x12\x1d\n\x05rules\x18\x07 \x03(\x0b\x32\x0e.protocol.Rule\x12-\n\x0esystemFirewall\x18\x08 \x01(\x0b\x32\x15.protocol.SysFirewall\"\xbb\x01\n\x0cNotification\x12\n\n\x02id\x18\x01 \x01(\x04\x12\x12\n\nclientName\x18\x02 \x01(\t\x12\x12\n\nserverName\x18\x03 \x01(\t\x12\x1e\n\x04type\x18\x04 \x01(\x0e\x32\x10.protocol.Action\x12\x0c\n\x04\x64\x61ta\x18\x05 \x01(\t\x12\x1d\n\x05rules\x18\x06 \x03(\x0b\x32\x0e.protocol.Rule\x12*\n\x0bsysFirewall\x18\x07 \x01(\x0b\x32\x15.protocol.SysFirewall\"\\\n\x11NotificationReply\x12\n\n\x02id\x18\x01 \x01(\x04\x12-\n\x04\x63ode\x18\x02 \x01(\x0e\x32\x1f.protocol.NotificationReplyCode\x12\x0c\n\x04\x64\x61ta\x18\x03 \x01(\t*\x95\x02\n\x06\x41\x63tion\x12\x08\n\x04NONE\x10\x00\x12\x17\n\x13\x45NABLE_INTERCEPTION\x10\x01\x12\x18\n\x14\x44ISABLE_INTERCEPTION\x10\x02\x12\x13\n\x0f\x45NABLE_FIREWALL\x10\x03\x12\x14\n\x10\x44ISABLE_FIREWALL\x10\x04\x12\x13\n\x0fRELOAD_FW_RULES\x10\x05\x12\x11\n\rCHANGE_CONFIG\x10\x06\x12\x0f\n\x0b\x45NABLE_RULE\x10\x07\x12\x10\n\x0c\x44ISABLE_RULE\x10\x08\x12\x0f\n\x0b\x44\x45LETE_RULE\x10\t\x12\x0f\n\x0b\x43HANGE_RULE\x10\n\x12\r\n\tLOG_LEVEL\x10\x0b\x12\x08\n\x04STOP\x10\x0c\x12\x0e\n\nTASK_START\x10\r\x12\r\n\tTASK_STOP\x10\x0e**\n\x15NotificationReplyCode\x12\x06\n\x02OK\x10\x00\x12\t\n\x05\x45RROR\x10\x01\x32\xaf\x02\n\x02UI\x12\x34\n\x04Ping\x12\x15.protocol.PingRequest\x1a\x13.protocol.PingReply\"\x00\x12\x31\n\x07\x41skRule\x12\x14.protocol.Connection\x1a\x0e.protocol.Rule\"\x00\x12=\n\tSubscribe\x12\x16.protocol.ClientConfig\x1a\x16.protocol.ClientConfig\"\x00\x12J\n\rNotifications\x12\x1b.protocol.NotificationReply\x1a\x16.protocol.Notification\"\x00(\x01\x30\x01\x12\x35\n\tPostAlert\x12\x0f.protocol.Alert\x1a\x15.protocol.MsgResponse\"\x00\x42\x35Z3github.com/evilsocket/opensnitch/daemon/ui/protocolb\x06proto3') -_globals = globals() -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ui_pb2', _globals) -if not _descriptor._USE_C_DESCRIPTORS: - _globals['DESCRIPTOR']._loaded_options = None - _globals['DESCRIPTOR']._serialized_options = b'Z3github.com/evilsocket/opensnitch/daemon/ui/protocol' - _globals['_STATISTICS_BYPROTOENTRY']._loaded_options = None - _globals['_STATISTICS_BYPROTOENTRY']._serialized_options = b'8\001' - _globals['_STATISTICS_BYADDRESSENTRY']._loaded_options = None - _globals['_STATISTICS_BYADDRESSENTRY']._serialized_options = b'8\001' - _globals['_STATISTICS_BYHOSTENTRY']._loaded_options = None - _globals['_STATISTICS_BYHOSTENTRY']._serialized_options = b'8\001' - _globals['_STATISTICS_BYPORTENTRY']._loaded_options = None - _globals['_STATISTICS_BYPORTENTRY']._serialized_options = b'8\001' - _globals['_STATISTICS_BYUIDENTRY']._loaded_options = None - _globals['_STATISTICS_BYUIDENTRY']._serialized_options = b'8\001' - _globals['_STATISTICS_BYEXECUTABLEENTRY']._loaded_options = None - _globals['_STATISTICS_BYEXECUTABLEENTRY']._serialized_options = b'8\001' - _globals['_PROCESS_ENVENTRY']._loaded_options = None - _globals['_PROCESS_ENVENTRY']._serialized_options = b'8\001' - _globals['_PROCESS_CHECKSUMSENTRY']._loaded_options = None - _globals['_PROCESS_CHECKSUMSENTRY']._serialized_options = b'8\001' - _globals['_CONNECTION_PROCESSENVENTRY']._loaded_options = None - _globals['_CONNECTION_PROCESSENVENTRY']._serialized_options = b'8\001' - _globals['_CONNECTION_PROCESSCHECKSUMSENTRY']._loaded_options = None - _globals['_CONNECTION_PROCESSCHECKSUMSENTRY']._serialized_options = b'8\001' - _globals['_ACTION']._serialized_start=4153 - _globals['_ACTION']._serialized_end=4430 - _globals['_NOTIFICATIONREPLYCODE']._serialized_start=4432 - _globals['_NOTIFICATIONREPLYCODE']._serialized_end=4474 - _globals['_ALERT']._serialized_start=23 - _globals['_ALERT']._serialized_end=610 - _globals['_ALERT_PRIORITY']._serialized_start=357 - _globals['_ALERT_PRIORITY']._serialized_end=398 - _globals['_ALERT_TYPE']._serialized_start=400 - _globals['_ALERT_TYPE']._serialized_end=440 - _globals['_ALERT_ACTION']._serialized_start=442 - _globals['_ALERT_ACTION']._serialized_end=492 - _globals['_ALERT_WHAT']._serialized_start=494 - _globals['_ALERT_WHAT']._serialized_end=602 - _globals['_MSGRESPONSE']._serialized_start=612 - _globals['_MSGRESPONSE']._serialized_end=637 - _globals['_EVENT']._serialized_start=639 - _globals['_EVENT']._serialized_end=750 - _globals['_STATISTICS']._serialized_start=753 - _globals['_STATISTICS']._serialized_end=1604 - _globals['_STATISTICS_BYPROTOENTRY']._serialized_start=1315 - _globals['_STATISTICS_BYPROTOENTRY']._serialized_end=1361 - _globals['_STATISTICS_BYADDRESSENTRY']._serialized_start=1363 - _globals['_STATISTICS_BYADDRESSENTRY']._serialized_end=1411 - _globals['_STATISTICS_BYHOSTENTRY']._serialized_start=1413 - _globals['_STATISTICS_BYHOSTENTRY']._serialized_end=1458 - _globals['_STATISTICS_BYPORTENTRY']._serialized_start=1460 - _globals['_STATISTICS_BYPORTENTRY']._serialized_end=1505 - _globals['_STATISTICS_BYUIDENTRY']._serialized_start=1507 - _globals['_STATISTICS_BYUIDENTRY']._serialized_end=1551 - _globals['_STATISTICS_BYEXECUTABLEENTRY']._serialized_start=1553 - _globals['_STATISTICS_BYEXECUTABLEENTRY']._serialized_end=1604 - _globals['_PINGREQUEST']._serialized_start=1606 - _globals['_PINGREQUEST']._serialized_end=1668 - _globals['_PINGREPLY']._serialized_start=1670 - _globals['_PINGREPLY']._serialized_end=1693 - _globals['_STRINGINT']._serialized_start=1695 - _globals['_STRINGINT']._serialized_end=1734 - _globals['_PROCESS']._serialized_start=1737 - _globals['_PROCESS']._serialized_end=2148 - _globals['_PROCESS_ENVENTRY']._serialized_start=2056 - _globals['_PROCESS_ENVENTRY']._serialized_end=2098 - _globals['_PROCESS_CHECKSUMSENTRY']._serialized_start=2100 - _globals['_PROCESS_CHECKSUMSENTRY']._serialized_end=2148 - _globals['_CONNECTION']._serialized_start=2151 - _globals['_CONNECTION']._serialized_end=2650 - _globals['_CONNECTION_PROCESSENVENTRY']._serialized_start=2544 - _globals['_CONNECTION_PROCESSENVENTRY']._serialized_end=2593 - _globals['_CONNECTION_PROCESSCHECKSUMSENTRY']._serialized_start=2595 - _globals['_CONNECTION_PROCESSCHECKSUMSENTRY']._serialized_end=2650 - _globals['_OPERATOR']._serialized_start=2652 - _globals['_OPERATOR']._serialized_end=2760 - _globals['_RULE']._serialized_start=2763 - _globals['_RULE']._serialized_end=2945 - _globals['_STATEMENTVALUES']._serialized_start=2947 - _globals['_STATEMENTVALUES']._serialized_end=2992 - _globals['_STATEMENT']._serialized_start=2994 - _globals['_STATEMENT']._serialized_end=3074 - _globals['_EXPRESSIONS']._serialized_start=3076 - _globals['_EXPRESSIONS']._serialized_end=3129 - _globals['_FWRULE']._serialized_start=3132 - _globals['_FWRULE']._serialized_end=3346 - _globals['_FWCHAIN']._serialized_start=3349 - _globals['_FWCHAIN']._serialized_end=3498 - _globals['_FWCHAINS']._serialized_start=3500 - _globals['_FWCHAINS']._serialized_end=3577 - _globals['_SYSFIREWALL']._serialized_start=3579 - _globals['_SYSFIREWALL']._serialized_end=3667 - _globals['_CLIENTCONFIG']._serialized_start=3670 - _globals['_CLIENTCONFIG']._serialized_end=3866 - _globals['_NOTIFICATION']._serialized_start=3869 - _globals['_NOTIFICATION']._serialized_end=4056 - _globals['_NOTIFICATIONREPLY']._serialized_start=4058 - _globals['_NOTIFICATIONREPLY']._serialized_end=4150 - _globals['_UI']._serialized_start=4477 - _globals['_UI']._serialized_end=4780 +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ui_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'Z3github.com/evilsocket/opensnitch/daemon/ui/protocol' + _STATISTICS_BYPROTOENTRY._options = None + _STATISTICS_BYPROTOENTRY._serialized_options = b'8\001' + _STATISTICS_BYADDRESSENTRY._options = None + _STATISTICS_BYADDRESSENTRY._serialized_options = b'8\001' + _STATISTICS_BYHOSTENTRY._options = None + _STATISTICS_BYHOSTENTRY._serialized_options = b'8\001' + _STATISTICS_BYPORTENTRY._options = None + _STATISTICS_BYPORTENTRY._serialized_options = b'8\001' + _STATISTICS_BYUIDENTRY._options = None + _STATISTICS_BYUIDENTRY._serialized_options = b'8\001' + _STATISTICS_BYEXECUTABLEENTRY._options = None + _STATISTICS_BYEXECUTABLEENTRY._serialized_options = b'8\001' + _PROCESS_ENVENTRY._options = None + _PROCESS_ENVENTRY._serialized_options = b'8\001' + _PROCESS_CHECKSUMSENTRY._options = None + _PROCESS_CHECKSUMSENTRY._serialized_options = b'8\001' + _CONNECTION_PROCESSENVENTRY._options = None + _CONNECTION_PROCESSENVENTRY._serialized_options = b'8\001' + _CONNECTION_PROCESSCHECKSUMSENTRY._options = None + _CONNECTION_PROCESSCHECKSUMSENTRY._serialized_options = b'8\001' + _ACTION._serialized_start=4153 + _ACTION._serialized_end=4430 + _NOTIFICATIONREPLYCODE._serialized_start=4432 + _NOTIFICATIONREPLYCODE._serialized_end=4474 + _ALERT._serialized_start=23 + _ALERT._serialized_end=610 + _ALERT_PRIORITY._serialized_start=357 + _ALERT_PRIORITY._serialized_end=398 + _ALERT_TYPE._serialized_start=400 + _ALERT_TYPE._serialized_end=440 + _ALERT_ACTION._serialized_start=442 + _ALERT_ACTION._serialized_end=492 + _ALERT_WHAT._serialized_start=494 + _ALERT_WHAT._serialized_end=602 + _MSGRESPONSE._serialized_start=612 + _MSGRESPONSE._serialized_end=637 + _EVENT._serialized_start=639 + _EVENT._serialized_end=750 + _STATISTICS._serialized_start=753 + _STATISTICS._serialized_end=1604 + _STATISTICS_BYPROTOENTRY._serialized_start=1315 + _STATISTICS_BYPROTOENTRY._serialized_end=1361 + _STATISTICS_BYADDRESSENTRY._serialized_start=1363 + _STATISTICS_BYADDRESSENTRY._serialized_end=1411 + _STATISTICS_BYHOSTENTRY._serialized_start=1413 + _STATISTICS_BYHOSTENTRY._serialized_end=1458 + _STATISTICS_BYPORTENTRY._serialized_start=1460 + _STATISTICS_BYPORTENTRY._serialized_end=1505 + _STATISTICS_BYUIDENTRY._serialized_start=1507 + _STATISTICS_BYUIDENTRY._serialized_end=1551 + _STATISTICS_BYEXECUTABLEENTRY._serialized_start=1553 + _STATISTICS_BYEXECUTABLEENTRY._serialized_end=1604 + _PINGREQUEST._serialized_start=1606 + _PINGREQUEST._serialized_end=1668 + _PINGREPLY._serialized_start=1670 + _PINGREPLY._serialized_end=1693 + _STRINGINT._serialized_start=1695 + _STRINGINT._serialized_end=1734 + _PROCESS._serialized_start=1737 + _PROCESS._serialized_end=2148 + _PROCESS_ENVENTRY._serialized_start=2056 + _PROCESS_ENVENTRY._serialized_end=2098 + _PROCESS_CHECKSUMSENTRY._serialized_start=2100 + _PROCESS_CHECKSUMSENTRY._serialized_end=2148 + _CONNECTION._serialized_start=2151 + _CONNECTION._serialized_end=2650 + _CONNECTION_PROCESSENVENTRY._serialized_start=2544 + _CONNECTION_PROCESSENVENTRY._serialized_end=2593 + _CONNECTION_PROCESSCHECKSUMSENTRY._serialized_start=2595 + _CONNECTION_PROCESSCHECKSUMSENTRY._serialized_end=2650 + _OPERATOR._serialized_start=2652 + _OPERATOR._serialized_end=2760 + _RULE._serialized_start=2763 + _RULE._serialized_end=2945 + _STATEMENTVALUES._serialized_start=2947 + _STATEMENTVALUES._serialized_end=2992 + _STATEMENT._serialized_start=2994 + _STATEMENT._serialized_end=3074 + _EXPRESSIONS._serialized_start=3076 + _EXPRESSIONS._serialized_end=3129 + _FWRULE._serialized_start=3132 + _FWRULE._serialized_end=3346 + _FWCHAIN._serialized_start=3349 + _FWCHAIN._serialized_end=3498 + _FWCHAINS._serialized_start=3500 + _FWCHAINS._serialized_end=3577 + _SYSFIREWALL._serialized_start=3579 + _SYSFIREWALL._serialized_end=3667 + _CLIENTCONFIG._serialized_start=3670 + _CLIENTCONFIG._serialized_end=3866 + _NOTIFICATION._serialized_start=3869 + _NOTIFICATION._serialized_end=4056 + _NOTIFICATIONREPLY._serialized_start=4058 + _NOTIFICATIONREPLY._serialized_end=4150 + _UI._serialized_start=4477 + _UI._serialized_end=4780 # @@protoc_insertion_point(module_scope) diff --git a/ui/opensnitch/proto/ui_pb2.pyi b/ui/opensnitch/proto/ui_pb2.pyi deleted file mode 100644 index afacfc05b8..0000000000 --- a/ui/opensnitch/proto/ui_pb2.pyi +++ /dev/null @@ -1,494 +0,0 @@ -from google.protobuf.internal import containers as _containers -from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from collections.abc import Iterable as _Iterable, Mapping as _Mapping -from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union - -DESCRIPTOR: _descriptor.FileDescriptor - -class Action(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - NONE: _ClassVar[Action] - ENABLE_INTERCEPTION: _ClassVar[Action] - DISABLE_INTERCEPTION: _ClassVar[Action] - ENABLE_FIREWALL: _ClassVar[Action] - DISABLE_FIREWALL: _ClassVar[Action] - RELOAD_FW_RULES: _ClassVar[Action] - CHANGE_CONFIG: _ClassVar[Action] - ENABLE_RULE: _ClassVar[Action] - DISABLE_RULE: _ClassVar[Action] - DELETE_RULE: _ClassVar[Action] - CHANGE_RULE: _ClassVar[Action] - LOG_LEVEL: _ClassVar[Action] - STOP: _ClassVar[Action] - TASK_START: _ClassVar[Action] - TASK_STOP: _ClassVar[Action] - -class NotificationReplyCode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - OK: _ClassVar[NotificationReplyCode] - ERROR: _ClassVar[NotificationReplyCode] -NONE: Action -ENABLE_INTERCEPTION: Action -DISABLE_INTERCEPTION: Action -ENABLE_FIREWALL: Action -DISABLE_FIREWALL: Action -RELOAD_FW_RULES: Action -CHANGE_CONFIG: Action -ENABLE_RULE: Action -DISABLE_RULE: Action -DELETE_RULE: Action -CHANGE_RULE: Action -LOG_LEVEL: Action -STOP: Action -TASK_START: Action -TASK_STOP: Action -OK: NotificationReplyCode -ERROR: NotificationReplyCode - -class Alert(_message.Message): - __slots__ = () - class Priority(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - LOW: _ClassVar[Alert.Priority] - MEDIUM: _ClassVar[Alert.Priority] - HIGH: _ClassVar[Alert.Priority] - LOW: Alert.Priority - MEDIUM: Alert.Priority - HIGH: Alert.Priority - class Type(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - ERROR: _ClassVar[Alert.Type] - WARNING: _ClassVar[Alert.Type] - INFO: _ClassVar[Alert.Type] - ERROR: Alert.Type - WARNING: Alert.Type - INFO: Alert.Type - class Action(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - NONE: _ClassVar[Alert.Action] - SHOW_ALERT: _ClassVar[Alert.Action] - SAVE_TO_DB: _ClassVar[Alert.Action] - NONE: Alert.Action - SHOW_ALERT: Alert.Action - SAVE_TO_DB: Alert.Action - class What(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - GENERIC: _ClassVar[Alert.What] - PROC_MONITOR: _ClassVar[Alert.What] - FIREWALL: _ClassVar[Alert.What] - CONNECTION: _ClassVar[Alert.What] - RULE: _ClassVar[Alert.What] - NETLINK: _ClassVar[Alert.What] - KERNEL_EVENT: _ClassVar[Alert.What] - GENERIC: Alert.What - PROC_MONITOR: Alert.What - FIREWALL: Alert.What - CONNECTION: Alert.What - RULE: Alert.What - NETLINK: Alert.What - KERNEL_EVENT: Alert.What - ID_FIELD_NUMBER: _ClassVar[int] - TYPE_FIELD_NUMBER: _ClassVar[int] - ACTION_FIELD_NUMBER: _ClassVar[int] - PRIORITY_FIELD_NUMBER: _ClassVar[int] - WHAT_FIELD_NUMBER: _ClassVar[int] - TEXT_FIELD_NUMBER: _ClassVar[int] - PROC_FIELD_NUMBER: _ClassVar[int] - CONN_FIELD_NUMBER: _ClassVar[int] - RULE_FIELD_NUMBER: _ClassVar[int] - FWRULE_FIELD_NUMBER: _ClassVar[int] - id: int - type: Alert.Type - action: Alert.Action - priority: Alert.Priority - what: Alert.What - text: str - proc: Process - conn: Connection - rule: Rule - fwrule: FwRule - def __init__(self, id: _Optional[int] = ..., type: _Optional[_Union[Alert.Type, str]] = ..., action: _Optional[_Union[Alert.Action, str]] = ..., priority: _Optional[_Union[Alert.Priority, str]] = ..., what: _Optional[_Union[Alert.What, str]] = ..., text: _Optional[str] = ..., proc: _Optional[_Union[Process, _Mapping]] = ..., conn: _Optional[_Union[Connection, _Mapping]] = ..., rule: _Optional[_Union[Rule, _Mapping]] = ..., fwrule: _Optional[_Union[FwRule, _Mapping]] = ...) -> None: ... - -class MsgResponse(_message.Message): - __slots__ = () - ID_FIELD_NUMBER: _ClassVar[int] - id: int - def __init__(self, id: _Optional[int] = ...) -> None: ... - -class Event(_message.Message): - __slots__ = () - TIME_FIELD_NUMBER: _ClassVar[int] - CONNECTION_FIELD_NUMBER: _ClassVar[int] - RULE_FIELD_NUMBER: _ClassVar[int] - UNIXNANO_FIELD_NUMBER: _ClassVar[int] - time: str - connection: Connection - rule: Rule - unixnano: int - def __init__(self, time: _Optional[str] = ..., connection: _Optional[_Union[Connection, _Mapping]] = ..., rule: _Optional[_Union[Rule, _Mapping]] = ..., unixnano: _Optional[int] = ...) -> None: ... - -class Statistics(_message.Message): - __slots__ = () - class ByProtoEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: int - def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... - class ByAddressEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: int - def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... - class ByHostEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: int - def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... - class ByPortEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: int - def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... - class ByUidEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: int - def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... - class ByExecutableEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: int - def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... - DAEMON_VERSION_FIELD_NUMBER: _ClassVar[int] - RULES_FIELD_NUMBER: _ClassVar[int] - UPTIME_FIELD_NUMBER: _ClassVar[int] - DNS_RESPONSES_FIELD_NUMBER: _ClassVar[int] - CONNECTIONS_FIELD_NUMBER: _ClassVar[int] - IGNORED_FIELD_NUMBER: _ClassVar[int] - ACCEPTED_FIELD_NUMBER: _ClassVar[int] - DROPPED_FIELD_NUMBER: _ClassVar[int] - RULE_HITS_FIELD_NUMBER: _ClassVar[int] - RULE_MISSES_FIELD_NUMBER: _ClassVar[int] - BY_PROTO_FIELD_NUMBER: _ClassVar[int] - BY_ADDRESS_FIELD_NUMBER: _ClassVar[int] - BY_HOST_FIELD_NUMBER: _ClassVar[int] - BY_PORT_FIELD_NUMBER: _ClassVar[int] - BY_UID_FIELD_NUMBER: _ClassVar[int] - BY_EXECUTABLE_FIELD_NUMBER: _ClassVar[int] - EVENTS_FIELD_NUMBER: _ClassVar[int] - daemon_version: str - rules: int - uptime: int - dns_responses: int - connections: int - ignored: int - accepted: int - dropped: int - rule_hits: int - rule_misses: int - by_proto: _containers.ScalarMap[str, int] - by_address: _containers.ScalarMap[str, int] - by_host: _containers.ScalarMap[str, int] - by_port: _containers.ScalarMap[str, int] - by_uid: _containers.ScalarMap[str, int] - by_executable: _containers.ScalarMap[str, int] - events: _containers.RepeatedCompositeFieldContainer[Event] - def __init__(self, daemon_version: _Optional[str] = ..., rules: _Optional[int] = ..., uptime: _Optional[int] = ..., dns_responses: _Optional[int] = ..., connections: _Optional[int] = ..., ignored: _Optional[int] = ..., accepted: _Optional[int] = ..., dropped: _Optional[int] = ..., rule_hits: _Optional[int] = ..., rule_misses: _Optional[int] = ..., by_proto: _Optional[_Mapping[str, int]] = ..., by_address: _Optional[_Mapping[str, int]] = ..., by_host: _Optional[_Mapping[str, int]] = ..., by_port: _Optional[_Mapping[str, int]] = ..., by_uid: _Optional[_Mapping[str, int]] = ..., by_executable: _Optional[_Mapping[str, int]] = ..., events: _Optional[_Iterable[_Union[Event, _Mapping]]] = ...) -> None: ... - -class PingRequest(_message.Message): - __slots__ = () - ID_FIELD_NUMBER: _ClassVar[int] - STATS_FIELD_NUMBER: _ClassVar[int] - id: int - stats: Statistics - def __init__(self, id: _Optional[int] = ..., stats: _Optional[_Union[Statistics, _Mapping]] = ...) -> None: ... - -class PingReply(_message.Message): - __slots__ = () - ID_FIELD_NUMBER: _ClassVar[int] - id: int - def __init__(self, id: _Optional[int] = ...) -> None: ... - -class StringInt(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: int - def __init__(self, key: _Optional[str] = ..., value: _Optional[int] = ...) -> None: ... - -class Process(_message.Message): - __slots__ = () - class EnvEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: str - def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - class ChecksumsEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: str - def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - PID_FIELD_NUMBER: _ClassVar[int] - PPID_FIELD_NUMBER: _ClassVar[int] - UID_FIELD_NUMBER: _ClassVar[int] - COMM_FIELD_NUMBER: _ClassVar[int] - PATH_FIELD_NUMBER: _ClassVar[int] - ARGS_FIELD_NUMBER: _ClassVar[int] - ENV_FIELD_NUMBER: _ClassVar[int] - CWD_FIELD_NUMBER: _ClassVar[int] - CHECKSUMS_FIELD_NUMBER: _ClassVar[int] - IO_READS_FIELD_NUMBER: _ClassVar[int] - IO_WRITES_FIELD_NUMBER: _ClassVar[int] - NET_READS_FIELD_NUMBER: _ClassVar[int] - NET_WRITES_FIELD_NUMBER: _ClassVar[int] - PROCESS_TREE_FIELD_NUMBER: _ClassVar[int] - pid: int - ppid: int - uid: int - comm: str - path: str - args: _containers.RepeatedScalarFieldContainer[str] - env: _containers.ScalarMap[str, str] - cwd: str - checksums: _containers.ScalarMap[str, str] - io_reads: int - io_writes: int - net_reads: int - net_writes: int - process_tree: _containers.RepeatedCompositeFieldContainer[StringInt] - def __init__(self, pid: _Optional[int] = ..., ppid: _Optional[int] = ..., uid: _Optional[int] = ..., comm: _Optional[str] = ..., path: _Optional[str] = ..., args: _Optional[_Iterable[str]] = ..., env: _Optional[_Mapping[str, str]] = ..., cwd: _Optional[str] = ..., checksums: _Optional[_Mapping[str, str]] = ..., io_reads: _Optional[int] = ..., io_writes: _Optional[int] = ..., net_reads: _Optional[int] = ..., net_writes: _Optional[int] = ..., process_tree: _Optional[_Iterable[_Union[StringInt, _Mapping]]] = ...) -> None: ... - -class Connection(_message.Message): - __slots__ = () - class ProcessEnvEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: str - def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - class ProcessChecksumsEntry(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: str - def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - PROTOCOL_FIELD_NUMBER: _ClassVar[int] - SRC_IP_FIELD_NUMBER: _ClassVar[int] - SRC_PORT_FIELD_NUMBER: _ClassVar[int] - DST_IP_FIELD_NUMBER: _ClassVar[int] - DST_HOST_FIELD_NUMBER: _ClassVar[int] - DST_PORT_FIELD_NUMBER: _ClassVar[int] - USER_ID_FIELD_NUMBER: _ClassVar[int] - PROCESS_ID_FIELD_NUMBER: _ClassVar[int] - PROCESS_PATH_FIELD_NUMBER: _ClassVar[int] - PROCESS_CWD_FIELD_NUMBER: _ClassVar[int] - PROCESS_ARGS_FIELD_NUMBER: _ClassVar[int] - PROCESS_ENV_FIELD_NUMBER: _ClassVar[int] - PROCESS_CHECKSUMS_FIELD_NUMBER: _ClassVar[int] - PROCESS_TREE_FIELD_NUMBER: _ClassVar[int] - protocol: str - src_ip: str - src_port: int - dst_ip: str - dst_host: str - dst_port: int - user_id: int - process_id: int - process_path: str - process_cwd: str - process_args: _containers.RepeatedScalarFieldContainer[str] - process_env: _containers.ScalarMap[str, str] - process_checksums: _containers.ScalarMap[str, str] - process_tree: _containers.RepeatedCompositeFieldContainer[StringInt] - def __init__(self, protocol: _Optional[str] = ..., src_ip: _Optional[str] = ..., src_port: _Optional[int] = ..., dst_ip: _Optional[str] = ..., dst_host: _Optional[str] = ..., dst_port: _Optional[int] = ..., user_id: _Optional[int] = ..., process_id: _Optional[int] = ..., process_path: _Optional[str] = ..., process_cwd: _Optional[str] = ..., process_args: _Optional[_Iterable[str]] = ..., process_env: _Optional[_Mapping[str, str]] = ..., process_checksums: _Optional[_Mapping[str, str]] = ..., process_tree: _Optional[_Iterable[_Union[StringInt, _Mapping]]] = ...) -> None: ... - -class Operator(_message.Message): - __slots__ = () - TYPE_FIELD_NUMBER: _ClassVar[int] - OPERAND_FIELD_NUMBER: _ClassVar[int] - DATA_FIELD_NUMBER: _ClassVar[int] - SENSITIVE_FIELD_NUMBER: _ClassVar[int] - LIST_FIELD_NUMBER: _ClassVar[int] - type: str - operand: str - data: str - sensitive: bool - list: _containers.RepeatedCompositeFieldContainer[Operator] - def __init__(self, type: _Optional[str] = ..., operand: _Optional[str] = ..., data: _Optional[str] = ..., sensitive: _Optional[bool] = ..., list: _Optional[_Iterable[_Union[Operator, _Mapping]]] = ...) -> None: ... - -class Rule(_message.Message): - __slots__ = () - CREATED_FIELD_NUMBER: _ClassVar[int] - NAME_FIELD_NUMBER: _ClassVar[int] - DESCRIPTION_FIELD_NUMBER: _ClassVar[int] - ENABLED_FIELD_NUMBER: _ClassVar[int] - PRECEDENCE_FIELD_NUMBER: _ClassVar[int] - NOLOG_FIELD_NUMBER: _ClassVar[int] - ACTION_FIELD_NUMBER: _ClassVar[int] - DURATION_FIELD_NUMBER: _ClassVar[int] - OPERATOR_FIELD_NUMBER: _ClassVar[int] - created: int - name: str - description: str - enabled: bool - precedence: bool - nolog: bool - action: str - duration: str - operator: Operator - def __init__(self, created: _Optional[int] = ..., name: _Optional[str] = ..., description: _Optional[str] = ..., enabled: _Optional[bool] = ..., precedence: _Optional[bool] = ..., nolog: _Optional[bool] = ..., action: _Optional[str] = ..., duration: _Optional[str] = ..., operator: _Optional[_Union[Operator, _Mapping]] = ...) -> None: ... - -class StatementValues(_message.Message): - __slots__ = () - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - Key: str - Value: str - def __init__(self, Key: _Optional[str] = ..., Value: _Optional[str] = ...) -> None: ... - -class Statement(_message.Message): - __slots__ = () - OP_FIELD_NUMBER: _ClassVar[int] - NAME_FIELD_NUMBER: _ClassVar[int] - VALUES_FIELD_NUMBER: _ClassVar[int] - Op: str - Name: str - Values: _containers.RepeatedCompositeFieldContainer[StatementValues] - def __init__(self, Op: _Optional[str] = ..., Name: _Optional[str] = ..., Values: _Optional[_Iterable[_Union[StatementValues, _Mapping]]] = ...) -> None: ... - -class Expressions(_message.Message): - __slots__ = () - STATEMENT_FIELD_NUMBER: _ClassVar[int] - Statement: Statement - def __init__(self, Statement: _Optional[_Union[Statement, _Mapping]] = ...) -> None: ... - -class FwRule(_message.Message): - __slots__ = () - TABLE_FIELD_NUMBER: _ClassVar[int] - CHAIN_FIELD_NUMBER: _ClassVar[int] - UUID_FIELD_NUMBER: _ClassVar[int] - ENABLED_FIELD_NUMBER: _ClassVar[int] - POSITION_FIELD_NUMBER: _ClassVar[int] - DESCRIPTION_FIELD_NUMBER: _ClassVar[int] - PARAMETERS_FIELD_NUMBER: _ClassVar[int] - EXPRESSIONS_FIELD_NUMBER: _ClassVar[int] - TARGET_FIELD_NUMBER: _ClassVar[int] - TARGETPARAMETERS_FIELD_NUMBER: _ClassVar[int] - Table: str - Chain: str - UUID: str - Enabled: bool - Position: int - Description: str - Parameters: str - Expressions: _containers.RepeatedCompositeFieldContainer[Expressions] - Target: str - TargetParameters: str - def __init__(self, Table: _Optional[str] = ..., Chain: _Optional[str] = ..., UUID: _Optional[str] = ..., Enabled: _Optional[bool] = ..., Position: _Optional[int] = ..., Description: _Optional[str] = ..., Parameters: _Optional[str] = ..., Expressions: _Optional[_Iterable[_Union[Expressions, _Mapping]]] = ..., Target: _Optional[str] = ..., TargetParameters: _Optional[str] = ...) -> None: ... - -class FwChain(_message.Message): - __slots__ = () - NAME_FIELD_NUMBER: _ClassVar[int] - TABLE_FIELD_NUMBER: _ClassVar[int] - FAMILY_FIELD_NUMBER: _ClassVar[int] - PRIORITY_FIELD_NUMBER: _ClassVar[int] - TYPE_FIELD_NUMBER: _ClassVar[int] - HOOK_FIELD_NUMBER: _ClassVar[int] - POLICY_FIELD_NUMBER: _ClassVar[int] - RULES_FIELD_NUMBER: _ClassVar[int] - Name: str - Table: str - Family: str - Priority: str - Type: str - Hook: str - Policy: str - Rules: _containers.RepeatedCompositeFieldContainer[FwRule] - def __init__(self, Name: _Optional[str] = ..., Table: _Optional[str] = ..., Family: _Optional[str] = ..., Priority: _Optional[str] = ..., Type: _Optional[str] = ..., Hook: _Optional[str] = ..., Policy: _Optional[str] = ..., Rules: _Optional[_Iterable[_Union[FwRule, _Mapping]]] = ...) -> None: ... - -class FwChains(_message.Message): - __slots__ = () - RULE_FIELD_NUMBER: _ClassVar[int] - CHAINS_FIELD_NUMBER: _ClassVar[int] - Rule: FwRule - Chains: _containers.RepeatedCompositeFieldContainer[FwChain] - def __init__(self, Rule: _Optional[_Union[FwRule, _Mapping]] = ..., Chains: _Optional[_Iterable[_Union[FwChain, _Mapping]]] = ...) -> None: ... - -class SysFirewall(_message.Message): - __slots__ = () - ENABLED_FIELD_NUMBER: _ClassVar[int] - VERSION_FIELD_NUMBER: _ClassVar[int] - SYSTEMRULES_FIELD_NUMBER: _ClassVar[int] - Enabled: bool - Version: int - SystemRules: _containers.RepeatedCompositeFieldContainer[FwChains] - def __init__(self, Enabled: _Optional[bool] = ..., Version: _Optional[int] = ..., SystemRules: _Optional[_Iterable[_Union[FwChains, _Mapping]]] = ...) -> None: ... - -class ClientConfig(_message.Message): - __slots__ = () - ID_FIELD_NUMBER: _ClassVar[int] - NAME_FIELD_NUMBER: _ClassVar[int] - VERSION_FIELD_NUMBER: _ClassVar[int] - ISFIREWALLRUNNING_FIELD_NUMBER: _ClassVar[int] - CONFIG_FIELD_NUMBER: _ClassVar[int] - LOGLEVEL_FIELD_NUMBER: _ClassVar[int] - RULES_FIELD_NUMBER: _ClassVar[int] - SYSTEMFIREWALL_FIELD_NUMBER: _ClassVar[int] - id: int - name: str - version: str - isFirewallRunning: bool - config: str - logLevel: int - rules: _containers.RepeatedCompositeFieldContainer[Rule] - systemFirewall: SysFirewall - def __init__(self, id: _Optional[int] = ..., name: _Optional[str] = ..., version: _Optional[str] = ..., isFirewallRunning: _Optional[bool] = ..., config: _Optional[str] = ..., logLevel: _Optional[int] = ..., rules: _Optional[_Iterable[_Union[Rule, _Mapping]]] = ..., systemFirewall: _Optional[_Union[SysFirewall, _Mapping]] = ...) -> None: ... - -class Notification(_message.Message): - __slots__ = () - ID_FIELD_NUMBER: _ClassVar[int] - CLIENTNAME_FIELD_NUMBER: _ClassVar[int] - SERVERNAME_FIELD_NUMBER: _ClassVar[int] - TYPE_FIELD_NUMBER: _ClassVar[int] - DATA_FIELD_NUMBER: _ClassVar[int] - RULES_FIELD_NUMBER: _ClassVar[int] - SYSFIREWALL_FIELD_NUMBER: _ClassVar[int] - id: int - clientName: str - serverName: str - type: Action - data: str - rules: _containers.RepeatedCompositeFieldContainer[Rule] - sysFirewall: SysFirewall - def __init__(self, id: _Optional[int] = ..., clientName: _Optional[str] = ..., serverName: _Optional[str] = ..., type: _Optional[_Union[Action, str]] = ..., data: _Optional[str] = ..., rules: _Optional[_Iterable[_Union[Rule, _Mapping]]] = ..., sysFirewall: _Optional[_Union[SysFirewall, _Mapping]] = ...) -> None: ... - -class NotificationReply(_message.Message): - __slots__ = () - ID_FIELD_NUMBER: _ClassVar[int] - CODE_FIELD_NUMBER: _ClassVar[int] - DATA_FIELD_NUMBER: _ClassVar[int] - id: int - code: NotificationReplyCode - data: str - def __init__(self, id: _Optional[int] = ..., code: _Optional[_Union[NotificationReplyCode, str]] = ..., data: _Optional[str] = ...) -> None: ... diff --git a/ui/opensnitch/proto/ui_pb2_grpc.py b/ui/opensnitch/proto/ui_pb2_grpc.py index 4ad9187261..4e3a786551 100644 --- a/ui/opensnitch/proto/ui_pb2_grpc.py +++ b/ui/opensnitch/proto/ui_pb2_grpc.py @@ -1,28 +1,8 @@ # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! """Client and server classes corresponding to protobuf-defined services.""" import grpc -import warnings -import ui_pb2 as ui__pb2 - -GRPC_GENERATED_VERSION = '1.76.0' -GRPC_VERSION = grpc.__version__ -_version_not_supported = False - -try: - from grpc._utilities import first_version_is_lower - _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) -except ImportError: - _version_not_supported = True - -if _version_not_supported: - raise RuntimeError( - f'The grpc package installed is at version {GRPC_VERSION},' - + ' but the generated code in ui_pb2_grpc.py depends on' - + f' grpcio>={GRPC_GENERATED_VERSION}.' - + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' - + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' - ) +from . import ui_pb2 as ui__pb2 class UIStub(object): @@ -38,27 +18,27 @@ def __init__(self, channel): '/protocol.UI/Ping', request_serializer=ui__pb2.PingRequest.SerializeToString, response_deserializer=ui__pb2.PingReply.FromString, - _registered_method=True) + ) self.AskRule = channel.unary_unary( '/protocol.UI/AskRule', request_serializer=ui__pb2.Connection.SerializeToString, response_deserializer=ui__pb2.Rule.FromString, - _registered_method=True) + ) self.Subscribe = channel.unary_unary( '/protocol.UI/Subscribe', request_serializer=ui__pb2.ClientConfig.SerializeToString, response_deserializer=ui__pb2.ClientConfig.FromString, - _registered_method=True) + ) self.Notifications = channel.stream_stream( '/protocol.UI/Notifications', request_serializer=ui__pb2.NotificationReply.SerializeToString, response_deserializer=ui__pb2.Notification.FromString, - _registered_method=True) + ) self.PostAlert = channel.unary_unary( '/protocol.UI/PostAlert', request_serializer=ui__pb2.Alert.SerializeToString, response_deserializer=ui__pb2.MsgResponse.FromString, - _registered_method=True) + ) class UIServicer(object): @@ -126,7 +106,6 @@ def add_UIServicer_to_server(servicer, server): generic_handler = grpc.method_handlers_generic_handler( 'protocol.UI', rpc_method_handlers) server.add_generic_rpc_handlers((generic_handler,)) - server.add_registered_method_handlers('protocol.UI', rpc_method_handlers) # This class is part of an EXPERIMENTAL API. @@ -144,21 +123,11 @@ def Ping(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary( - request, - target, - '/protocol.UI/Ping', + return grpc.experimental.unary_unary(request, target, '/protocol.UI/Ping', ui__pb2.PingRequest.SerializeToString, ui__pb2.PingReply.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @staticmethod def AskRule(request, @@ -171,21 +140,11 @@ def AskRule(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary( - request, - target, - '/protocol.UI/AskRule', + return grpc.experimental.unary_unary(request, target, '/protocol.UI/AskRule', ui__pb2.Connection.SerializeToString, ui__pb2.Rule.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @staticmethod def Subscribe(request, @@ -198,21 +157,11 @@ def Subscribe(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary( - request, - target, - '/protocol.UI/Subscribe', + return grpc.experimental.unary_unary(request, target, '/protocol.UI/Subscribe', ui__pb2.ClientConfig.SerializeToString, ui__pb2.ClientConfig.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @staticmethod def Notifications(request_iterator, @@ -225,21 +174,11 @@ def Notifications(request_iterator, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.stream_stream( - request_iterator, - target, - '/protocol.UI/Notifications', + return grpc.experimental.stream_stream(request_iterator, target, '/protocol.UI/Notifications', ui__pb2.NotificationReply.SerializeToString, ui__pb2.Notification.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) @staticmethod def PostAlert(request, @@ -252,18 +191,8 @@ def PostAlert(request, wait_for_ready=None, timeout=None, metadata=None): - return grpc.experimental.unary_unary( - request, - target, - '/protocol.UI/PostAlert', + return grpc.experimental.unary_unary(request, target, '/protocol.UI/PostAlert', ui__pb2.Alert.SerializeToString, ui__pb2.MsgResponse.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) From 89708b64ab06e982184809faa3a3b7d7218c3343 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Wed, 11 Mar 2026 17:04:09 +0100 Subject: [PATCH 10/13] UI overhaul --- .../plugins/list_subscriptions/_models.py | 602 ----- .../plugins/list_subscriptions/_utils.py | 371 +-- .../plugins/list_subscriptions/io/__init__.py | 1 + .../plugins/list_subscriptions/io/lock.py | 101 + .../plugins/list_subscriptions/io/storage.py | 59 + .../list_subscriptions/list_subscriptions.py | 796 +++++- .../list_subscriptions/models/__init__.py | 0 .../list_subscriptions/models/action.py | 92 + .../list_subscriptions/models/config.py | 132 + .../list_subscriptions/models/events.py | 16 + .../models/global_defaults.py | 42 + .../list_subscriptions/models/metadata.py | 51 + .../models/subscriptions.py | 284 +++ .../res/bulk_edit_dialog.ui | 192 +- .../res/list_subscriptions_dialog.ui | 433 ++-- .../res/subscription_dialog.ui | 198 +- .../plugins/list_subscriptions/ui/__init__.py | 1 + .../list_subscriptions/ui/bulk_edit_dialog.py | 377 +++ .../plugins/list_subscriptions/ui/helpers.py | 171 ++ .../list_subscriptions_dialog.py} | 2204 ++++++++--------- .../ui/subscription_dialog.py | 595 +++++ .../ui/toggle_switch_widget.py | 272 ++ 22 files changed, 4626 insertions(+), 2364 deletions(-) delete mode 100644 ui/opensnitch/plugins/list_subscriptions/_models.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/io/__init__.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/io/lock.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/io/storage.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/models/__init__.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/models/action.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/models/config.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/models/events.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/models/global_defaults.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/models/metadata.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/models/subscriptions.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/__init__.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/helpers.py rename ui/opensnitch/plugins/list_subscriptions/{_gui.py => ui/list_subscriptions_dialog.py} (56%) create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/toggle_switch_widget.py diff --git a/ui/opensnitch/plugins/list_subscriptions/_models.py b/ui/opensnitch/plugins/list_subscriptions/_models.py deleted file mode 100644 index 8a3ffef3ed..0000000000 --- a/ui/opensnitch/plugins/list_subscriptions/_models.py +++ /dev/null @@ -1,602 +0,0 @@ -from dataclasses import dataclass, field, asdict, replace -from typing import Any, TypeVar -from collections.abc import Callable - -from opensnitch.plugins.list_subscriptions._utils import ( - dedupe_subscription_identity, - derive_filename, - ensure_filename_type_suffix, - normalize_groups, - normalize_lists_dir, - now_iso, - normalize_iso_timestamp, - opt_int, - opt_str, - parse_compact_duration, - safe_filename, - to_seconds, - to_max_bytes, -) - - -DEFAULT_UA = "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0" - -DEFAULT_NOTIFY_CONFIG = { - "success": {"desktop": "Lists subscriptions updated"}, - "error": {"desktop": "Error updating lists subscriptions"}, -} - -SubscriptionLike = TypeVar( - "SubscriptionLike", "SubscriptionSpec", "MutableSubscriptionSpec" -) - - -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 - - -@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), - ) - - -@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 - - -@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, - } - - -@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(), - } - }, - } - - -@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) diff --git a/ui/opensnitch/plugins/list_subscriptions/_utils.py b/ui/opensnitch/plugins/list_subscriptions/_utils.py index b596964076..ddc0684848 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_utils.py +++ b/ui/opensnitch/plugins/list_subscriptions/_utils.py @@ -1,17 +1,30 @@ -import errno -import json import os import re -import time -from enum import IntEnum from datetime import datetime -from typing import Any +from typing import Any, Final from urllib.parse import urlparse, unquote from opensnitch.utils.xdg import xdg_config_home - -TIME_MULT = { +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, @@ -23,28 +36,28 @@ "d": 24 * 60 * 60, "w": 7 * 24 * 60 * 60, } -SHORT_TIME_MULT = { +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 = { +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" +) -class RuntimeEvent(IntEnum): - RUNTIME_ENABLED = 1 - CONFIG_RELOADED = 2 - RUNTIME_DISABLED = 3 - RUNTIME_STOPPED = 4 - RUNTIME_ERROR = 5 +DEFAULT_NOTIFY_CONFIG: Final[dict[str, dict[str, str]]] = { + "success": {"desktop": "Lists subscriptions updated"}, + "error": {"desktop": "Error updating lists subscriptions"}, +} def now_iso(): @@ -84,7 +97,18 @@ def opt_str(value: Any): return None -def normalize_lists_dir(path: str | None) -> str: +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 == "": @@ -95,11 +119,20 @@ def normalize_lists_dir(path: str | None) -> str: return expanded -def safe_filename(value: Any) -> str: +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 filename_from_url(url: str | None) -> str: +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 "")) @@ -107,7 +140,7 @@ def filename_from_url(url: str | None) -> str: return "" -def slugify_name(name: str | None) -> str: +def slugify_name(name: str | None): raw = (name or "").strip().lower() if raw == "": return "subscription.list" @@ -119,7 +152,55 @@ def slugify_name(name: str | None) -> str: return safe_filename(slug) -def derive_filename(name: str | None, url: str | None, filename: str | None) -> str: +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 @@ -129,10 +210,10 @@ def derive_filename(name: str | None, url: str | None, filename: str | None) -> return slugify_name(name) -def ensure_filename_type_suffix(filename: str, list_type: str) -> str: +def ensure_filename_type_suffix(filename: str, list_type: str): fn = safe_filename(filename) base, ext = os.path.splitext(fn) - ltype = (list_type or "hosts").strip().lower() + ltype = normalized_list_type(list_type) suffix = f"-{ltype}" if not base.lower().endswith(suffix): base = f"{base}{suffix}" if base else ltype @@ -141,7 +222,106 @@ def ensure_filename_type_suffix(filename: str, list_type: str) -> str: return safe_filename(f"{base}{ext}") -def normalize_group(group: str | None) -> str: +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 subscription_event_item( + key: str, + *, + name: str, + url: str, + filename: str, + list_type: str, + state: str | None = None, + path: str | None = None, +): + item: dict[str, Any] = { + "key": key, + "name": name, + "url": url, + "filename": filename, + "format": list_type, + } + if state: + item["state"] = state + if path: + item["path"] = path + return item + + +def subscription_payload_dict( + *, + enabled: bool, + name: str, + url: str, + filename: str, + list_type: 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, +): + return { + "enabled": enabled, + "name": name, + "url": url, + "filename": filename, + "format": list_type, + "groups": groups, + "interval": interval, + "interval_units": interval_units, + "timeout": timeout, + "timeout_units": timeout_units, + "max_size": max_size, + "max_size_units": max_size_units, + } + + +def normalize_group(group: str | None): raw = (group or "").strip().lower() if raw == "": return "" @@ -149,7 +329,7 @@ def normalize_group(group: str | None) -> str: return raw -def normalize_groups(groups: Any) -> list[str]: +def normalize_groups(groups: Any): out: list[str] = [] if isinstance(groups, (list, tuple, set)): raw_items = [str(x) for x in groups] @@ -186,7 +366,7 @@ def dedupe_subscription_identity( base, ext = os.path.splitext(filename) if ext == "": ext = ".txt" - suffix = f"-{(list_type or 'hosts').strip().lower()}" + suffix = f"-{normalized_list_type(list_type)}" root = base if root.lower().endswith(suffix): root = root[: -len(suffix)] @@ -250,145 +430,6 @@ def to_max_bytes(value: Any, units: str | None, default_bytes: int): return default_bytes -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) -> 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() - - -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: - stat = os.stat(self.lock_path) - except FileNotFoundError: - return False - - pid = self._read_owner_pid() - if pid is None: - return False - if pid > 0: - return not self._pid_is_alive(pid) - - age = time.time() - stat.st_mtime - return age >= max(max_age, 0.0) - - 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): - try: - self.fd = os.open( - self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600 - ) - os.write(self.fd, str(os.getpid()).encode("utf-8")) - return True - except OSError as e: - if e.errno == errno.EEXIST: - if self.break_stale(): - return self.acquire() - 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 - - def is_hosts_file_like(sample_lines: list[str]): valid = 0 total = 0 diff --git a/ui/opensnitch/plugins/list_subscriptions/io/__init__.py b/ui/opensnitch/plugins/list_subscriptions/io/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/io/__init__.py @@ -0,0 +1 @@ + diff --git a/ui/opensnitch/plugins/list_subscriptions/io/lock.py b/ui/opensnitch/plugins/list_subscriptions/io/lock.py new file mode 100644 index 0000000000..341df16fb3 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/io/lock.py @@ -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 \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/io/storage.py b/ui/opensnitch/plugins/list_subscriptions/io/storage.py new file mode 100644 index 0000000000..38c65a9ee7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/io/storage.py @@ -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() diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index 8cc15fe6ac..f1da7cedb0 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -4,28 +4,34 @@ import threading import shutil import sys -from typing import Any +from typing import Any, ClassVar, Final from abc import ABCMeta from datetime import datetime, timedelta from queue import Queue - import requests -from opensnitch.proto import ui_pb2 if "PyQt6" in sys.modules: - from PyQt6 import QtCore, QtGui + from PyQt6 import QtCore, QtGui, QtWidgets elif "PyQt5" in sys.modules: - from PyQt5 import QtCore, QtGui + from PyQt5 import QtCore, QtGui, QtWidgets else: try: - from PyQt6 import QtCore, QtGui + from PyQt6 import QtCore, QtGui, QtWidgets except Exception: - from PyQt5 import QtCore, QtGui + from PyQt5 import QtCore, QtGui, QtWidgets try: from opensnitch.dialogs.events import StatsDialog except ImportError: - from opensnitch.dialogs.stats import StatsDialog + from opensnitch.dialogs.stats import StatsDialog # type: ignore + +from opensnitch.plugins.list_subscriptions.io.lock import FileLock +from opensnitch.plugins.list_subscriptions.io.storage import read_json_locked +from opensnitch.plugins.list_subscriptions.models.config import PluginConfig +from opensnitch.plugins.list_subscriptions.models.events import RuntimeEvent +from opensnitch.plugins.list_subscriptions.models.metadata import ListMetadata +from opensnitch.plugins.list_subscriptions.models.subscriptions import SubscriptionSpec +from opensnitch.proto import ui_pb2 from opensnitch.config import Config from opensnitch.nodes import Nodes from opensnitch.notifications import DesktopNotifications @@ -33,31 +39,30 @@ from opensnitch.rules import Rule from opensnitch.database import Database from opensnitch.utils import GenericTimer -from opensnitch.utils.xdg import xdg_config_home -from opensnitch.plugins.list_subscriptions._models import ( - DEFAULT_UA, - ListMetadata, - PluginConfig, - SubscriptionSpec, -) from opensnitch.plugins.list_subscriptions._utils import ( - FileLock, - RuntimeEvent, - ensure_filename_type_suffix, + ACTION_FILE, + DEFAULT_LISTS_DIR, + DEFAULT_UA, is_hosts_file_like, + list_file_path, normalize_groups, normalize_lists_dir, now_iso, parse_iso, - read_json_locked, + subscription_dirname, + subscription_event_item, +) +from opensnitch.plugins.list_subscriptions.io.storage import ( write_json_atomic_locked, ) -ch = logging.StreamHandler() +ch: Final[logging.StreamHandler] = logging.StreamHandler() # ch.setLevel(logging.ERROR) -formatter = logging.Formatter("%(asctime)s - %(name)s - [%(levelname)s] %(message)s") +formatter: Final[logging.Formatter] = logging.Formatter( + "%(asctime)s - %(name)s - [%(levelname)s] %(message)s" +) ch.setFormatter(formatter) -logger = logging.getLogger(__name__) +logger: Final[logging.Logger] = logging.getLogger(__name__) logger.addHandler(ch) logger.setLevel(logging.WARNING) @@ -69,7 +74,7 @@ class SingletonABCMeta(ABCMeta): _instances: dict[type, object] = {} _lock = threading.Lock() - def __call__(cls, *args, **kwargs): + def __call__(cls, *args: Any, **kwargs: Any): with cls._lock: if cls not in cls._instances: cls._instances[cls] = super().__call__(*args, **kwargs) @@ -87,25 +92,24 @@ class ListSubscriptions(PluginBase, metaclass=SingletonABCMeta): """ # fields overriden from parent class - name = "List_subscriptions" - version = 0 - author = "opensnitch" - created = "" - modified = "" - enabled = False - description = "Manage list subscriptions (e.g. blocklists) with periodic updates" + name: ClassVar[str] = "List_subscriptions" + version: ClassVar[int] = 0 + author: ClassVar[str] = "opensnitch" + created: ClassVar[str] = "" + modified: ClassVar[str] = "" + enabled: bool = False + description: ClassVar[str] = ( + "Manage list subscriptions (e.g. blocklists) with periodic updates" + ) # default - TYPE = [PluginBase.TYPE_GLOBAL] + TYPE: ClassVar[list[Any]] = [PluginBase.TYPE_GLOBAL] # runtime state scheduled_tasks: dict[str, GenericTimer] = {} - default_conf = "{0}/{1}".format( - xdg_config_home, "opensnitch/actions/list_subscriptions.json" - ) - default_lists_dir = os.path.join( - xdg_config_home, "opensnitch", "list_subscriptions" - ) + default_conf: ClassVar[str] = ACTION_FILE + default_lists_dir: ClassVar[str] = DEFAULT_LISTS_DIR + REFRESH_SUBSCRIPTIONS_SIGNAL: ClassVar[str] = "refresh_subscriptions" @classmethod def get_instance(cls) -> "ListSubscriptions | None": @@ -133,8 +137,9 @@ def __init__(self, config: dict[str, Any] | None = None): self._app_icon = os.path.join( os.path.abspath(os.path.dirname(__file__)), "../../res/icon-white.svg" ) - self._cfg_dialog = None - self._cfg_action = None + self._cfg_dialog: Any = None + self._cfg_action: Any = None + self._cfg_toolbar_button: QtWidgets.QPushButton | None = None self.scheduled_tasks = {} self._startup_recheck_lock = threading.Lock() self._startup_recheck_pending = False @@ -158,6 +163,11 @@ def _emit_runtime_event( *, error: str | None = None, action_path: str | None = None, + target: str | None = None, + path: str | None = None, + source: str | None = None, + state: str | None = None, + items: list[dict[str, Any]] | None = None, ): payload: dict[str, Any] = { "plugin": self.get_name(), @@ -168,6 +178,16 @@ def _emit_runtime_event( payload["action_path"] = action_path if error: payload["error"] = error + if target: + payload["target"] = target + if path: + payload["path"] = path + if source: + payload["source"] = source + if state: + payload["state"] = state + if items: + payload["items"] = items self.signal_out.emit(payload) def _load_action_config(self, action_cfg: dict[str, Any] | None = None): @@ -198,6 +218,40 @@ def _load_action_config(self, action_cfg: dict[str, Any] | None = None): else: self._notify = None + def _config_update_diff_targets( + self, + previous_subscriptions: list[SubscriptionSpec], + ): + previous_enabled_by_key = { + self._sub_key(sub): bool(sub.enabled) for sub in previous_subscriptions + } + targets: list[SubscriptionSpec] = [] + try: + current_subscriptions = list(self._config.subscriptions) + except Exception: + current_subscriptions = [] + for sub in current_subscriptions: + if not sub.enabled: + continue + old_enabled = previous_enabled_by_key.get(self._sub_key(sub)) + if old_enabled is None or old_enabled is False: + targets.append(sub) + return targets + + def _apply_config_update_diff( + self, + previous_subscriptions: list[SubscriptionSpec], + ): + refresh_targets = self._config_update_diff_targets(previous_subscriptions) + if not refresh_targets: + return + th = threading.Thread( + target=self.refresh_subscriptions, + args=(refresh_targets, "config_update"), + daemon=True, + ) + th.start() + def _start_runtime(self, *, recheck: bool): if not self.enabled: return @@ -256,21 +310,34 @@ def _on_nodes_updated(self, total: int): with self._startup_recheck_lock: pending = self._startup_recheck_pending if pending and self._has_ready_local_node(): - logger.warning( - "local node connected, running deferred startup refresh" - ) + logger.warning("local node connected, running deferred startup refresh") self._schedule_startup_recheck(delay=0.5) def _reload_from_action_file(self, action_path: str | None = None): action_path = (action_path or self.default_conf).strip() or self.default_conf try: raw_action = read_json_locked(action_path) + self._emit_runtime_event( + RuntimeEvent.FILE_LOAD_FINISHED, + "Runtime configuration loaded.", + action_path=action_path, + target="action_config", + path=action_path, + ) except Exception as exc: logger.warning( "failed to read action file %s: %r", action_path, exc, ) + self._emit_runtime_event( + RuntimeEvent.FILE_LOAD_ERROR, + "Failed to load runtime configuration.", + error=str(exc), + action_path=action_path, + target="action_config", + path=action_path, + ) return False, str(exc) if not isinstance(raw_action, dict): @@ -304,26 +371,12 @@ def _paths(self, sub: SubscriptionSpec): os.makedirs(lists_dir, mode=0o700, exist_ok=True) sources_dir = os.path.join(lists_dir, "sources.list.d") os.makedirs(sources_dir, mode=0o700, exist_ok=True) - safe_filename = os.path.basename((sub.filename or "").strip()) - if safe_filename == "": - safe_filename = "subscription.list" - safe_filename = ensure_filename_type_suffix(safe_filename, sub.format) - list_path = os.path.join(sources_dir, safe_filename) + list_path = list_file_path(lists_dir, sub.filename, sub.format) meta_path = list_path + ".meta.json" return list_path, meta_path def _subscription_dirname(self, sub: SubscriptionSpec): - safe_filename = os.path.basename((sub.filename or "").strip()) - if safe_filename == "": - safe_filename = "subscription.list" - safe_filename = ensure_filename_type_suffix(safe_filename, sub.format) - base, _ext = os.path.splitext(safe_filename) - list_type = (sub.format or "hosts").strip().lower() - suffix = f"-{list_type}" - sub_dirname = base if base else "subscription" - if not sub_dirname.lower().endswith(suffix): - sub_dirname = f"{sub_dirname}{suffix}" - return sub_dirname + return subscription_dirname(sub.filename, sub.format) def _rules_root_dir(self): if self._config is None: @@ -372,7 +425,11 @@ def _sync_global_symlinks(self): if not os.path.exists(list_path): continue raw_groups: tuple[str, ...] = getattr(sub, "groups", tuple()) - groups = [self._subscription_dirname(sub), "all", *normalize_groups(raw_groups)] + groups = [ + self._subscription_dirname(sub), + "all", + *normalize_groups(raw_groups), + ] link_name = f"{idx:02d}-{os.path.basename(list_path)}" for group in groups: desired.setdefault(group, {})[link_name] = list_path @@ -448,12 +505,44 @@ def _sync_global_symlinks(self): def _load_meta(self, meta_path: str): try: - return ListMetadata.from_dict(read_json_locked(meta_path)) - except Exception: + meta = ListMetadata.from_dict(read_json_locked(meta_path)) + self._emit_runtime_event( + RuntimeEvent.FILE_LOAD_FINISHED, + "Subscription metadata loaded.", + target="subscription_meta", + path=meta_path, + ) + return meta + except Exception as exc: + self._emit_runtime_event( + RuntimeEvent.FILE_LOAD_ERROR, + "Failed to load subscription metadata.", + error=str(exc), + target="subscription_meta", + path=meta_path, + ) return ListMetadata() def _save_meta(self, meta_path: str, meta: ListMetadata): - write_json_atomic_locked(meta_path, meta.to_dict()) + try: + write_json_atomic_locked(meta_path, meta.to_dict()) + self._emit_runtime_event( + RuntimeEvent.FILE_SAVE_FINISHED, + "Subscription metadata saved.", + target="subscription_meta", + path=meta_path, + state=meta.last_result or None, + ) + except Exception as exc: + self._emit_runtime_event( + RuntimeEvent.FILE_SAVE_ERROR, + "Failed to save subscription metadata.", + error=str(exc), + target="subscription_meta", + path=meta_path, + state=meta.last_result or None, + ) + raise def _fsync_parent_dir(self, path: str): parent = os.path.dirname(path) @@ -471,16 +560,14 @@ def _fsync_parent_dir(self, path: str): os.close(dir_fd) def _affected_rule_dirs(self, sub: SubscriptionSpec): - affected_dirs = {os.path.join(self._rules_root_dir(), self._subscription_dirname(sub))} + affected_dirs = { + os.path.join(self._rules_root_dir(), self._subscription_dirname(sub)) + } rules_root = self._rules_root_dir() affected_dirs.add(os.path.join(rules_root, "all")) for group in normalize_groups(sub.groups): affected_dirs.add(os.path.join(rules_root, group)) - return { - os.path.normpath(path) - for path in affected_dirs - if path.strip() != "" - } + return {os.path.normpath(path) for path in affected_dirs if path.strip() != ""} def _reload_rules_for_updated_subscription(self, sub: SubscriptionSpec): try: @@ -496,22 +583,26 @@ def _reload_rules_for_updated_subscription(self, sub: SubscriptionSpec): while records.next(): rule = Rule.new_from_records(records) if rule.operator.operand == Config.OPERAND_LIST_DOMAINS: - direct_dir = os.path.normpath(str(rule.operator.data or "").strip()) + direct_dir = os.path.normpath( + str(rule.operator.data or "").strip() + ) if direct_dir in affected_dirs: matched = True if not matched: for operator in getattr(rule.operator, "list", []): if operator.operand != Config.OPERAND_LIST_DOMAINS: continue - nested_dir = os.path.normpath(str(operator.data or "").strip()) + nested_dir = os.path.normpath( + str(operator.data or "").strip() + ) if nested_dir in affected_dirs: matched = True break if not matched: continue - notification = ui_pb2.Notification( # type: ignore - type=ui_pb2.CHANGE_RULE, # type: ignore + notification = ui_pb2.Notification( # type: ignore + type=ui_pb2.CHANGE_RULE, # type: ignore rules=[rule], ) self._nodes.send_notification(addr, notification, None) @@ -542,11 +633,7 @@ def _sub_key(self, sub: SubscriptionSpec): def configure(self, parent: Any = None): if isinstance(parent, StatsDialog): - if self._cfg_action is not None: - return - - menu = parent.actionsButton.menu() - if menu is None: + if self._cfg_action is not None or self._cfg_toolbar_button is not None: return icon_path = os.path.join( @@ -555,26 +642,120 @@ def configure(self, parent: Any = None): icon = ( QtGui.QIcon(icon_path) if os.path.exists(icon_path) else QtGui.QIcon() ) + if self._install_toolbar_button(parent, icon): + self._remove_menu_action(parent) + return + self._install_menu_action(parent, icon) - quit_action = self._find_quit_action(menu) - if quit_action is not None: - if not icon.isNull(): - self._cfg_action = menu.addAction(icon, "List subscriptions") - else: - self._cfg_action = menu.addAction("List subscriptions") - menu.insertAction(quit_action, self._cfg_action) + def _install_toolbar_button(self, parent: StatsDialog, icon: QtGui.QIcon): + actions_button = getattr(parent, "actionsButton", None) + if not isinstance(actions_button, QtWidgets.QPushButton): + return False + button_parent = actions_button.parentWidget() + if button_parent is None: + return False + layout = self._find_layout_containing_widget( + button_parent.layout(), actions_button + ) + if not isinstance(layout, QtWidgets.QHBoxLayout): + return False + + insert_at = -1 + reference_button: QtWidgets.QPushButton | None = None + for idx in range(layout.count()): + item = layout.itemAt(idx) + if item is None: + continue + if item.spacerItem() is not None: + insert_at = idx + break + widget = item.widget() + if isinstance(widget, QtWidgets.QPushButton): + reference_button = widget + if insert_at < 0: + insert_at = layout.count() + if reference_button is None: + return False + + button = QtWidgets.QPushButton(button_parent) # pyright: ignore[reportCallIssue,reportArgumentType] + button.setObjectName("listSubscriptionsButton") + button.setText("") + button.setToolTip("List subscriptions") + button.setStatusTip("Open list subscriptions") + button.setFlat(True) + if not icon.isNull(): + button.setIcon(icon) # type: ignore[arg-type] + button.setCursor(reference_button.cursor()) # pyright: ignore[reportArgumentType] + button.setFocusPolicy(reference_button.focusPolicy()) # pyright: ignore[reportArgumentType] + button.setSizePolicy(reference_button.sizePolicy()) # pyright: ignore[reportArgumentType] + button.setMinimumSize(reference_button.minimumSize()) # pyright: ignore[reportArgumentType] + button.setMaximumHeight(reference_button.maximumHeight()) # pyright: ignore[reportArgumentType] + button.setIconSize(reference_button.iconSize()) # pyright: ignore[reportArgumentType] + + layout.insertWidget(insert_at, button) # pyright: ignore[reportArgumentType] + button.clicked.connect(lambda *_: self._open_config_dialog(parent)) + self._cfg_toolbar_button = button + return True + + def _find_layout_containing_widget( + self, + layout: QtWidgets.QLayout | None, + target: QtWidgets.QWidget, + ): + if layout is None: + return None + for idx in range(layout.count()): + item = layout.itemAt(idx) + if item is None: + continue + widget = item.widget() + if widget is target: + return layout + if isinstance(widget, QtWidgets.QWidget): + found = self._find_layout_containing_widget(widget.layout(), target) + if found is not None: + return found + child_layout = item.layout() + if child_layout is not None: + found = self._find_layout_containing_widget(child_layout, target) + if found is not None: + return found + return None + + def _install_menu_action(self, parent: StatsDialog, icon: QtGui.QIcon): + menu = parent.actionsButton.menu() + if menu is None: + return + + quit_action = self._find_quit_action(menu) + if quit_action is not None: + if not icon.isNull(): + self._cfg_action = menu.addAction(icon, "List subscriptions") else: - acts = menu.actions() - if acts and not acts[-1].isSeparator(): - menu.addSeparator() - if not icon.isNull(): - self._cfg_action = menu.addAction(icon, "List subscriptions") - else: - self._cfg_action = menu.addAction("List subscriptions") + self._cfg_action = menu.addAction("List subscriptions") + menu.insertAction(quit_action, self._cfg_action) + else: + acts = menu.actions() + if acts and not acts[-1].isSeparator(): + menu.addSeparator() + if not icon.isNull(): + self._cfg_action = menu.addAction(icon, "List subscriptions") + else: + self._cfg_action = menu.addAction("List subscriptions") - self._cfg_action.triggered.connect( - lambda *_: self._open_config_dialog(parent) - ) + self._cfg_action.triggered.connect(lambda *_: self._open_config_dialog(parent)) + + def _remove_menu_action(self, parent: StatsDialog): + menu = parent.actionsButton.menu() + if menu is None: + return + text = "list subscriptions" + for action in list(menu.actions()): + if (action.text() or "").replace("&", "").strip().lower() == text: + menu.removeAction(action) + if action is self._cfg_action: + self._cfg_action = None + break def _find_quit_action(self, menu: Any): qt_key = getattr(getattr(QtCore, "Qt", object()), "Key", None) @@ -598,8 +779,8 @@ def _find_quit_action(self, menu: Any): return acts[-1] return None - def _open_config_dialog(self, parent): - from opensnitch.plugins.list_subscriptions import _gui + def _open_config_dialog(self, parent: Any): + from opensnitch.plugins.list_subscriptions.ui.list_subscriptions_dialog import ListSubscriptionsDialog appicon = None try: @@ -610,7 +791,7 @@ def _open_config_dialog(self, parent): if self._cfg_dialog is None: # Some wrapped dialog types are not accepted as QWidget parents by # PyQt6 constructors in plugin context. Use a top-level dialog. - self._cfg_dialog = _gui.ListSubscriptionsDialog( + self._cfg_dialog = ListSubscriptionsDialog( parent=None, appicon=appicon ) self._cfg_dialog.show() @@ -681,7 +862,7 @@ def _startup_recheck_all(self): if not sub.enabled: continue try: - self.force_refresh_subscription(sub) + self.refresh_subscriptions(sub, source="startup_recheck") except Exception as e: logger.warning( "startup recheck error name='%s' err=%s", @@ -724,7 +905,7 @@ def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): logger.warning("skip '%s' (not due yet)", sub.name) return - th = threading.Thread(target=self.download, args=(key, sub)) + th = threading.Thread(target=self.download, args=(sub,)) th.start() th.join() @@ -762,9 +943,18 @@ def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): self._notify_title, result_msg, self._app_icon ) - def force_refresh_subscription(self, sub: SubscriptionSpec): - key = self._sub_key(sub) - ok = self.download(key, sub, force=True) + def refresh_subscriptions( + self, + subscriptions: SubscriptionSpec | list[SubscriptionSpec], + source: str = "scheduled", + force: bool = False, + ): + ok = self.download( + subscriptions, + force=force, + source=source, + emit_download_events=True, + ) self._sync_global_symlinks() return ok @@ -780,13 +970,13 @@ def cb_signal(self, signal: dict[str, Any]): ) ok, err = self._reload_from_action_file(action_path) if ok: - self.enabled = True - self.run() - self._emit_runtime_event( + self.enabled = True + self.run() + self._emit_runtime_event( RuntimeEvent.RUNTIME_ENABLED, - "Plugin runtime enabled.", - action_path=action_path, - ) + "Plugin runtime enabled.", + action_path=action_path, + ) else: self._emit_runtime_event( RuntimeEvent.RUNTIME_ERROR, @@ -796,16 +986,54 @@ def cb_signal(self, signal: dict[str, Any]): ) return + if sig == self.REFRESH_SUBSCRIPTIONS_SIGNAL: + raw_items = signal.get("items") + source = str(signal.get("source") or "manual_refresh") + subscriptions: list[SubscriptionSpec] = [] + if isinstance(raw_items, list): + for raw_item in raw_items: + if not isinstance(raw_item, dict): + continue + try: + sub = SubscriptionSpec.from_dict( + raw_item, + self._config.defaults, + ) + except Exception: + sub = None + if sub is not None: + subscriptions.append(sub) + if not subscriptions: + self._emit_runtime_event( + RuntimeEvent.RUNTIME_ERROR, + "No subscriptions were provided for refresh.", + action_path=action_path, + ) + return + th = threading.Thread( + target=self.refresh_subscriptions, + args=(subscriptions, source, True), + daemon=True, + ) + th.start() + return + if sig == PluginSignal.CONFIG_UPDATE: logger.warning( "cb_signal: CONFIG_UPDATE action_path=%r", action_path, ) + previous_subscriptions: list[SubscriptionSpec] = [] + try: + previous_subscriptions = list(self._config.subscriptions) + except Exception: + previous_subscriptions = [] self.stop() ok, err = self._reload_from_action_file(action_path) if ok: if self.enabled: - self.run() + self._start_runtime(recheck=False) + self._apply_config_update_diff(previous_subscriptions) self._emit_runtime_event( RuntimeEvent.CONFIG_RELOADED, "Plugin runtime configuration reloaded.", @@ -829,12 +1057,16 @@ def cb_signal(self, signal: dict[str, Any]): self.enabled = False self.stop() self._emit_runtime_event( - RuntimeEvent.RUNTIME_DISABLED - if sig == PluginSignal.DISABLE - else RuntimeEvent.RUNTIME_STOPPED, - "Plugin runtime disabled." - if sig == PluginSignal.DISABLE - else "Plugin runtime stopped.", + ( + RuntimeEvent.RUNTIME_DISABLED + if sig == PluginSignal.DISABLE + else RuntimeEvent.RUNTIME_STOPPED + ), + ( + "Plugin runtime disabled." + if sig == PluginSignal.DISABLE + else "Plugin runtime stopped." + ), action_path=action_path, ) return @@ -883,7 +1115,14 @@ def _mark_failure(self, meta: ListMetadata, err: str): datetime.now().astimezone() + timedelta(seconds=seconds) ).isoformat() - def download(self, key: str, sub: SubscriptionSpec, force: bool = False): + def _download_one( + self, + key: str, + sub: SubscriptionSpec, + force: bool = False, + source: str = "scheduled", + emit_download_events: bool = True, + ): list_path, meta_path = self._paths(sub) os.makedirs(os.path.dirname(list_path), exist_ok=True) @@ -896,6 +1135,23 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): meta.last_checked = now_iso() meta.last_error = "" + event_item = subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + path=list_path, + ) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_STARTED, + f"Downloading subscription '{sub.name}'.", + target="subscription_list", + path=list_path, + source=source, + items=[event_item], + ) # conditional headers headers: dict[str, str] = {} @@ -910,6 +1166,26 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): if not lock.acquire(): meta.last_result = "busy" self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_FAILED, + f"Subscription '{sub.name}' is busy.", + target="subscription_list", + path=list_path, + source=source, + state="busy", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state="busy", + path=list_path, + ) + ], + ) self._resultsQueue.put((key, False, "busy")) return False @@ -922,6 +1198,27 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): except Exception as e: self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_FAILED, + f"Subscription download failed for '{sub.name}'.", + error=repr(e), + target="subscription_list", + path=list_path, + source=source, + state="request_error", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state="request_error", + path=list_path, + ) + ], + ) self._resultsQueue.put((key, False, "request_error")) return False @@ -932,6 +1229,26 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): meta.backoff_until = "" meta.last_result = "not_modified" self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_FINISHED, + f"Subscription '{sub.name}' is up to date.", + target="subscription_list", + path=list_path, + source=source, + state="not_modified", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state="not_modified", + path=list_path, + ) + ], + ) self._resultsQueue.put((key, True, "not_modified")) logger.warning("subscription not-modified name='%s'", sub.name) return True @@ -939,6 +1256,27 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): if r.status_code != 200: self._mark_failure(meta, f"http_{r.status_code}") self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_FAILED, + f"Subscription download failed for '{sub.name}'.", + error=f"http_{r.status_code}", + target="subscription_list", + path=list_path, + source=source, + state=f"http_{r.status_code}", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state=f"http_{r.status_code}", + path=list_path, + ) + ], + ) self._resultsQueue.put((key, False, f"http_{r.status_code}")) logger.warning( "subscription download http-error name='%s' code=%s", @@ -953,6 +1291,27 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): if int(cl) > sub.max_bytes: self._mark_failure(meta, f"too_large:{cl}") self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_FAILED, + f"Subscription download exceeded max size for '{sub.name}'.", + error=f"too_large:{cl}", + target="subscription_list", + path=list_path, + source=source, + state="too_large", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state="too_large", + path=list_path, + ) + ], + ) self._resultsQueue.put((key, False, "too_large")) logger.warning( "subscription download too-large name='%s' len=%s", @@ -1000,6 +1359,27 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): pass self._mark_failure(meta, "bad_format_hosts") self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_FAILED, + f"Subscription file format is invalid for '{sub.name}'.", + error="bad_format_hosts", + target="subscription_list", + path=list_path, + source=source, + state="bad_format", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state="bad_format", + path=list_path, + ) + ], + ) self._resultsQueue.put((key, False, "bad_format")) logger.warning( "subscription file bad-format name='%s'", @@ -1009,6 +1389,14 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): os.replace(tmp, list_path) self._fsync_parent_dir(list_path) + self._emit_runtime_event( + RuntimeEvent.FILE_SAVE_FINISHED, + f"Subscription file saved for '{sub.name}'.", + target="subscription_list", + path=list_path, + source=source, + items=[event_item], + ) except Exception as e: try: @@ -1018,6 +1406,47 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): pass self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) + self._emit_runtime_event( + RuntimeEvent.FILE_SAVE_ERROR, + f"Failed to save subscription file for '{sub.name}'.", + error=repr(e), + target="subscription_list", + path=list_path, + source=source, + state="write_error", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state="write_error", + path=list_path, + ) + ], + ) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_FAILED, + f"Subscription download failed for '{sub.name}'.", + error=repr(e), + target="subscription_list", + path=list_path, + source=source, + state="write_error", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state="write_error", + path=list_path, + ) + ], + ) self._resultsQueue.put((key, False, "write_error")) logger.warning( "subscription file write-error name='%s' err=%s", @@ -1040,6 +1469,26 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): meta.backoff_until = "" meta.last_result = "updated" self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_FINISHED, + f"Subscription '{sub.name}' updated.", + target="subscription_list", + path=list_path, + source=source, + state="updated", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state="updated", + path=list_path, + ) + ], + ) logger.warning( "subscription updated name='%s' bytes=%s", sub.name, @@ -1056,6 +1505,27 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): except Exception as e: self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_FAILED, + f"Subscription download failed for '{sub.name}'.", + error=repr(e), + target="subscription_list", + path=list_path, + source=source, + state="unexpected_error", + items=[ + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state="unexpected_error", + path=list_path, + ) + ], + ) self._resultsQueue.put((key, False, "unexpected_error")) logger.warning( "subscription download unexpected-error name='%s' err=%s", @@ -1066,3 +1536,97 @@ def download(self, key: str, sub: SubscriptionSpec, force: bool = False): finally: lock.release() + + def download( + self, + subscriptions: SubscriptionSpec | list[SubscriptionSpec], + force: bool = False, + source: str = "scheduled", + emit_download_events: bool = True, + ): + if isinstance(subscriptions, SubscriptionSpec): + return self._download_one( + self._sub_key(subscriptions), + subscriptions, + force=force, + source=source, + emit_download_events=emit_download_events, + ) + + if not subscriptions: + return True + + items: list[dict[str, Any]] = [] + if emit_download_events: + self._emit_runtime_event( + RuntimeEvent.DOWNLOAD_STARTED, + "Batch subscription refresh started.", + target="subscription_list", + source=source, + items=[ + subscription_event_item( + self._sub_key(sub), + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + ) + for sub in subscriptions + ], + ) + had_errors = False + for sub in subscriptions: + key = self._sub_key(sub) + list_path, meta_path = self._paths(sub) + item_state = "unexpected_error" + try: + ok = self._download_one( + key, + sub, + force=force, + source=source, + emit_download_events=False, + ) + item_state = "updated" if ok else "error" + if not ok: + had_errors = True + except Exception as exc: + had_errors = True + logger.warning( + "batch download failed for '%s': %r", + sub.name, + exc, + ) + try: + item_state = self._load_meta(meta_path).last_result or item_state + except Exception: + pass + items.append( + subscription_event_item( + key, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + state=item_state, + path=list_path, + ) + ) + if emit_download_events: + self._emit_runtime_event( + ( + RuntimeEvent.DOWNLOAD_FAILED + if had_errors + else RuntimeEvent.DOWNLOAD_FINISHED + ), + ( + "Batch subscription refresh finished with errors." + if had_errors + else "Batch subscription refresh finished." + ), + target="subscription_list", + source=source, + state="batch_failed" if had_errors else "batch_finished", + items=items, + ) + return not had_errors diff --git a/ui/opensnitch/plugins/list_subscriptions/models/__init__.py b/ui/opensnitch/plugins/list_subscriptions/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/models/action.py b/ui/opensnitch/plugins/list_subscriptions/models/action.py new file mode 100644 index 0000000000..2c1bfca4bb --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/action.py @@ -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(), + } + }, + } \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/models/config.py b/ui/opensnitch/plugins/list_subscriptions/models/config.py new file mode 100644 index 0000000000..2f96aa0656 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/config.py @@ -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, + } diff --git a/ui/opensnitch/plugins/list_subscriptions/models/events.py b/ui/opensnitch/plugins/list_subscriptions/models/events.py new file mode 100644 index 0000000000..714582a59a --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/events.py @@ -0,0 +1,16 @@ +from enum import IntEnum + + +class RuntimeEvent(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 \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/models/global_defaults.py b/ui/opensnitch/plugins/list_subscriptions/models/global_defaults.py new file mode 100644 index 0000000000..cbe6539cc7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/global_defaults.py @@ -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), + ) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/models/metadata.py b/ui/opensnitch/plugins/list_subscriptions/models/metadata.py new file mode 100644 index 0000000000..9b5305b84b --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/metadata.py @@ -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) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/models/subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/models/subscriptions.py new file mode 100644 index 0000000000..aaba1540ac --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/models/subscriptions.py @@ -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 diff --git a/ui/opensnitch/plugins/list_subscriptions/res/bulk_edit_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/bulk_edit_dialog.ui index c512cd377f..2e3163ec13 100644 --- a/ui/opensnitch/plugins/list_subscriptions/res/bulk_edit_dialog.ui +++ b/ui/opensnitch/plugins/list_subscriptions/res/bulk_edit_dialog.ui @@ -15,150 +15,70 @@ - - - - - - - Apply enabled - - - - - - - Enabled - - - - - - - - - Groups - - - - - - - - - Apply groups - - - - - - - - - - - - Format - - - - - - - - - Apply format - - - - - - - - - - - - Interval - - - - - - - - - Apply interval - - - - - - - - - - - - - - - Timeout - - - - - - - - - Apply timeout - - - - - - - - - - - - - - - Max size - - - - - - - - - Apply max size - - - - - - - - - - - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Selected changes + + + + + - + - + Choose which changes to apply to the selected subscriptions. + + + true + + + + + + + + Property + + + + + New value + + + + + + + + Qt::Horizontal + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui index 574b88674b..beaf354b22 100644 --- a/ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui +++ b/ui/opensnitch/plugins/list_subscriptions/res/list_subscriptions_dialog.ui @@ -72,187 +72,328 @@ - + - Runtime: inactive + Status - - - - - - - - Lists directory - - - - - - - - - - Default interval - - - - - - - - - - - - - Default timeout - - - - - - - - - - - - - Default max size - - - - - - - - - - - - - Default User-Agent - - - - - - - - + + - Node + Runtime: inactive - - - - - - - + - - - Global actions - - - - - - Add subscription - - - + + + + 0 + + + 0 + + + 0 + + + 0 + - + - Refresh all + Defaults + + + + + + + + + Lists directory + + + + + + + + + + Default interval + + + + + + + + + + + + + Default timeout + + + + + + + + + + + + + Default max size + + + + + + + + + + + + + Default User-Agent + + + + + + + + + + Node + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + - + - Create global rule + List subscriptions - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - Selected subscription(s) actions - - - - - - Edit + + + + + + + + + + + + + + + + + 0 - - - - - - Delete + + 0 - - - - - - Refresh + + 0 - - - - - - Create rule + + 0 - - - - - - Qt::Horizontal + + + + Global actions + + + + + + + + + + + + Add subscription + + + + + + + Refresh all + + + + + + + Create global rule + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + + + + QFrame::VLine + + + QFrame::Plain + + + + + + + + + + + + 0 - - - 40 - 20 - + + 0 - - - - + + 0 + + + 0 + + + + + Selected subscriptions + + + + + + + + + + + + Edit + + + + + + + Delete + + + + + + + Refresh + + + + + + + Create rule + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + QFrame::HLine + + + QFrame::Plain + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui index 1fea6d33ec..3e08d1e77f 100644 --- a/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui +++ b/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui @@ -19,102 +19,132 @@ - Settings + - - + + true + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Settings + + + + + + + + + Enabled - - + + Name - - + + - - + + - - + + URL - - + + - - + + - - + + Filename - - + + - - + + - - + + Format - - + + - - + + Groups - - + + - - + + - - + + Interval - - + + @@ -123,15 +153,15 @@ - - + + Timeout - - + + @@ -140,15 +170,15 @@ - - + + Max size - - + + @@ -157,6 +187,8 @@ + + @@ -164,9 +196,42 @@ - Metadata + + + + true - + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Metadata + + + + + + + + + + 12 + @@ -174,7 +239,17 @@ - + + + + QFrame::VLine + + + QFrame::Plain + + + + @@ -188,7 +263,7 @@ - + @@ -202,7 +277,7 @@ - + @@ -216,7 +291,7 @@ - + @@ -230,7 +305,7 @@ - + @@ -244,7 +319,7 @@ - + @@ -258,7 +333,7 @@ - + @@ -272,7 +347,7 @@ - + @@ -289,7 +364,7 @@ - + @@ -300,19 +375,28 @@ + + - - - + + + Qt::Horizontal + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py @@ -0,0 +1 @@ + diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py new file mode 100644 index 0000000000..a5a61d0d32 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py @@ -0,0 +1,377 @@ +import logging +import os +import sys +from typing import Any, TYPE_CHECKING, Final + +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 # noqa: F401 + from PyQt5.QtCore import QCoreApplication as QC + + load_ui_type = uic.loadUiType + +from opensnitch.plugins.list_subscriptions.ui.helpers import ( + _apply_section_bar_style, + _apply_footer_separator_style, + _set_optional_field_tooltips, +) +from opensnitch.plugins.list_subscriptions.ui.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, + normalize_unit, +) + + +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 + 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) + self._apply_section_bar_style() + self._apply_footer_style() + 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",)) + self.interval_spin.setRange(0, 999999) + self.interval_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.interval, + self._defaults.interval_units, + ) + ) + self.interval_spin.setValue(0) + self.interval_units.clear() + self.interval_units.addItems(INTERVAL_UNITS) + self.interval_units.setCurrentText( + normalize_unit(self._defaults.interval_units, INTERVAL_UNITS, "hours") + ) + self.timeout_spin.setRange(0, 999999) + self.timeout_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.timeout, + self._defaults.timeout_units, + ) + ) + self.timeout_spin.setValue(0) + self.timeout_units.clear() + self.timeout_units.addItems(TIMEOUT_UNITS) + self.timeout_units.setCurrentText( + normalize_unit(self._defaults.timeout_units, TIMEOUT_UNITS, "seconds") + ) + self.max_size_spin.setRange(0, 999999) + self.max_size_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.max_size, + self._defaults.max_size_units, + ) + ) + self.max_size_spin.setValue(0) + self.max_size_units.clear() + self.max_size_units.addItems(SIZE_UNITS) + self.max_size_units.setCurrentText( + normalize_unit(self._defaults.max_size_units, SIZE_UNITS, "MB") + ) + + 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) + self._apply_optional_field_tooltips() + self._sync_apply_fields_state() + self._sync_optional_fields_state() + self.resize(760, 420) + + def _apply_section_bar_style(self): + _apply_section_bar_style( + self, + self.changes_section_bar, + self.changes_section_label, + ) + + def _apply_footer_style(self): + _apply_footer_separator_style(self, self.footer_separator_line) + + def _apply_optional_field_tooltips(self): + _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, + ) + + 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 + + def _is_field_applied(self, key: str): + 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): + if column != 0: + return + self._sync_apply_fields_state() + + def _sync_optional_fields_state(self): + 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): + 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() + + def _validate_then_accept(self): + 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() + + def values(self): + 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 + ), + } diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/helpers.py b/ui/opensnitch/plugins/list_subscriptions/ui/helpers.py new file mode 100644 index 0000000000..fa774265ed --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/helpers.py @@ -0,0 +1,171 @@ +import sys +from typing import TYPE_CHECKING + +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 # noqa: F401 + from PyQt5.QtCore import QCoreApplication as QC + + load_ui_type = uic.loadUiType + + +def _is_dark_palette(widget: QtWidgets.QWidget): + return widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128 + + +def _section_background_color_name(widget: QtWidgets.QWidget): + bg_role = ( + QtGui.QPalette.ColorRole.AlternateBase + if _is_dark_palette(widget) + else QtGui.QPalette.ColorRole.Button + ) + return widget.palette().color(bg_role).name() + + +def _section_border_color_name(widget: QtWidgets.QWidget): + border_role = ( + QtGui.QPalette.ColorRole.Midlight + if _is_dark_palette(widget) + else QtGui.QPalette.ColorRole.Mid + ) + return widget.palette().color(border_role).name() + + +def _footer_separator_color_name(widget: QtWidgets.QWidget): + footer_role = ( + QtGui.QPalette.ColorRole.Midlight + if _is_dark_palette(widget) + else QtGui.QPalette.ColorRole.Dark + ) + return widget.palette().color(footer_role).name() + + +def _apply_section_bar_style( + widget: QtWidgets.QWidget, + container: QtWidgets.QFrame, + label: QtWidgets.QLabel, + *, + right_border: bool = False, + expanding_label: bool = False, +): + bg = _section_background_color_name(widget) + 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): + footer_color = _footer_separator_color_name(widget) + separator.setFixedHeight(1) + separator.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) + separator.setStyleSheet( + f"QFrame {{ color: {footer_color}; background-color: {footer_color}; }}" + ) + + +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.") + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/_gui.py b/ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py similarity index 56% rename from ui/opensnitch/plugins/list_subscriptions/_gui.py rename to ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py index 981c328644..05175e54cb 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_gui.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py @@ -1,109 +1,140 @@ import json import logging import os -import re import sys -import threading -from urllib.parse import urlparse, unquote -from typing import cast, Any, TYPE_CHECKING +from contextlib import contextmanager +from typing import cast, Any, TYPE_CHECKING, Final 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 import QtCore, QtGui, QtWidgets, uic # noqa: F401 from PyQt5.QtCore import QCoreApplication as QC + load_ui_type = uic.loadUiType + +from opensnitch.plugins.list_subscriptions.io.storage import read_json_locked +from opensnitch.plugins.list_subscriptions.models.config import PluginConfig +from opensnitch.plugins.list_subscriptions.models.subscriptions import ( + MutableSubscriptionSpec, + SubscriptionSpec, +) +from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults +from opensnitch.plugins.list_subscriptions.models.events import RuntimeEvent from opensnitch.actions import Actions from opensnitch.nodes import Nodes from opensnitch.plugins import PluginSignal -from opensnitch.utils.xdg import xdg_config_home -from opensnitch.plugins.list_subscriptions._models import ( - GlobalDefaults, +from opensnitch.plugins.list_subscriptions.models.action import ( MutableActionConfig, - MutableSubscriptionSpec, - PluginConfig, - SubscriptionSpec, ) +from opensnitch.plugins.list_subscriptions.ui.helpers import ( + _section_background_color_name, + _section_border_color_name, + _apply_section_bar_style, +) +from opensnitch.plugins.list_subscriptions.ui.toggle_switch_widget import ( + _replace_checkbox_with_toggle, +) +from opensnitch.plugins.list_subscriptions.ui.subscription_dialog import ( + SubscriptionDialog, +) +from opensnitch.plugins.list_subscriptions.ui.bulk_edit_dialog import BulkEditDialog from opensnitch.plugins.list_subscriptions._utils import ( - RuntimeEvent, + ACTION_FILE, + DEFAULT_LISTS_DIR, + RES_DIR, + INTERVAL_UNITS, + TIMEOUT_UNITS, + SIZE_UNITS, + display_str, + derive_filename, ensure_filename_type_suffix, + filename_from_content_disposition, + list_file_path, normalize_group, normalize_groups, normalize_lists_dir, - read_json_locked, + normalize_unit, + safe_filename, + strip_or_none, + subscription_payload_dict, + subscription_rule_dir, + timestamp_sort_key, +) +from opensnitch.plugins.list_subscriptions.io.storage import ( write_json_atomic_locked, ) from opensnitch.dialogs.ruleseditor import RulesEditorDialog import requests -from .list_subscriptions import ListSubscriptions - +from opensnitch.plugins.list_subscriptions.list_subscriptions import ListSubscriptions -ACTION_FILE = os.path.join( - xdg_config_home, "opensnitch", "actions", "list_subscriptions.json" -) -DEFAULT_LISTS_DIR = os.path.join(xdg_config_home, "opensnitch", "list_subscriptions") -PLUGIN_DIR = os.path.abspath(os.path.dirname(__file__)) -RES_DIR = os.path.join(PLUGIN_DIR, "res") -LIST_SUBSCRIPTIONS_DIALOG_UI_PATH = os.path.join( +LIST_SUBSCRIPTIONS_DIALOG_UI_PATH: Final[str] = os.path.join( RES_DIR, "list_subscriptions_dialog.ui" ) -SUBSCRIPTION_DIALOG_UI_PATH = os.path.join(RES_DIR, "subscription_dialog.ui") -BULK_EDIT_DIALOG_UI_PATH = os.path.join(RES_DIR, "bulk_edit_dialog.ui") - -SubscriptionDialogUI = uic.loadUiType(SUBSCRIPTION_DIALOG_UI_PATH)[0] # type: ignore -BulkEditDialogUI = uic.loadUiType(BULK_EDIT_DIALOG_UI_PATH)[0] # type: ignore -ListSubscriptionsDialogUI = uic.loadUiType(LIST_SUBSCRIPTIONS_DIALOG_UI_PATH)[0] # type: ignore - -INTERVAL_UNITS = ("seconds", "minutes", "hours", "days", "weeks") -TIMEOUT_UNITS = ("seconds", "minutes", "hours", "days", "weeks") -SIZE_UNITS = ("bytes", "KB", "MB", "GB") - -COL_ENABLED = 0 -COL_NAME = 1 -COL_URL = 2 -COL_FILENAME = 3 -COL_FORMAT = 4 -COL_GROUP = 5 -COL_INTERVAL = 6 -COL_INTERVAL_UNITS = 7 -COL_TIMEOUT = 8 -COL_TIMEOUT_UNITS = 9 -COL_MAX_SIZE = 10 -COL_MAX_SIZE_UNITS = 11 -COL_FILE = 12 -COL_META = 13 -COL_STATE = 14 -COL_LAST_CHECKED = 15 -COL_LAST_UPDATED = 16 -COL_FAILS = 17 -COL_ERROR = 18 - -logger = logging.getLogger(__name__) + +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_LAST_CHECKED: Final[int] = 15 +COL_LAST_UPDATED: Final[int] = 16 +COL_FAILS: Final[int] = 17 +COL_ERROR: Final[int] = 18 + +logger: Final[logging.Logger] = logging.getLogger(__name__) class KeepForegroundOnSelectionDelegate(QtWidgets.QStyledItemDelegate): - def initStyleOption(self, option, index): + 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) + brush = ( + foreground + if isinstance(foreground, QtGui.QBrush) + else QtGui.QBrush(foreground) + ) option.palette.setBrush( QtGui.QPalette.ColorRole.Text, brush, @@ -114,648 +145,170 @@ def initStyleOption(self, option, index): ) -class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): - _url_test_finished = QtCore.pyqtSignal(bool, str) - - if TYPE_CHECKING: - enabled_check: QtWidgets.QCheckBox - name_edit: QtWidgets.QLineEdit - name_error_label: QtWidgets.QLabel - url_edit: QtWidgets.QLineEdit - url_error_label: QtWidgets.QLabel - filename_edit: QtWidgets.QLineEdit - filename_error_label: QtWidgets.QLabel - format_combo: QtWidgets.QComboBox - group_combo: 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 - meta_group: QtWidgets.QGroupBox - meta_file_present: QtWidgets.QLabel - meta_meta_present: QtWidgets.QLabel - meta_state: QtWidgets.QLabel - meta_last_checked: QtWidgets.QLabel - meta_last_updated: QtWidgets.QLabel - meta_failures: QtWidgets.QLabel - meta_error: QtWidgets.QLabel - meta_list_path: QtWidgets.QLabel - meta_meta_path: QtWidgets.QLabel - error_label: QtWidgets.QLabel - 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__( +class CenteredCheckDelegate(QtWidgets.QStyledItemDelegate): + def _indicator_rect( 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._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._build_ui() - - def _build_ui(self): - self.setupUi(self) - self._set_dialog_message("", error=False) - 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._handle_url_test_finished) - self.add_button.clicked.connect(self._validate_then_accept) - self.test_url_button.clicked.connect(self._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) - self.interval_spin.setRange(0, 999999) - self.interval_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( - self._defaults.interval, - self._defaults.interval_units, - ) - ) - self.interval_spin.setValue(max(0, int(self._sub.interval or 0))) - self.interval_units.clear() - self.interval_units.addItems(INTERVAL_UNITS) - self.interval_units.setCurrentText( - self._normalize_unit( - str(self._sub.interval_units or self._defaults.interval_units), - INTERVAL_UNITS, - "hours", - ) - ) - self.timeout_spin.setRange(0, 999999) - self.timeout_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( - self._defaults.timeout, - self._defaults.timeout_units, - ) - ) - self.timeout_spin.setValue(max(0, int(self._sub.timeout or 0))) - self.timeout_units.clear() - self.timeout_units.addItems(TIMEOUT_UNITS) - self.timeout_units.setCurrentText( - self._normalize_unit( - str(self._sub.timeout_units or self._defaults.timeout_units), - TIMEOUT_UNITS, - "seconds", - ) - ) - self.max_size_spin.setRange(0, 999999) - self.max_size_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( - self._defaults.max_size, - self._defaults.max_size_units, - ) - ) - self.max_size_spin.setValue(max(0, int(self._sub.max_size or 0))) - self.max_size_units.clear() - self.max_size_units.addItems(SIZE_UNITS) - self.max_size_units.setCurrentText( - self._normalize_unit( - str(self._sub.max_size_units or self._defaults.max_size_units), - SIZE_UNITS, - "MB", - ) - ) - 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) - self._apply_optional_field_tooltips() - self._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.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 _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): - normalized = (value or "").strip().lower() - for unit in allowed: - if unit.lower() == normalized: - return unit - return fallback - - def _apply_optional_field_tooltips(self): - self.interval_spin.setToolTip( - QC.translate("stats", "Set to 0 to inherit the global interval.") - ) - self.interval_units.setToolTip( - QC.translate("stats", "Used only when the interval override is set.") - ) - self.timeout_spin.setToolTip( - QC.translate("stats", "Set to 0 to inherit the global timeout.") - ) - self.timeout_units.setToolTip( - QC.translate("stats", "Used only when the timeout override is set.") - ) - self.max_size_spin.setToolTip( - QC.translate("stats", "Set to 0 to inherit the global max size.") - ) - self.max_size_units.setToolTip( - QC.translate("stats", "Used only when the max size override is set.") - ) - - def _sync_optional_fields_state(self): - self.interval_units.setEnabled(self.interval_spin.value() > 0) - self.timeout_units.setEnabled(self.timeout_spin.value() > 0) - self.max_size_units.setEnabled(self.max_size_spin.value() > 0) - - def _clear_field_errors(self): - self._set_dialog_message("", error=False) - self.name_error_label.setText("") - self.url_error_label.setText("") - self.filename_error_label.setText("") - - def _set_dialog_message(self, message: str, error: bool): - color = "red" if error else "#2e7d32" - self.error_label.setStyleSheet(f"color: {color};") - self.error_label.setText(message) - - def _is_valid_url(self, value: str): - parsed = urlparse(value) - return parsed.scheme in {"http", "https"} and parsed.netloc != "" + 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 _test_url(self): - self.url_error_label.setText("") - self._set_dialog_message("", error=False) - url = (self.url_edit.text() or "").strip() - if url == "": - self.url_error_label.setText(QC.translate("stats", "URL is required.")) - self._set_dialog_message( - QC.translate("stats", "Fix the highlighted fields."), error=True - ) + def paint( + self, + painter: QtGui.QPainter | None, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> None: + if painter is None: return - if not self._is_valid_url(url): - self.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 - ) + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + if not ( + opt.features + & QtWidgets.QStyleOptionViewItem.ViewItemFeature.HasCheckIndicator + ): + super().paint(painter, option, index) return - self.test_url_button.setEnabled(False) - self._set_dialog_message(QC.translate("stats", "Testing URL..."), error=False) - - def _run_test(): - try: - response = requests.head(url, allow_redirects=True, timeout=5) - 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 url - response.close() - if response.status_code in (403, 405): - response = requests.get( - url, allow_redirects=True, timeout=5, stream=True - ) - if response.status_code >= 400: - raise requests.HTTPError(f"HTTP {response.status_code}") - final_url = response.url or final_url - response.close() - message = QC.translate("stats", "URL reachable.") - if final_url != url: - message = QC.translate( - "stats", "URL reachable via redirect to {0}" - ).format(final_url) - self._url_test_finished.emit(True, message) - except requests.RequestException as exc: - self._url_test_finished.emit(False, str(exc)) - - threading.Thread(target=_run_test, daemon=True).start() - - def _handle_url_test_finished(self, success: bool, message: str): - self.test_url_button.setEnabled(True) - if success: - self.url_error_label.setText("") - self._set_dialog_message(message, error=False) - return - self.url_error_label.setText(QC.translate("stats", "URL check failed.")) - self._set_dialog_message( - QC.translate("stats", "URL test failed: {0}").format(message), - error=True, + style = ( + opt.widget.style() + if opt.widget is not None + else QtWidgets.QApplication.style() ) - - def _validate_then_accept(self): - self._clear_field_errors() - raw_url = (self.url_edit.text() or "").strip() - raw_name = (self.name_edit.text() or "").strip() - raw_filename = (self.filename_edit.text() or "").strip() - list_type = (self.format_combo.currentText() or "hosts").strip().lower() - name = raw_name - filename = os.path.basename(raw_filename) - has_error = False - - if raw_url == "": - self.url_error_label.setText(QC.translate("stats", "URL is required.")) - has_error = True - elif not self._is_valid_url(raw_url): - self.url_error_label.setText( - QC.translate("stats", "Enter a valid http:// or https:// URL.") - ) - has_error = True - - if raw_name == "" and raw_filename == "": - self.name_error_label.setText( - QC.translate("stats", "Provide a name or filename.") - ) - self.filename_error_label.setText( - QC.translate("stats", "Provide a filename or name.") - ) - has_error = True - elif raw_filename != "" and filename != raw_filename: - self.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 - ) + if style is None: return - if filename == "" and name != "": - filename = self._slugify_name(name) - filename = ensure_filename_type_suffix(filename, list_type) - - if name == "" and filename != "": - name = self._deslugify_filename(filename, list_type) - - self.name_edit.setText(name) - self.filename_edit.setText(filename) - self.accept() - - def _slugify_name(self, name: str): - 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 slug - - def _deslugify_filename(self, filename: str, list_type: str): - safe = os.path.basename((filename or "").strip()) - base, _ext = os.path.splitext(safe) - suffix = f"-{(list_type or 'hosts').strip().lower()}" - 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 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 - ), + 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 BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI): - if TYPE_CHECKING: - apply_enabled: QtWidgets.QCheckBox - enabled_value: QtWidgets.QCheckBox - apply_group: QtWidgets.QCheckBox - group_value: QtWidgets.QComboBox - apply_format: QtWidgets.QCheckBox - format_value: QtWidgets.QComboBox - apply_interval: QtWidgets.QCheckBox - interval_spin: QtWidgets.QSpinBox - interval_units: QtWidgets.QComboBox - apply_timeout: QtWidgets.QCheckBox - timeout_spin: QtWidgets.QSpinBox - timeout_units: QtWidgets.QComboBox - apply_max_size: QtWidgets.QCheckBox - max_size_spin: QtWidgets.QSpinBox - max_size_units: QtWidgets.QComboBox - error_label: QtWidgets.QLabel - 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, - ): - super().__init__(parent) - self.setWindowTitle(QC.translate("stats", "Edit selected subscriptions")) - self._defaults = defaults - self._groups = groups or [] - self._build_ui() - - def _build_ui(self): - self.setupUi(self) - self.error_label.setStyleSheet("color: red;") - 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.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",)) - self.interval_spin.setRange(0, 999999) - self.interval_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( - self._defaults.interval, - self._defaults.interval_units, - ) - ) - self.interval_spin.setValue(0) - self.interval_units.clear() - self.interval_units.addItems(INTERVAL_UNITS) - self.interval_units.setCurrentText( - self._normalize_unit(self._defaults.interval_units, INTERVAL_UNITS, "hours") - ) - self.timeout_spin.setRange(0, 999999) - self.timeout_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( - self._defaults.timeout, - self._defaults.timeout_units, - ) - ) - self.timeout_spin.setValue(0) - self.timeout_units.clear() - self.timeout_units.addItems(TIMEOUT_UNITS) - self.timeout_units.setCurrentText( - self._normalize_unit(self._defaults.timeout_units, TIMEOUT_UNITS, "seconds") - ) - self.max_size_spin.setRange(0, 999999) - self.max_size_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( - self._defaults.max_size, - self._defaults.max_size_units, - ) - ) - self.max_size_spin.setValue(0) - self.max_size_units.clear() - self.max_size_units.addItems(SIZE_UNITS) - self.max_size_units.setCurrentText( - self._normalize_unit(self._defaults.max_size_units, SIZE_UNITS, "MB") - ) - 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) - self._apply_optional_field_tooltips() - self._sync_optional_fields_state() - self.resize(640, 360) - - def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): - normalized = (value or "").strip().lower() - for unit in allowed: - if unit.lower() == normalized: - return unit - return fallback - - def _apply_optional_field_tooltips(self): - self.interval_spin.setToolTip( - QC.translate( - "stats", - "Set to 0 to clear the interval override and use the global default.", - ) - ) - self.interval_units.setToolTip( - QC.translate("stats", "Used only when an interval override is applied.") - ) - self.timeout_spin.setToolTip( - QC.translate( - "stats", - "Set to 0 to clear the timeout override and use the global default.", - ) - ) - self.timeout_units.setToolTip( - QC.translate("stats", "Used only when a timeout override is applied.") - ) - self.max_size_spin.setToolTip( - QC.translate( - "stats", - "Set to 0 to clear the max size override and use the global default.", - ) - ) - self.max_size_units.setToolTip( - QC.translate("stats", "Used only when a max size override is applied.") - ) - - def _sync_optional_fields_state(self): - self.interval_units.setEnabled(self.interval_spin.value() > 0) - self.timeout_units.setEnabled(self.timeout_spin.value() > 0) - self.max_size_units.setEnabled(self.max_size_spin.value() > 0) - - def _validate_then_accept(self): - if not any( - ( - self.apply_enabled.isChecked(), - self.apply_group.isChecked(), - self.apply_format.isChecked(), - self.apply_interval.isChecked(), - self.apply_timeout.isChecked(), - self.apply_max_size.isChecked(), - ) - ): - self.error_label.setText( - QC.translate("stats", "Select at least one field to apply.") - ) - return - self.error_label.setText("") - self.accept() - - def values(self): - return { - "enabled": ( - self.enabled_value.isChecked() - if self.apply_enabled.isChecked() - else None - ), - "groups": ( - normalize_groups(self.group_value.currentText()) - if self.apply_group.isChecked() - else None - ), - "format": ( - (self.format_value.currentText() or "hosts").strip().lower() - if self.apply_format.isChecked() - else None - ), - "apply_interval": self.apply_interval.isChecked(), - "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.apply_timeout.isChecked(), - "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.apply_max_size.isChecked(), - "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 - ), - } +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) class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 _nodes: Nodes _actions: Actions @@ -765,8 +318,8 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): _state_poll_timer: QtCore.QTimer _runtime_plugin: ListSubscriptions | None _pending_runtime_reload: str | None - - _download_finished = QtCore.pyqtSignal() + _pending_refresh_keys: set[str] + _active_refresh_keys: set[str] def __init__( self, @@ -789,32 +342,137 @@ def __init__( self._rules_dialog: RulesEditorDialog | 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._state_poll_timer = QtCore.QTimer(self) self._state_poll_timer.setInterval(2000) self._state_poll_timer.timeout.connect(self._refresh_states_if_visible) - self._download_finished.connect(self.refresh_states) self._build_ui() - def showEvent(self, event: QtGui.QShowEvent): # type: ignore + def showEvent(self, event: QtGui.QShowEvent | None): # type: ignore[override] super().showEvent(event) self.load_action_file() if not self._state_poll_timer.isActive(): self._state_poll_timer.start() - def hideEvent(self, event: QtGui.QHideEvent): # type: ignore + def hideEvent(self, event: QtGui.QHideEvent | None): # type: ignore[override] if self._state_poll_timer.isActive(): self._state_poll_timer.stop() super().hideEvent(event) - def closeEvent(self, event: QtGui.QCloseEvent): # type: ignore + def closeEvent(self, event: QtGui.QCloseEvent | None): # type: ignore[override] if self._state_poll_timer.isActive(): self._state_poll_timer.stop() super().closeEvent(event) def _build_ui(self): self.setupUi(self) + self.enable_plugin_check = _replace_checkbox_with_toggle( + self.enable_plugin_check + ) 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 + ) + self._apply_section_header_style( + self.defaults_section_bar, + self.defaults_section_label, + ) + self._apply_section_header_style( + self.table_section_bar, + self.table_section_label, + ) + self._apply_section_header_style( + self.global_actions_bar, + self.global_actions_label, + ) + self._apply_section_header_style( + self.selected_actions_bar, + self.selected_actions_label, + ) + self.actionsRowLayout.setStretch(0, 1) + self.actionsRowLayout.setStretch(2, 1) + self.actions_vertical_separator.hide() + section_border_color = _section_border_color_name(self) + self.global_actions_bar.setStyleSheet( + "QFrame {" + f"background-color: {_section_background_color_name(self)};" + 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; }}" + ) + 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.default_interval_spin.setRange(1, 999999) self.default_interval_units.clear() @@ -875,6 +533,9 @@ def _build_ui(self): 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, @@ -885,6 +546,8 @@ def _build_ui(self): header = self.table.horizontalHeader() if header is not None: header.setStretchLastSection(True) + header.setSortIndicatorShown(True) + header.setSortIndicator(COL_ENABLED, QtCore.Qt.SortOrder.AscendingOrder) header.setSectionResizeMode( COL_ENABLED, QtWidgets.QHeaderView.ResizeMode.Fixed ) @@ -900,13 +563,17 @@ def _build_ui(self): None, self.table, ) - self.table.setColumnWidth(COL_ENABLED, max(indicator_w, indicator_h) + 12) + self.table.setColumnWidth( + COL_ENABLED, max(indicator_w, indicator_h) + 18 + ) header.setSectionResizeMode( COL_URL, QtWidgets.QHeaderView.ResizeMode.Stretch ) header.setSectionResizeMode( COL_ERROR, QtWidgets.QHeaderView.ResizeMode.Stretch ) + 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. for col in ( @@ -938,6 +605,7 @@ def _build_ui(self): self.table.itemDoubleClicked.connect( lambda *_: self.edit_selected_subscription() ) + self.table.clicked.connect(self._handle_table_clicked) self.table.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self._open_table_context_menu) sel_model = self.table.selectionModel() @@ -948,6 +616,91 @@ def _build_ui(self): self._set_runtime_state(active=False) self._update_selected_actions_state() + def _apply_section_header_style( + self, container: QtWidgets.QFrame, label: QtWidgets.QLabel + ): + _apply_section_bar_style( + self, + container, + label, + expanding_label=True, + ) + + @contextmanager + def _sorting_suspended(self): + header = self.table.horizontalHeader() + sorting_enabled = self.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.table.setSortingEnabled(False) + try: + yield + finally: + self.table.setSortingEnabled(sorting_enabled) + if sorting_enabled and header is not None and sort_section >= 0: + self.table.sortItems(sort_section, sort_order) + + def _sort_key_for_column(self, col: int, text: str): + value = (text or "").strip() + if col in ( + COL_INTERVAL, + COL_TIMEOUT, + COL_MAX_SIZE, + COL_FAILS, + ): + if value == "": + return -1 + try: + return int(value) + except Exception: + return value.lower() + if col in (COL_LAST_CHECKED, COL_LAST_UPDATED): + return timestamp_sort_key(value) + if col == COL_STATE: + return self._state_sort_value(value) + return value.lower() + + def _state_sort_value(self, value: str): + normalized = (value or "").strip().lower() + if normalized in ("updated", "not_modified"): + return 0, normalized + if normalized == "pending": + return 1, normalized + return 2, normalized + + def _update_row_sort_keys(self, row: int): + enabled_item = self.table.item(row, COL_ENABLED) + state_item = self.table.item(row, COL_STATE) + last_checked_item = self.table.item(row, COL_LAST_CHECKED) + if enabled_item is None: + return + + enabled_rank = ( + 0 if enabled_item.checkState() == QtCore.Qt.CheckState.Checked else 1 + ) + state_text = state_item.text() if state_item is not None else "" + state_rank = self._state_sort_value(state_text) + last_checked_text = ( + last_checked_item.text() if last_checked_item is not None else "" + ) + last_checked_rank = timestamp_sort_key(last_checked_text) + combined_rank = (enabled_rank, state_rank, last_checked_rank) + + enabled_item.setData(QtCore.Qt.ItemDataRole.UserRole, combined_rank) + + def _new_enabled_item(self, enabled: bool) -> SortableTableWidgetItem: + item = SortableTableWidgetItem("") + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState( + QtCore.Qt.CheckState.Checked if enabled else QtCore.Qt.CheckState.Unchecked + ) + item.setData(QtCore.Qt.ItemDataRole.UserRole, 1 if enabled else 0) + return item + def _sync_runtime_binding_state(self): runtime_plugin = ListSubscriptions.get_instance() if runtime_plugin is None: @@ -963,115 +716,189 @@ def _sync_runtime_binding_state(self): self._runtime_plugin = None self._set_runtime_state(active=False) + self._set_refresh_busy(False) return None - def load_action_file(self): - self._loading = True - self._set_status("") - self._reload_nodes() - self.table.setRowCount(0) - self.create_file_button.setVisible(True) - self.lists_dir_edit.setText(DEFAULT_LISTS_DIR) - self.enable_plugin_check.setChecked(False) - self._set_runtime_state(active=False) - self._global_defaults = GlobalDefaults.from_dict( - {}, lists_dir=DEFAULT_LISTS_DIR - ) - self._apply_defaults_to_widgets() + def _set_refresh_busy(self, busy: bool): + self.refresh_state_button.setEnabled(not busy) + self.refresh_now_button.setEnabled(not busy and len(self._selected_rows()) > 0) - if not os.path.exists(self._action_path): - self._set_status( - QC.translate( - "stats", "Action file not found. Click 'Create action file'." - ), - error=False, - ) - self._loading = False + def _track_refresh_keys(self, keys: set[str]): + if not keys: return + self._pending_refresh_keys.update(keys) + self._set_refresh_busy(True) + + def _clear_refresh_key(self, key: str): + self._pending_refresh_keys.discard(key) + self._active_refresh_keys.discard(key) + if not self._pending_refresh_keys and not self._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)] - try: - data = read_json_locked(self._action_path) - except Exception as e: - self._set_status( - QC.translate("stats", "Error reading action file: {0}").format(str(e)), - error=True, + def _runtime_download_message( + self, + event_name: RuntimeEvent | 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 == RuntimeEvent.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 == RuntimeEvent.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 == RuntimeEvent.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 load_action_file(self): + with self._sorting_suspended(): + self._loading = True + self._set_status("") + self._reload_nodes() + self.table.setRowCount(0) + self.create_file_button.setVisible(True) + self.lists_dir_edit.setText(DEFAULT_LISTS_DIR) + self.enable_plugin_check.setChecked(False) + self._set_runtime_state(active=False) + self._global_defaults = GlobalDefaults.from_dict( + {}, lists_dir=DEFAULT_LISTS_DIR ) - self._loading = False - return + self._apply_defaults_to_widgets() - action_model = MutableActionConfig.from_action_dict( - data, lists_dir=DEFAULT_LISTS_DIR - ) - self._global_defaults = action_model.plugin.defaults - self.enable_plugin_check.setChecked(action_model.enabled) - self._sync_runtime_binding_state() - self.lists_dir_edit.setText( - normalize_lists_dir(self._global_defaults.lists_dir) - ) - self._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 - ) + if not os.path.exists(self._action_path): + self._set_status( + QC.translate( + "stats", "Action file not found. Click 'Create action file'." + ), + error=False, + ) + self._loading = False + return - for sub in normalized_subs: - self._append_row(sub) + try: + data = read_json_locked(self._action_path) + except Exception as e: + self._set_status( + QC.translate("stats", "Error reading action file: {0}").format( + str(e) + ), + error=True, + ) + self._loading = False + return - self._loading = False - self.refresh_states() - self._update_selected_actions_state() - self.create_file_button.setVisible(False) - if migrated_legacy_group: - self.save_action_file() - self._set_status( - QC.translate( - "stats", - "Migrated legacy 'group' entries to 'groups' and auto-saved configuration.", - ), - error=False, - ) - return - if fixed_count > 0: - self._set_status( - QC.translate( - "stats", "Loaded configuration with normalized subscription fields." - ), - error=False, - ) - else: - self._set_status( - QC.translate("stats", "List subscriptions configuration loaded."), - error=False, + action_model = MutableActionConfig.from_action_dict( + data, lists_dir=DEFAULT_LISTS_DIR + ) + self._global_defaults = action_model.plugin.defaults + self.enable_plugin_check.setChecked(action_model.enabled) + self._sync_runtime_binding_state() + self.lists_dir_edit.setText( + normalize_lists_dir(self._global_defaults.lists_dir) + ) + self._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._append_row(sub) + + self._loading = False + self.refresh_states() + self._update_selected_actions_state() + self.create_file_button.setVisible(False) + if migrated_legacy_group: + self.save_action_file() + self._set_status( + QC.translate( + "stats", + "Migrated legacy 'group' entries to 'groups' and auto-saved configuration.", + ), + error=False, + ) + return + if fixed_count > 0: + self._set_status( + QC.translate( + "stats", + "Loaded configuration with normalized subscription fields.", + ), + error=False, + ) + else: + self._set_status( + QC.translate("stats", "List subscriptions configuration loaded."), + error=False, + ) + 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)): + 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._set_status(QC.translate("stats", "Runtime is already active.")) @@ -1080,7 +907,8 @@ def start_runtime_clicked(self): if not os.path.exists(self._action_path): self._set_status( QC.translate( - "stats", "Action file not found. Create and save the configuration first." + "stats", + "Action file not found. Create and save the configuration first.", ), error=True, ) @@ -1088,7 +916,9 @@ def start_runtime_clicked(self): if runtime_plugin is not None: self._bind_runtime_plugin(runtime_plugin) - self._set_runtime_state(active=None, text=QC.translate("stats", "Runtime: starting")) + self._set_runtime_state( + active=None, text=QC.translate("stats", "Runtime: starting") + ) try: runtime_plugin.signal_in.emit( { @@ -1128,13 +958,17 @@ def start_runtime_clicked(self): 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)): + if runtime_plugin is None or not bool( + getattr(runtime_plugin, "enabled", False) + ): self._set_runtime_state(active=False) self._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._set_runtime_state( + active=None, text=QC.translate("stats", "Runtime: stopping") + ) try: runtime_plugin.signal_in.emit( { @@ -1151,7 +985,9 @@ def stop_runtime_clicked(self): 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)): + if runtime_plugin is None or not bool( + getattr(runtime_plugin, "enabled", False) + ): self.load_action_file() return @@ -1168,9 +1004,7 @@ def reload_runtime_and_config(self): except Exception: self._pending_runtime_reload = None self._set_status( - QC.translate( - "stats", "Runtime reload failed to start. Restart UI." - ), + QC.translate("stats", "Runtime reload failed to start. Restart UI."), error=True, ) @@ -1255,7 +1089,7 @@ def save_action_file(self): for row, sub in enumerate(normalized_subscriptions): self._set_text_item(row, COL_NAME, sub.name) - self._set_text_item(row, COL_FILENAME, self._safe_filename(sub.filename)) + self._set_text_item(row, COL_FILENAME, safe_filename(sub.filename)) try: write_json_atomic_locked(self._action_path, action) @@ -1274,94 +1108,98 @@ def save_action_file(self): ) def refresh_states(self): - lists_dir = normalize_lists_dir( - self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR - ) - for row in range(self.table.rowCount()): - filename_item = self.table.item(row, COL_FILENAME) - enabled_item = self.table.item(row, COL_ENABLED) - if filename_item is None or enabled_item is None: - continue + with self._sorting_suspended(): + lists_dir = normalize_lists_dir( + self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + for row in range(self.table.rowCount()): + filename_item = self.table.item(row, COL_FILENAME) + enabled_item = self.table.item(row, COL_ENABLED) + if filename_item is None or enabled_item is None: + continue - filename = self._safe_filename(filename_item.text()) - list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() - enabled = enabled_item.checkState() == QtCore.Qt.CheckState.Checked - list_path = self._list_file_path(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 = {} - 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 "" - fg_color: QtGui.QColor - - if not enabled: - state = "disabled" - fg_color = self._state_text_color("disabled") - elif not file_exists: - # New/manual subscriptions may not be downloaded yet. - # Expose that as pending instead of an error-like missing state. - if not meta_exists or last_result in ("never", "", "busy"): - state = "pending" - fg_color = self._state_text_color("pending") + filename = safe_filename(filename_item.text()) + list_type = ( + (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() + ) + enabled = enabled_item.checkState() == QtCore.Qt.CheckState.Checked + list_path = list_file_path(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 = {} + 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 "" + fg_color: QtGui.QColor + + if not enabled: + state = "disabled" + fg_color = self._state_text_color("disabled") + elif not file_exists: + # New/manual subscriptions may not be downloaded yet. + # Expose that as pending instead of an error-like missing state. + if not meta_exists or last_result in ("never", "", "busy"): + state = "pending" + fg_color = self._state_text_color("pending") + else: + state = "missing" + fg_color = self._state_text_color("missing") + elif last_result in ("updated", "not_modified"): + state = last_result + fg_color = self._state_text_color(last_result) + elif last_result in ( + "error", + "write_error", + "request_error", + "unexpected_error", + "bad_format", + "too_large", + ): + state = last_result + fg_color = self._state_text_color(last_result) + elif last_result == "busy": + state = "busy" + fg_color = self._state_text_color("busy") else: - state = "missing" - fg_color = self._state_text_color("missing") - elif last_result in ("updated", "not_modified"): - state = last_result - fg_color = self._state_text_color(last_result) - elif last_result in ( - "error", - "write_error", - "request_error", - "unexpected_error", - "bad_format", - "too_large", - ): - state = last_result - fg_color = self._state_text_color(last_result) - elif last_result == "busy": - state = "busy" - fg_color = self._state_text_color("busy") - else: - state = last_result - fg_color = self._state_text_color("other") + state = last_result + fg_color = self._state_text_color("other") - self._set_text_item( - row, COL_FILE, "yes" if file_exists else "no", editable=False - ) - self._set_text_item( - row, COL_META, "yes" if meta_exists else "no", editable=False - ) - self._set_text_item(row, COL_STATE, state, editable=False) - self._set_text_item(row, COL_LAST_CHECKED, last_checked, editable=False) - self._set_text_item(row, COL_LAST_UPDATED, last_updated, editable=False) - self._set_text_item(row, COL_FAILS, fail_count, editable=False) - self._set_text_item(row, COL_ERROR, last_error, editable=False) - - for col in ( - COL_FILE, - COL_META, - COL_STATE, - COL_LAST_CHECKED, - COL_LAST_UPDATED, - COL_FAILS, - COL_ERROR, - ): - item = self.table.item(row, col) - if item is not None: - item.setForeground(fg_color) + self._set_text_item( + row, COL_FILE, "yes" if file_exists else "no", editable=False + ) + self._set_text_item( + row, COL_META, "yes" if meta_exists else "no", editable=False + ) + self._set_text_item(row, COL_STATE, state, editable=False) + self._set_text_item(row, COL_LAST_CHECKED, last_checked, editable=False) + self._set_text_item(row, COL_LAST_UPDATED, last_updated, editable=False) + self._set_text_item(row, COL_FAILS, fail_count, editable=False) + self._set_text_item(row, COL_ERROR, last_error, editable=False) + + for col in ( + COL_FILE, + COL_META, + COL_STATE, + COL_LAST_CHECKED, + COL_LAST_UPDATED, + COL_FAILS, + COL_ERROR, + ): + item = self.table.item(row, col) + if item is not None: + item.setForeground(fg_color) + self._update_row_sort_keys(row) def _state_text_color(self, state: str): palette = self.table.palette() @@ -1419,9 +1257,10 @@ def add_subscription_row(self): return sub = dlg.subscription_spec() - self._append_row(sub) - row = self.table.rowCount() - 1 - _, changed = self._ensure_row_final_filename(row) + with self._sorting_suspended(): + self._append_row(sub) + row = self.table.rowCount() - 1 + _, changed = self._ensure_row_final_filename(row) if changed: self.refresh_states() @@ -1437,14 +1276,11 @@ def edit_selected_subscription(self): QC.translate("stats", "Select a subscription row first."), error=True ) return - - enabled_item = self.table.item(row, COL_ENABLED) - if enabled_item is None: - enabled_item = QtWidgets.QTableWidgetItem("") - enabled_item.setFlags( - enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable - ) - self.table.setItem(row, COL_ENABLED, enabled_item) + with self._sorting_suspended(): + enabled_item = self.table.item(row, COL_ENABLED) + if enabled_item is None: + enabled_item = self._new_enabled_item(False) + self.table.setItem(row, COL_ENABLED, enabled_item) interval_ok, interval_val = self._optional_int_from_text( self._cell_text(row, COL_INTERVAL), "Interval", row=row @@ -1465,17 +1301,11 @@ def edit_selected_subscription(self): format=self._cell_text(row, COL_FORMAT) or "hosts", groups=normalize_groups(self._cell_text(row, COL_GROUP)), interval=interval_val, - interval_units=self._optional_unit_from_text( - self._cell_text(row, COL_INTERVAL_UNITS) - ), + interval_units=strip_or_none(self._cell_text(row, COL_INTERVAL_UNITS)), timeout=timeout_val, - timeout_units=self._optional_unit_from_text( - self._cell_text(row, COL_TIMEOUT_UNITS) - ), + timeout_units=strip_or_none(self._cell_text(row, COL_TIMEOUT_UNITS)), max_size=max_size_val, - max_size_units=self._optional_unit_from_text( - self._cell_text(row, COL_MAX_SIZE_UNITS) - ), + max_size_units=strip_or_none(self._cell_text(row, COL_MAX_SIZE_UNITS)), ) meta = self._row_meta_snapshot(row) dlg = SubscriptionDialog( @@ -1490,39 +1320,47 @@ def edit_selected_subscription(self): return updated = dlg.subscription_spec() - enabled_item = self.table.item(row, COL_ENABLED) - if enabled_item is None: - enabled_item = QtWidgets.QTableWidgetItem("") - enabled_item.setFlags( - enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable - ) - self.table.setItem(row, COL_ENABLED, enabled_item) - enabled_item.setCheckState( - QtCore.Qt.CheckState.Checked - if bool(updated.enabled) - else QtCore.Qt.CheckState.Unchecked - ) - self._set_text_item(row, COL_NAME, updated.name) - self._set_text_item(row, COL_URL, updated.url) - self._set_text_item(row, COL_FILENAME, self._safe_filename(updated.filename)) - self._set_text_item(row, COL_FORMAT, updated.format) - self._set_text_item(row, COL_GROUP, ", ".join(normalize_groups(updated.groups))) - self._set_text_item(row, COL_INTERVAL, self._to_str(updated.interval)) - interval_units_val = self._to_str(updated.interval_units) - self._set_text_item(row, COL_INTERVAL_UNITS, interval_units_val) - self._set_text_item(row, COL_TIMEOUT, self._to_str(updated.timeout)) - timeout_units_val = self._to_str(updated.timeout_units) - self._set_text_item(row, COL_TIMEOUT_UNITS, timeout_units_val) - self._set_text_item(row, COL_MAX_SIZE, self._to_str(updated.max_size)) - max_size_units_val = self._to_str(updated.max_size_units) - self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units_val) - self._set_units_combo( - row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units_val - ) - self._set_units_combo(row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units_val) - self._set_units_combo(row, COL_MAX_SIZE_UNITS, SIZE_UNITS, max_size_units_val) + with self._sorting_suspended(): + enabled_item = self.table.item(row, COL_ENABLED) + if enabled_item is None: + enabled_item = self._new_enabled_item(False) + self.table.setItem(row, 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._set_text_item(row, COL_NAME, updated.name) + self._set_text_item(row, COL_URL, updated.url) + self._set_text_item(row, COL_FILENAME, safe_filename(updated.filename)) + self._set_text_item(row, COL_FORMAT, updated.format) + self._set_text_item( + row, COL_GROUP, ", ".join(normalize_groups(updated.groups)) + ) + self._set_text_item(row, COL_INTERVAL, display_str(updated.interval)) + interval_units_val = display_str(updated.interval_units) + self._set_text_item(row, COL_INTERVAL_UNITS, interval_units_val) + self._set_text_item(row, COL_TIMEOUT, display_str(updated.timeout)) + timeout_units_val = display_str(updated.timeout_units) + self._set_text_item(row, COL_TIMEOUT_UNITS, timeout_units_val) + self._set_text_item(row, COL_MAX_SIZE, display_str(updated.max_size)) + max_size_units_val = display_str(updated.max_size_units) + self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units_val) + self._set_units_combo( + row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units_val + ) + self._set_units_combo( + row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units_val + ) + self._set_units_combo( + row, COL_MAX_SIZE_UNITS, SIZE_UNITS, max_size_units_val + ) - _, changed = self._ensure_row_final_filename(row) + _, changed = self._ensure_row_final_filename(row) + self._update_row_sort_keys(row) self.save_action_file() self.refresh_states() if changed: @@ -1575,12 +1413,38 @@ def _selected_rows(self): return [] return sorted({i.row() for i in idx.selectedRows()}) + def _handle_table_clicked(self, index: QtCore.QModelIndex): + if not index.isValid() or index.column() != COL_ENABLED: + return + item = self.table.item(index.row(), COL_ENABLED) + if item is None: + return + checked = item.checkState() != QtCore.Qt.CheckState.Checked + item.setCheckState( + QtCore.Qt.CheckState.Checked if checked else QtCore.Qt.CheckState.Unchecked + ) + self._update_row_sort_keys(index.row()) + header = self.table.horizontalHeader() + if ( + self.table.isSortingEnabled() + and header is not None + and header.sortIndicatorSection() + in (COL_ENABLED, COL_STATE, COL_LAST_CHECKED) + ): + self.table.sortItems( + header.sortIndicatorSection(), header.sortIndicatorOrder() + ) + def _update_selected_actions_state(self): count = len(self._selected_rows()) has_selection = count > 0 self.edit_sub_button.setEnabled(has_selection) self.remove_sub_button.setEnabled(has_selection) - self.refresh_now_button.setEnabled(has_selection) + self.refresh_now_button.setEnabled( + has_selection + and not self._pending_refresh_keys + and not self._active_refresh_keys + ) self.create_rule_button.setEnabled(has_selection) def _open_table_context_menu(self, pos: QtCore.QPoint): @@ -1602,7 +1466,12 @@ def _open_table_context_menu(self, pos: QtCore.QPoint): 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")) - chosen = menu.exec(viewport.mapToGlobal(pos)) + chosen = QtWidgets.QMenu.exec( + menu.actions(), + viewport.mapToGlobal(pos), + None, + menu, + ) if chosen is act_edit: self.edit_selected_subscription() elif chosen is act_remove: @@ -1617,7 +1486,12 @@ def _open_table_context_menu(self, pos: QtCore.QPoint): 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")) - chosen = menu.exec(viewport.mapToGlobal(pos)) + chosen = QtWidgets.QMenu.exec( + menu.actions(), + viewport.mapToGlobal(pos), + None, + menu, + ) if chosen is act_edit: self._bulk_edit(rows) elif chosen is act_remove: @@ -1630,58 +1504,62 @@ def _open_table_context_menu(self, pos: QtCore.QPoint): def _bulk_edit(self, rows: list[int]): if not rows: return - dlg = BulkEditDialog(self, self._global_defaults, groups=self._known_groups()) + dlg = BulkEditDialog( + self, + self._global_defaults, + groups=self._known_groups(), + selected_count=len(rows), + ) if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: return values = dlg.values() - for row in rows: - if values.get("enabled") is not None: - enabled_item = self.table.item(row, COL_ENABLED) - if enabled_item is None: - enabled_item = QtWidgets.QTableWidgetItem("") - enabled_item.setFlags( - enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable + with self._sorting_suspended(): + for row in rows: + if values.get("enabled") is not None: + enabled_item = self.table.item(row, COL_ENABLED) + if enabled_item is None: + enabled_item = self._new_enabled_item(False) + self.table.setItem(row, COL_ENABLED, enabled_item) + enabled_item.setCheckState( + QtCore.Qt.CheckState.Checked + if bool(values["enabled"]) + else QtCore.Qt.CheckState.Unchecked ) - self.table.setItem(row, 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._set_text_item( - row, COL_GROUP, ", ".join(normalize_groups(values["groups"])) - ) - if values.get("format") is not None: - self._set_text_item(row, COL_FORMAT, str(values["format"])) - if values.get("apply_interval"): - self._set_text_item( - row, COL_INTERVAL, self._to_str(values.get("interval")) - ) - interval_units = self._to_str(values.get("interval_units")) - self._set_text_item(row, COL_INTERVAL_UNITS, interval_units) - self._set_units_combo( - row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units - ) - if values.get("apply_timeout"): - self._set_text_item( - row, COL_TIMEOUT, self._to_str(values.get("timeout")) - ) - timeout_units = self._to_str(values.get("timeout_units")) - self._set_text_item(row, COL_TIMEOUT_UNITS, timeout_units) - self._set_units_combo( - row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units - ) - if values.get("apply_max_size"): - self._set_text_item( - row, COL_MAX_SIZE, self._to_str(values.get("max_size")) - ) - max_size_units = self._to_str(values.get("max_size_units")) - self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units) - self._set_units_combo( - row, COL_MAX_SIZE_UNITS, SIZE_UNITS, max_size_units - ) - self._ensure_row_final_filename(row) + if values.get("groups") is not None: + self._set_text_item( + row, COL_GROUP, ", ".join(normalize_groups(values["groups"])) + ) + if values.get("format") is not None: + self._set_text_item(row, COL_FORMAT, str(values["format"])) + if values.get("apply_interval"): + self._set_text_item( + row, COL_INTERVAL, display_str(values.get("interval")) + ) + interval_units = display_str(values.get("interval_units")) + self._set_text_item(row, COL_INTERVAL_UNITS, interval_units) + self._set_units_combo( + row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units + ) + if values.get("apply_timeout"): + self._set_text_item( + row, COL_TIMEOUT, display_str(values.get("timeout")) + ) + timeout_units = display_str(values.get("timeout_units")) + self._set_text_item(row, COL_TIMEOUT_UNITS, timeout_units) + self._set_units_combo( + row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units + ) + if values.get("apply_max_size"): + self._set_text_item( + row, COL_MAX_SIZE, display_str(values.get("max_size")) + ) + max_size_units = display_str(values.get("max_size_units")) + self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units) + self._set_units_combo( + row, COL_MAX_SIZE_UNITS, SIZE_UNITS, max_size_units + ) + self._ensure_row_final_filename(row) + self._update_row_sort_keys(row) self.save_action_file() self.refresh_states() self._set_status( @@ -1773,15 +1651,15 @@ def refresh_selected_now(self): format=self._cell_text(row, COL_FORMAT) or "hosts", groups=normalize_groups(self._cell_text(row, COL_GROUP)), interval=interval_val, - interval_units=self._optional_unit_from_text( + interval_units=strip_or_none( self._cell_text(row, COL_INTERVAL_UNITS) ), timeout=timeout_val, - timeout_units=self._optional_unit_from_text( + timeout_units=strip_or_none( self._cell_text(row, COL_TIMEOUT_UNITS) ), max_size=max_size_val, - max_size_units=self._optional_unit_from_text( + max_size_units=strip_or_none( self._cell_text(row, COL_MAX_SIZE_UNITS) ), ) @@ -1801,37 +1679,39 @@ def refresh_selected_now(self): ) return target_sub = row_sub - + if target_sub is None: + self._set_status(QC.translate("stats", "Internal error: target_sub is None."), error=True) + return list_path, _ = plug._paths(target_sub) refresh_targets.append((target_sub, list_path)) - def _run_refresh(): - try: - for target_sub, _list_path in refresh_targets: - key = plug._sub_key(target_sub) - logger.warning( - "list_subscriptions.gui: manual refresh start key=%s name='%s' url='%s' file='%s'", - key, - target_sub.name, - target_sub.url, - target_sub.filename, + refresh_keys = {plug._sub_key(target_sub) for target_sub, _ in refresh_targets} + self._track_refresh_keys(refresh_keys) + plug.signal_in.emit( + { + "plugin": plug.get_name(), + "signal": plug.REFRESH_SUBSCRIPTIONS_SIGNAL, + "action_path": self._action_path, + "source": "manual_refresh", + "items": [ + subscription_payload_dict( + enabled=target_sub.enabled, + name=target_sub.name, + url=target_sub.url, + filename=target_sub.filename, + list_type=target_sub.format, + groups=list(target_sub.groups), + interval=target_sub.interval, + interval_units=target_sub.interval_units, + timeout=target_sub.timeout, + timeout_units=target_sub.timeout_units, + max_size=target_sub.max_size, + max_size_units=target_sub.max_size_units, ) - try: - if hasattr(plug, "force_refresh_subscription"): - plug.force_refresh_subscription(target_sub) - else: - # fallback for older plugin objects - plug.download(key, target_sub) - finally: - logger.warning( - "list_subscriptions.gui: manual refresh finished key=%s", - key, - ) - finally: - self._download_finished.emit() - - th = threading.Thread(target=_run_refresh, daemon=True) - th.start() + for target_sub, _ in refresh_targets + ], + } + ) if len(refresh_targets) == 1: self._set_status( QC.translate( @@ -1859,32 +1739,136 @@ def refresh_all_now(self): ) return - def _run_all_refresh(): + rows = list(range(self.table.rowCount())) + if not rows: + self._set_status( + QC.translate("stats", "No subscriptions available to refresh."), + error=True, + ) + return + + filename_changed = False + for row in rows: + url = self._cell_text(row, COL_URL) + filename, row_filename_changed = self._ensure_row_final_filename(row) + if url == "" or filename == "": + self._set_status( + QC.translate( + "stats", "URL and filename cannot be empty (row {0})." + ).format(row + 1), + error=True, + ) + return + filename_changed = filename_changed or row_filename_changed + + if filename_changed: + self.save_action_file() + _, _, plug = self._find_loaded_action() + if plug is None: + self._set_status( + QC.translate( + "stats", "Plugin is not loaded. Save configuration first." + ), + error=True, + ) + return + + refresh_targets: list[SubscriptionSpec] = [] + for row in rows: + url = self._cell_text(row, COL_URL) + filename = self._cell_text(row, COL_FILENAME) + target_sub: SubscriptionSpec | None = None try: - subs: list[SubscriptionSpec] = [] + for sub in plug._config.subscriptions: + if sub.url == url and sub.filename == filename: + target_sub = sub + break + except Exception: + target_sub = None + + if target_sub is None: try: - subs = list(getattr(plug._config, "subscriptions", [])) + enabled_item = self.table.item(row, COL_ENABLED) + interval_ok, interval_val = self._optional_int_from_text( + self._cell_text(row, COL_INTERVAL), "Interval", row=row + ) + timeout_ok, timeout_val = self._optional_int_from_text( + self._cell_text(row, COL_TIMEOUT), "Timeout", row=row + ) + max_size_ok, max_size_val = self._optional_int_from_text( + self._cell_text(row, COL_MAX_SIZE), "Max size", row=row + ) + if not interval_ok or not timeout_ok or not max_size_ok: + return + row_sub_edit = MutableSubscriptionSpec( + enabled=( + enabled_item is None + or enabled_item.checkState() == QtCore.Qt.CheckState.Checked + ), + name=self._cell_text(row, COL_NAME), + url=url, + filename=filename, + format=self._cell_text(row, COL_FORMAT) or "hosts", + groups=normalize_groups(self._cell_text(row, COL_GROUP)), + interval=interval_val, + interval_units=strip_or_none( + self._cell_text(row, COL_INTERVAL_UNITS) + ), + timeout=timeout_val, + timeout_units=strip_or_none( + self._cell_text(row, COL_TIMEOUT_UNITS) + ), + max_size=max_size_val, + max_size_units=strip_or_none( + self._cell_text(row, COL_MAX_SIZE_UNITS) + ), + ) + target_sub = SubscriptionSpec.from_dict( + row_sub_edit.to_dict(), + plug._config.defaults, + ) except Exception: - subs = [] - for sub in subs: - if not getattr(sub, "enabled", True): - continue - try: - if hasattr(plug, "force_refresh_subscription"): - plug.force_refresh_subscription(sub) - else: - key = plug._sub_key(sub) - plug.download(key, sub) - except Exception: - continue - finally: - self._download_finished.emit() + target_sub = None + if target_sub is not None: + refresh_targets.append(target_sub) + + if not refresh_targets: + self._set_status( + QC.translate("stats", "No subscriptions available to refresh."), + error=True, + ) + return - th = threading.Thread(target=_run_all_refresh, daemon=True) - th.start() + refresh_keys = {plug._sub_key(sub) for sub in refresh_targets} + self._track_refresh_keys(refresh_keys) + plug.signal_in.emit( + { + "plugin": plug.get_name(), + "signal": plug.REFRESH_SUBSCRIPTIONS_SIGNAL, + "action_path": self._action_path, + "source": "manual_refresh", + "items": [ + subscription_payload_dict( + enabled=sub.enabled, + name=sub.name, + url=sub.url, + filename=sub.filename, + list_type=sub.format, + groups=list(sub.groups), + 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, + ) + for sub in refresh_targets + ], + } + ) self._set_status( QC.translate( - "stats", "Bulk refresh triggered for all enabled subscriptions." + "stats", "Bulk refresh triggered for all listed subscriptions." ), error=False, ) @@ -1920,7 +1904,7 @@ def create_rule_from_selected(self): self.save_action_file() list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() - list_path = self._list_file_path(lists_dir, filename, list_type) + list_path = list_file_path(lists_dir, filename, list_type) rule_dir = self._prepare_rule_dir( url, filename, @@ -1930,7 +1914,7 @@ def create_rule_from_selected(self): ) if rule_dir is None: return - rule_token = os.path.splitext(self._safe_filename(filename))[0] + rule_token = os.path.splitext(safe_filename(filename))[0] rule_name = f"00-blocklist-{rule_token}" desc = f"From list subscription : {filename}" else: @@ -2013,9 +1997,7 @@ def create_global_rule(self): if self._rules_dialog.ruleNameEdit.text().strip() == "": self._rules_dialog.ruleNameEdit.setText(rule_name) if self._rules_dialog.ruleDescEdit.toPlainText().strip() == "": - self._rules_dialog.ruleDescEdit.setPlainText( - "From list subscription : all" - ) + self._rules_dialog.ruleDescEdit.setPlainText("From list subscription : all") self._rules_dialog.raise_() self._rules_dialog.activateWindow() self._set_status( @@ -2129,7 +2111,7 @@ def _prepare_rule_dir( list_type: str, ): _ = (url, list_path) - rule_dir = self._subscription_rule_dir( + rule_dir = subscription_rule_dir( lists_dir, filename, list_type, @@ -2146,32 +2128,6 @@ def _prepare_rule_dir( ) return None - def _subscription_dirname(self, filename: str, list_type: str): - safe_name = self._safe_filename(filename) - if safe_name == "": - safe_name = "subscription.list" - safe_name = ensure_filename_type_suffix(safe_name, list_type) - base, _ext = os.path.splitext(safe_name) - suffix = f"-{(list_type or 'hosts').strip().lower()}" - sub_dirname = base if base else "subscription" - if not sub_dirname.lower().endswith(suffix): - sub_dirname = f"{sub_dirname}{suffix}" - return sub_dirname - - def _subscription_rule_dir(self, lists_dir: str, filename: str, list_type: str): - return os.path.join( - lists_dir, - "rules.list.d", - self._subscription_dirname(filename, list_type), - ) - - def _list_file_path(self, lists_dir: str, filename: str, list_type: str): - safe_name = self._safe_filename(filename) - if safe_name == "": - safe_name = "subscription.list" - safe_name = ensure_filename_type_suffix(safe_name, list_type) - return os.path.join(lists_dir, "sources.list.d", safe_name) - def _apply_runtime_state(self, enabled: bool): old_key, _old_action, old_plugin = self._find_loaded_action() runtime_plugin = ListSubscriptions.get_instance() @@ -2280,6 +2236,7 @@ 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: @@ -2288,7 +2245,30 @@ def _handle_runtime_event(self, event: dict[str, Any]): event_name = None else: event_name = None - is_error = event_name == RuntimeEvent.RUNTIME_ERROR + is_error = event_name in ( + RuntimeEvent.RUNTIME_ERROR, + RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEvent.FILE_SAVE_ERROR, + RuntimeEvent.FILE_LOAD_ERROR, + ) + if event_name == RuntimeEvent.DOWNLOAD_STARTED: + for key in event_keys: + if key in self._pending_refresh_keys: + self._pending_refresh_keys.discard(key) + self._active_refresh_keys.add(key) + self._set_refresh_busy(True) + elif event_name in ( + RuntimeEvent.DOWNLOAD_FINISHED, + RuntimeEvent.DOWNLOAD_FAILED, + ): + for key in event_keys: + self._clear_refresh_key(key) + if event_name in ( + RuntimeEvent.DOWNLOAD_FINISHED, + RuntimeEvent.DOWNLOAD_FAILED, + ): + self.refresh_states() + self._update_selected_actions_state() if event_name == RuntimeEvent.RUNTIME_ENABLED: self._set_runtime_state(active=True) elif event_name in ( @@ -2302,7 +2282,9 @@ def _handle_runtime_event(self, event: dict[str, Any]): text=QC.translate("stats", "Runtime: reloading"), ) elif is_error: - self._set_runtime_state(active=None, text=QC.translate("stats", "Runtime: error")) + self._set_runtime_state( + active=None, text=QC.translate("stats", "Runtime: error") + ) if self._pending_runtime_reload == "waiting_config_reload": if event_name == RuntimeEvent.CONFIG_RELOADED: self._pending_runtime_reload = None @@ -2314,6 +2296,12 @@ def _handle_runtime_event(self, event: dict[str, Any]): message = QC.translate("stats", "Plugin runtime event: {0}").format( str(event_value or "unknown") ) + if event_name in ( + RuntimeEvent.DOWNLOAD_STARTED, + RuntimeEvent.DOWNLOAD_FINISHED, + RuntimeEvent.DOWNLOAD_FAILED, + ): + message = self._runtime_download_message(event_name, payload, message) if is_error and error_detail != "": message = f"{message} {error_detail}".strip() self._set_status(message, error=is_error) @@ -2365,7 +2353,7 @@ def _collect_subscriptions(self): url = self._cell_text(row, COL_URL) list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() groups = normalize_groups(self._cell_text(row, COL_GROUP)) - filename = self._safe_filename(self._cell_text(row, COL_FILENAME)) + filename = safe_filename(self._cell_text(row, COL_FILENAME)) if filename == "": filename = self._guess_filename(name, url) if filename != "": @@ -2392,11 +2380,11 @@ def _collect_subscriptions(self): format=list_type, groups=groups, interval=interval_val, - interval_units=self._optional_unit_from_text(interval_units), + interval_units=strip_or_none(interval_units), timeout=timeout_val, - timeout_units=self._optional_unit_from_text(timeout_units), + timeout_units=strip_or_none(timeout_units), max_size=max_size_val, - max_size_units=self._optional_unit_from_text(max_size_units), + max_size_units=strip_or_none(max_size_units), ) if sub.url == "" or sub.filename == "": self._set_status( @@ -2421,9 +2409,9 @@ def _row_meta_snapshot(self, row: int): lists_dir = normalize_lists_dir( self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR ) - filename = self._safe_filename(self._cell_text(row, COL_FILENAME)) + filename = safe_filename(self._cell_text(row, COL_FILENAME)) list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() - list_path = self._list_file_path(lists_dir, filename, list_type) + list_path = list_file_path(lists_dir, filename, list_type) meta_path = list_path + ".meta.json" file_exists = os.path.exists(list_path) @@ -2460,7 +2448,7 @@ def _ensure_row_final_filename(self, row: int): name = self._cell_text(row, COL_NAME) url = self._cell_text(row, COL_URL) list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() - original = self._safe_filename(self._cell_text(row, COL_FILENAME)) + original = safe_filename(self._cell_text(row, COL_FILENAME)) final_name = original changed = False @@ -2477,7 +2465,7 @@ def _ensure_row_final_filename(self, row: int): for i in range(self.table.rowCount()): if i == row: continue - other = self._safe_filename(self._cell_text(i, COL_FILENAME)) + other = safe_filename(self._cell_text(i, COL_FILENAME)) if other != "": existing.add(other) if key in existing: @@ -2498,21 +2486,12 @@ def _ensure_row_final_filename(self, row: int): def _append_row(self, sub: MutableSubscriptionSpec): row = self.table.rowCount() self.table.insertRow(row) - - enabled_item = QtWidgets.QTableWidgetItem("") - enabled_item.setFlags( - enabled_item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable - ) - enabled_item.setCheckState( - QtCore.Qt.CheckState.Checked - if bool(sub.enabled) - else QtCore.Qt.CheckState.Unchecked - ) + enabled_item = self._new_enabled_item(bool(sub.enabled)) self.table.setItem(row, COL_ENABLED, enabled_item) self._set_text_item(row, COL_NAME, str(sub.name)) self._set_text_item(row, COL_URL, str(sub.url)) - self._set_text_item(row, COL_FILENAME, self._safe_filename(sub.filename)) + self._set_text_item(row, COL_FILENAME, safe_filename(sub.filename)) self._set_text_item(row, COL_FORMAT, str(sub.format)) groups = normalize_groups(sub.groups) self._set_text_item(row, COL_GROUP, ", ".join(groups)) @@ -2522,20 +2501,20 @@ def _append_row(self, sub: MutableSubscriptionSpec): interval_units = sub.interval_units timeout_units = sub.timeout_units max_size_units = sub.max_size_units - self._set_text_item(row, COL_INTERVAL, self._to_str(interval)) - self._set_text_item(row, COL_INTERVAL_UNITS, self._to_str(interval_units)) - self._set_text_item(row, COL_TIMEOUT, self._to_str(timeout)) - self._set_text_item(row, COL_TIMEOUT_UNITS, self._to_str(timeout_units)) - self._set_text_item(row, COL_MAX_SIZE, self._to_str(max_size)) - self._set_text_item(row, COL_MAX_SIZE_UNITS, self._to_str(max_size_units)) + self._set_text_item(row, COL_INTERVAL, display_str(interval)) + self._set_text_item(row, COL_INTERVAL_UNITS, display_str(interval_units)) + self._set_text_item(row, COL_TIMEOUT, display_str(timeout)) + self._set_text_item(row, COL_TIMEOUT_UNITS, display_str(timeout_units)) + self._set_text_item(row, COL_MAX_SIZE, display_str(max_size)) + self._set_text_item(row, COL_MAX_SIZE_UNITS, display_str(max_size_units)) self._set_units_combo( - row, COL_INTERVAL_UNITS, INTERVAL_UNITS, self._to_str(interval_units) + row, COL_INTERVAL_UNITS, INTERVAL_UNITS, display_str(interval_units) ) self._set_units_combo( - row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, self._to_str(timeout_units) + row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, display_str(timeout_units) ) self._set_units_combo( - row, COL_MAX_SIZE_UNITS, SIZE_UNITS, self._to_str(max_size_units) + row, COL_MAX_SIZE_UNITS, SIZE_UNITS, display_str(max_size_units) ) self._set_text_item(row, COL_FILE, "", editable=False) @@ -2545,6 +2524,7 @@ def _append_row(self, sub: MutableSubscriptionSpec): self._set_text_item(row, COL_LAST_UPDATED, "", editable=False) self._set_text_item(row, COL_FAILS, "", editable=False) self._set_text_item(row, COL_ERROR, "", editable=False) + self._update_row_sort_keys(row) def _reload_nodes(self): self.nodes_combo.blockSignals(True) @@ -2556,31 +2536,24 @@ def _reload_nodes(self): def _apply_defaults_to_widgets(self): self.default_interval_spin.setValue(max(1, int(self._global_defaults.interval))) self.default_interval_units.setCurrentText( - self._normalize_unit( + normalize_unit( self._global_defaults.interval_units, INTERVAL_UNITS, "hours" ) ) self.default_timeout_spin.setValue(max(1, int(self._global_defaults.timeout))) self.default_timeout_units.setCurrentText( - self._normalize_unit( + normalize_unit( self._global_defaults.timeout_units, TIMEOUT_UNITS, "seconds" ) ) self.default_max_size_spin.setValue(max(1, int(self._global_defaults.max_size))) self.default_max_size_units.setCurrentText( - self._normalize_unit(self._global_defaults.max_size_units, SIZE_UNITS, "MB") + normalize_unit(self._global_defaults.max_size_units, SIZE_UNITS, "MB") ) self.default_user_agent.setText( (self._global_defaults.user_agent or "").strip() ) - def _normalize_unit(self, value: str, allowed: tuple[str, ...], fallback: str): - normalized = (value or "").strip().lower() - for unit in allowed: - if unit.lower() == normalized: - return unit - return fallback - def _set_units_combo( self, row: int, col: int, allowed: tuple[str, ...], value: str | None ): @@ -2596,23 +2569,12 @@ def _set_units_combo( if value is None or value.strip() == "": combo.setCurrentIndex(0) else: - combo.setCurrentText(self._normalize_unit(value, allowed, allowed[0])) + combo.setCurrentText(normalize_unit(value, allowed, allowed[0])) self.table.setCellWidget(row, col, combo) - def _safe_filename(self, value: Any): - return os.path.basename((self._to_str(value) or "").strip()) - def _guess_filename(self, name: str, url: str): from_header = self._filename_from_headers(url) - if from_header != "": - return self._safe_filename(from_header) - - from_url = self._filename_from_url(url) - if from_url != "": - return self._safe_filename(from_url) - - slug = self._slugify_name(name) - return self._safe_filename(slug) + return safe_filename(derive_filename(name, url, "", from_header)) def _filename_from_headers(self, url: str): if (url or "").strip() == "": @@ -2621,54 +2583,21 @@ def _filename_from_headers(self, url: str): r = requests.head(url, allow_redirects=True, timeout=5) cd = r.headers.get("Content-Disposition", "") if cd: - # Prefer RFC 5987 filename*; fallback to filename - 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 = requests.utils.parse_dict_header( - ";".join(cd.split(";")[1:]) - ) - raw = params.get("filename") - if raw: - filename = requests.utils.unquote_header_value(str(raw)).strip() - if filename: - return unquote(str(filename)).strip() + return filename_from_content_disposition(cd) except Exception: return "" return "" - def _filename_from_url(self, url: str): - u = (url or "").strip() - if u == "": - return "" - try: - parsed = urlparse(u) - base = os.path.basename(unquote(parsed.path or "")) - return base.strip() - except Exception: - return "" - - def _slugify_name(self, name: str): - 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 slug - def _set_text_item(self, row: int, col: int, text: str, editable: bool = True): item = self.table.item(row, col) if item is None: - item = QtWidgets.QTableWidgetItem() + item = SortableTableWidgetItem() self.table.setItem(row, col, item) item.setText(text) + item.setData( + QtCore.Qt.ItemDataRole.UserRole, + self._sort_key_for_column(col, text), + ) if editable: item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsEditable) else: @@ -2693,10 +2622,6 @@ def _optional_int_from_text( return False, None return True, parsed - def _optional_unit_from_text(self, value: Any): - text = (str(value or "")).strip() - return text or None - def _to_int_or_keep(self, value: Any, field_name: str, row: int | None = None): try: parsed = int(value) @@ -2728,11 +2653,6 @@ def _to_int_or_keep(self, value: Any, field_name: str, row: int | None = None): return None return parsed - def _to_str(self, value: Any): - if value is None: - return "" - return str(value) - def _set_status(self, msg: str, error: bool = False): self.status_label.setStyleSheet("color: red;" if error else "color: green;") self.status_label.setText(msg) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py new file mode 100644 index 0000000000..8888ef08e3 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py @@ -0,0 +1,595 @@ +import logging +import os +import sys +import threading +from typing import Any, TYPE_CHECKING, Final + +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 # noqa: F401 + from PyQt5.QtCore import QCoreApplication as QC + + load_ui_type = uic.loadUiType + +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, + deslugify_filename, + derive_filename, + ensure_filename_type_suffix, + is_valid_url, + normalize_group, + normalize_groups, + normalize_unit, + safe_filename, +) +from opensnitch.plugins.list_subscriptions.ui.helpers import ( + _apply_footer_separator_style, + _apply_section_bar_style, + _set_optional_field_tooltips, +) +from opensnitch.plugins.list_subscriptions.ui.toggle_switch_widget import ( + _replace_checkbox_with_toggle, +) +import requests + +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] + +logger: Final[logging.Logger] = logging.getLogger(__name__) + + +class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): + _url_test_finished = QtCore.pyqtSignal(bool, str) + + 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._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._build_ui() + + def _build_ui(self): + self.setupUi(self) + self.enabled_check = _replace_checkbox_with_toggle(self.enabled_check) + self._set_dialog_message("", error=False) + 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) + self._apply_dialog_section_bar_style( + self.settings_section_bar, self.settings_section_label + ) + self._apply_dialog_section_bar_style( + self.meta_section_bar, self.meta_section_label + ) + self._apply_dialog_split_header_style() + self._apply_dialog_footer_style(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 + ) + 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) + 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._handle_url_test_finished) + self.add_button.clicked.connect(self._validate_then_accept) + self.test_url_button.clicked.connect(self._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) + self.interval_spin.setRange(0, 999999) + self.interval_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.interval, + self._defaults.interval_units, + ) + ) + self.interval_spin.setValue(max(0, int(self._sub.interval or 0))) + self.interval_units.clear() + self.interval_units.addItems(INTERVAL_UNITS) + self.interval_units.setCurrentText( + normalize_unit( + str(self._sub.interval_units or self._defaults.interval_units), + INTERVAL_UNITS, + "hours", + ) + ) + self.timeout_spin.setRange(0, 999999) + self.timeout_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.timeout, + self._defaults.timeout_units, + ) + ) + self.timeout_spin.setValue(max(0, int(self._sub.timeout or 0))) + self.timeout_units.clear() + self.timeout_units.addItems(TIMEOUT_UNITS) + self.timeout_units.setCurrentText( + normalize_unit( + str(self._sub.timeout_units or self._defaults.timeout_units), + TIMEOUT_UNITS, + "seconds", + ) + ) + self.max_size_spin.setRange(0, 999999) + self.max_size_spin.setSpecialValueText( + QC.translate("stats", "Use global default ({0} {1})").format( + self._defaults.max_size, + self._defaults.max_size_units, + ) + ) + self.max_size_spin.setValue(max(0, int(self._sub.max_size or 0))) + self.max_size_units.clear() + self.max_size_units.addItems(SIZE_UNITS) + self.max_size_units.setCurrentText( + normalize_unit( + str(self._sub.max_size_units or self._defaults.max_size_units), + SIZE_UNITS, + "MB", + ) + ) + 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) + self._apply_optional_field_tooltips() + self._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.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 _apply_dialog_section_bar_style( + self, container: QtWidgets.QFrame, label: QtWidgets.QLabel + ): + _apply_section_bar_style(self, container, label) + + def _apply_dialog_split_header_style(self): + # Match the main dialog split-header pattern: the left section owns + # the center divider, rather than leaving a visual gap between bars. + _apply_section_bar_style( + self, + self.settings_section_bar, + self.settings_section_label, + right_border=True, + ) + + def _apply_dialog_footer_style(self, separator: QtWidgets.QFrame): + _apply_footer_separator_style(self, separator) + + def _apply_optional_field_tooltips(self): + _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, + ) + + def _sync_optional_fields_state(self): + self.interval_units.setEnabled(self.interval_spin.value() > 0) + self.timeout_units.setEnabled(self.timeout_spin.value() > 0) + self.max_size_units.setEnabled(self.max_size_spin.value() > 0) + + def _clear_field_errors(self): + self._set_dialog_message("", error=False) + self.name_error_label.setText("") + self.url_error_label.setText("") + self.filename_error_label.setText("") + + def _set_dialog_message(self, message: str, error: bool): + color = "red" if error else "#2e7d32" + self.error_label.setStyleSheet(f"color: {color};") + self.error_label.setText(message) + + def _test_url(self): + self.url_error_label.setText("") + self._set_dialog_message("", error=False) + url = (self.url_edit.text() or "").strip() + if url == "": + self.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.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.test_url_button.setEnabled(False) + self._set_dialog_message(QC.translate("stats", "Testing URL..."), error=False) + + def _run_test(): + try: + response = requests.head(url, allow_redirects=True, timeout=5) + 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 url + response.close() + if response.status_code in (403, 405): + response = requests.get( + url, allow_redirects=True, timeout=5, stream=True + ) + if response.status_code >= 400: + raise requests.HTTPError(f"HTTP {response.status_code}") + final_url = response.url or final_url + response.close() + message = QC.translate("stats", "URL reachable.") + if final_url != url: + message = QC.translate( + "stats", "URL reachable via redirect to {0}" + ).format(final_url) + self._url_test_finished.emit(True, message) + except requests.RequestException as exc: + self._url_test_finished.emit(False, str(exc)) + + threading.Thread(target=_run_test, daemon=True).start() + + def _handle_url_test_finished(self, success: bool, message: str): + self.test_url_button.setEnabled(True) + if success: + self.url_error_label.setText("") + self._set_dialog_message(message, error=False) + return + self.url_error_label.setText(QC.translate("stats", "URL check failed.")) + self._set_dialog_message( + QC.translate("stats", "URL test failed: {0}").format(message), + error=True, + ) + + def _validate_then_accept(self): + self._clear_field_errors() + raw_url = (self.url_edit.text() or "").strip() + raw_name = (self.name_edit.text() or "").strip() + raw_filename = (self.filename_edit.text() or "").strip() + list_type = (self.format_combo.currentText() or "hosts").strip().lower() + name = raw_name + filename = safe_filename(raw_filename) + has_error = False + + if raw_url == "": + self.url_error_label.setText(QC.translate("stats", "URL is required.")) + has_error = True + elif not is_valid_url(raw_url): + self.url_error_label.setText( + QC.translate("stats", "Enter a valid http:// or https:// URL.") + ) + has_error = True + + if raw_name == "" and raw_filename == "": + self.name_error_label.setText( + QC.translate("stats", "Provide a name or filename.") + ) + self.filename_error_label.setText( + QC.translate("stats", "Provide a filename or name.") + ) + has_error = True + elif raw_filename != "" and filename != raw_filename: + self.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.name_edit.setText(name) + self.filename_edit.setText(filename) + self.accept() + + 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 + ), + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/toggle_switch_widget.py b/ui/opensnitch/plugins/list_subscriptions/ui/toggle_switch_widget.py new file mode 100644 index 0000000000..d4f1f919e5 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/toggle_switch_widget.py @@ -0,0 +1,272 @@ +import sys +from typing import TYPE_CHECKING + +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 + + +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 From e15221df29f661737ba7e66176ab3139b7d2f578 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Wed, 11 Mar 2026 23:56:19 +0100 Subject: [PATCH 11/13] fixes for url test blocking ui thread + url test message ui bug + default rule creation options + hide node to foce local node --- .../plugins/list_subscriptions/_utils.py | 55 ------- .../list_subscriptions/list_subscriptions.py | 150 +++++++++--------- .../list_subscriptions/models/events.py | 27 +++- .../list_subscriptions/ui/bulk_edit_dialog.py | 30 ++-- .../ui/list_subscriptions_dialog.py | 106 ++++++++----- .../ui/subscription_dialog.py | 140 ++++++++-------- 6 files changed, 250 insertions(+), 258 deletions(-) diff --git a/ui/opensnitch/plugins/list_subscriptions/_utils.py b/ui/opensnitch/plugins/list_subscriptions/_utils.py index ddc0684848..013b1c9084 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_utils.py +++ b/ui/opensnitch/plugins/list_subscriptions/_utils.py @@ -266,61 +266,6 @@ def timestamp_sort_key(value: str | None): return (normalized == "", normalized) -def subscription_event_item( - key: str, - *, - name: str, - url: str, - filename: str, - list_type: str, - state: str | None = None, - path: str | None = None, -): - item: dict[str, Any] = { - "key": key, - "name": name, - "url": url, - "filename": filename, - "format": list_type, - } - if state: - item["state"] = state - if path: - item["path"] = path - return item - - -def subscription_payload_dict( - *, - enabled: bool, - name: str, - url: str, - filename: str, - list_type: 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, -): - return { - "enabled": enabled, - "name": name, - "url": url, - "filename": filename, - "format": list_type, - "groups": groups, - "interval": interval, - "interval_units": interval_units, - "timeout": timeout, - "timeout_units": timeout_units, - "max_size": max_size, - "max_size_units": max_size_units, - } - - def normalize_group(group: str | None): raw = (group or "").strip().lower() if raw == "": diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index f1da7cedb0..c1c8963d0f 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -28,7 +28,10 @@ from opensnitch.plugins.list_subscriptions.io.lock import FileLock from opensnitch.plugins.list_subscriptions.io.storage import read_json_locked from opensnitch.plugins.list_subscriptions.models.config import PluginConfig -from opensnitch.plugins.list_subscriptions.models.events import RuntimeEvent +from opensnitch.plugins.list_subscriptions.models.events import ( + RuntimeEventType, + SubscriptionEventItem, +) from opensnitch.plugins.list_subscriptions.models.metadata import ListMetadata from opensnitch.plugins.list_subscriptions.models.subscriptions import SubscriptionSpec from opensnitch.proto import ui_pb2 @@ -50,7 +53,6 @@ now_iso, parse_iso, subscription_dirname, - subscription_event_item, ) from opensnitch.plugins.list_subscriptions.io.storage import ( write_json_atomic_locked, @@ -158,7 +160,7 @@ def __init__(self, config: dict[str, Any] | None = None): def _emit_runtime_event( self, - event: RuntimeEvent, + event: RuntimeEventType, message: str, *, error: str | None = None, @@ -167,7 +169,7 @@ def _emit_runtime_event( path: str | None = None, source: str | None = None, state: str | None = None, - items: list[dict[str, Any]] | None = None, + items: list[dict[str, Any]] | list[SubscriptionEventItem] | None = None, ): payload: dict[str, Any] = { "plugin": self.get_name(), @@ -318,7 +320,7 @@ def _reload_from_action_file(self, action_path: str | None = None): try: raw_action = read_json_locked(action_path) self._emit_runtime_event( - RuntimeEvent.FILE_LOAD_FINISHED, + RuntimeEventType.FILE_LOAD_FINISHED, "Runtime configuration loaded.", action_path=action_path, target="action_config", @@ -331,7 +333,7 @@ def _reload_from_action_file(self, action_path: str | None = None): exc, ) self._emit_runtime_event( - RuntimeEvent.FILE_LOAD_ERROR, + RuntimeEventType.FILE_LOAD_ERROR, "Failed to load runtime configuration.", error=str(exc), action_path=action_path, @@ -344,7 +346,7 @@ def _reload_from_action_file(self, action_path: str | None = None): logger.warning( "invalid action payload in %s: %r", action_path, - type(raw_action).__name__, + type(raw_action).__name__, # pyright: ignore[reportCallIssue] ) return False, f"invalid action payload type: {type(raw_action).__name__}" @@ -507,7 +509,7 @@ def _load_meta(self, meta_path: str): try: meta = ListMetadata.from_dict(read_json_locked(meta_path)) self._emit_runtime_event( - RuntimeEvent.FILE_LOAD_FINISHED, + RuntimeEventType.FILE_LOAD_FINISHED, "Subscription metadata loaded.", target="subscription_meta", path=meta_path, @@ -515,7 +517,7 @@ def _load_meta(self, meta_path: str): return meta except Exception as exc: self._emit_runtime_event( - RuntimeEvent.FILE_LOAD_ERROR, + RuntimeEventType.FILE_LOAD_ERROR, "Failed to load subscription metadata.", error=str(exc), target="subscription_meta", @@ -527,7 +529,7 @@ def _save_meta(self, meta_path: str, meta: ListMetadata): try: write_json_atomic_locked(meta_path, meta.to_dict()) self._emit_runtime_event( - RuntimeEvent.FILE_SAVE_FINISHED, + RuntimeEventType.FILE_SAVE_FINISHED, "Subscription metadata saved.", target="subscription_meta", path=meta_path, @@ -535,7 +537,7 @@ def _save_meta(self, meta_path: str, meta: ListMetadata): ) except Exception as exc: self._emit_runtime_event( - RuntimeEvent.FILE_SAVE_ERROR, + RuntimeEventType.FILE_SAVE_ERROR, "Failed to save subscription metadata.", error=str(exc), target="subscription_meta", @@ -973,13 +975,13 @@ def cb_signal(self, signal: dict[str, Any]): self.enabled = True self.run() self._emit_runtime_event( - RuntimeEvent.RUNTIME_ENABLED, + RuntimeEventType.RUNTIME_ENABLED, "Plugin runtime enabled.", action_path=action_path, ) else: self._emit_runtime_event( - RuntimeEvent.RUNTIME_ERROR, + RuntimeEventType.RUNTIME_ERROR, "Failed to enable plugin runtime.", error=err, action_path=action_path, @@ -1005,7 +1007,7 @@ def cb_signal(self, signal: dict[str, Any]): subscriptions.append(sub) if not subscriptions: self._emit_runtime_event( - RuntimeEvent.RUNTIME_ERROR, + RuntimeEventType.RUNTIME_ERROR, "No subscriptions were provided for refresh.", action_path=action_path, ) @@ -1035,13 +1037,13 @@ def cb_signal(self, signal: dict[str, Any]): self._start_runtime(recheck=False) self._apply_config_update_diff(previous_subscriptions) self._emit_runtime_event( - RuntimeEvent.CONFIG_RELOADED, + RuntimeEventType.CONFIG_RELOADED, "Plugin runtime configuration reloaded.", action_path=action_path, ) else: self._emit_runtime_event( - RuntimeEvent.RUNTIME_ERROR, + RuntimeEventType.RUNTIME_ERROR, "Failed to reload plugin runtime configuration.", error=err, action_path=action_path, @@ -1058,9 +1060,9 @@ def cb_signal(self, signal: dict[str, Any]): self.stop() self._emit_runtime_event( ( - RuntimeEvent.RUNTIME_DISABLED + RuntimeEventType.RUNTIME_DISABLED if sig == PluginSignal.DISABLE - else RuntimeEvent.RUNTIME_STOPPED + else RuntimeEventType.RUNTIME_STOPPED ), ( "Plugin runtime disabled." @@ -1074,7 +1076,7 @@ def cb_signal(self, signal: dict[str, Any]): if sig == PluginSignal.ERROR: err = str(signal.get("error") or signal.get("message") or "") self._emit_runtime_event( - RuntimeEvent.RUNTIME_ERROR, + RuntimeEventType.RUNTIME_ERROR, "Plugin runtime reported an error.", error=err or None, action_path=action_path, @@ -1135,17 +1137,17 @@ def _download_one( meta.last_checked = now_iso() meta.last_error = "" - event_item = subscription_event_item( - key, + event_item = SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, path=list_path, ) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_STARTED, + RuntimeEventType.DOWNLOAD_STARTED, f"Downloading subscription '{sub.name}'.", target="subscription_list", path=list_path, @@ -1168,19 +1170,19 @@ def _download_one( self._save_meta(meta_path, meta) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_FAILED, f"Subscription '{sub.name}' is busy.", target="subscription_list", path=list_path, source=source, state="busy", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state="busy", path=list_path, ) @@ -1200,7 +1202,7 @@ def _download_one( self._save_meta(meta_path, meta) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_FAILED, f"Subscription download failed for '{sub.name}'.", error=repr(e), target="subscription_list", @@ -1208,12 +1210,12 @@ def _download_one( source=source, state="request_error", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state="request_error", path=list_path, ) @@ -1231,19 +1233,19 @@ def _download_one( self._save_meta(meta_path, meta) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_FINISHED, + RuntimeEventType.DOWNLOAD_FINISHED, f"Subscription '{sub.name}' is up to date.", target="subscription_list", path=list_path, source=source, state="not_modified", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state="not_modified", path=list_path, ) @@ -1258,7 +1260,7 @@ def _download_one( self._save_meta(meta_path, meta) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_FAILED, f"Subscription download failed for '{sub.name}'.", error=f"http_{r.status_code}", target="subscription_list", @@ -1266,12 +1268,12 @@ def _download_one( source=source, state=f"http_{r.status_code}", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state=f"http_{r.status_code}", path=list_path, ) @@ -1293,7 +1295,7 @@ def _download_one( self._save_meta(meta_path, meta) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_FAILED, f"Subscription download exceeded max size for '{sub.name}'.", error=f"too_large:{cl}", target="subscription_list", @@ -1301,12 +1303,12 @@ def _download_one( source=source, state="too_large", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state="too_large", path=list_path, ) @@ -1361,7 +1363,7 @@ def _download_one( self._save_meta(meta_path, meta) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_FAILED, f"Subscription file format is invalid for '{sub.name}'.", error="bad_format_hosts", target="subscription_list", @@ -1369,12 +1371,12 @@ def _download_one( source=source, state="bad_format", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state="bad_format", path=list_path, ) @@ -1390,7 +1392,7 @@ def _download_one( os.replace(tmp, list_path) self._fsync_parent_dir(list_path) self._emit_runtime_event( - RuntimeEvent.FILE_SAVE_FINISHED, + RuntimeEventType.FILE_SAVE_FINISHED, f"Subscription file saved for '{sub.name}'.", target="subscription_list", path=list_path, @@ -1407,7 +1409,7 @@ def _download_one( self._mark_failure(meta, repr(e)) self._save_meta(meta_path, meta) self._emit_runtime_event( - RuntimeEvent.FILE_SAVE_ERROR, + RuntimeEventType.FILE_SAVE_ERROR, f"Failed to save subscription file for '{sub.name}'.", error=repr(e), target="subscription_list", @@ -1415,12 +1417,12 @@ def _download_one( source=source, state="write_error", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state="write_error", path=list_path, ) @@ -1428,7 +1430,7 @@ def _download_one( ) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_FAILED, f"Subscription download failed for '{sub.name}'.", error=repr(e), target="subscription_list", @@ -1436,12 +1438,12 @@ def _download_one( source=source, state="write_error", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state="write_error", path=list_path, ) @@ -1471,19 +1473,19 @@ def _download_one( self._save_meta(meta_path, meta) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_FINISHED, + RuntimeEventType.DOWNLOAD_FINISHED, f"Subscription '{sub.name}' updated.", target="subscription_list", path=list_path, source=source, state="updated", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state="updated", path=list_path, ) @@ -1507,7 +1509,7 @@ def _download_one( self._save_meta(meta_path, meta) if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_FAILED, f"Subscription download failed for '{sub.name}'.", error=repr(e), target="subscription_list", @@ -1515,12 +1517,12 @@ def _download_one( source=source, state="unexpected_error", items=[ - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state="unexpected_error", path=list_path, ) @@ -1556,20 +1558,20 @@ def download( if not subscriptions: return True - items: list[dict[str, Any]] = [] + items: list[SubscriptionEventItem] = [] if emit_download_events: self._emit_runtime_event( - RuntimeEvent.DOWNLOAD_STARTED, + RuntimeEventType.DOWNLOAD_STARTED, "Batch subscription refresh started.", target="subscription_list", source=source, items=[ - subscription_event_item( - self._sub_key(sub), + SubscriptionEventItem( + key=self._sub_key(sub), name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, ) for sub in subscriptions ], @@ -1602,12 +1604,12 @@ def download( except Exception: pass items.append( - subscription_event_item( - key, + SubscriptionEventItem( + key=key, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, state=item_state, path=list_path, ) @@ -1615,9 +1617,9 @@ def download( if emit_download_events: self._emit_runtime_event( ( - RuntimeEvent.DOWNLOAD_FAILED + RuntimeEventType.DOWNLOAD_FAILED if had_errors - else RuntimeEvent.DOWNLOAD_FINISHED + else RuntimeEventType.DOWNLOAD_FINISHED ), ( "Batch subscription refresh finished with errors." diff --git a/ui/opensnitch/plugins/list_subscriptions/models/events.py b/ui/opensnitch/plugins/list_subscriptions/models/events.py index 714582a59a..efb572e265 100644 --- a/ui/opensnitch/plugins/list_subscriptions/models/events.py +++ b/ui/opensnitch/plugins/list_subscriptions/models/events.py @@ -1,7 +1,32 @@ +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 RuntimeEvent(IntEnum): + +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 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py index a5a61d0d32..b2dd8f9730 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py @@ -60,6 +60,7 @@ 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 @@ -104,8 +105,12 @@ def _build_ui(self): self.changes_tree.setContentsMargins(0, 0, 0, 0) self.buttons_layout.setContentsMargins(12, 10, 12, 12) self.buttons_layout.setSpacing(8) - self._apply_section_bar_style() - self._apply_footer_style() + _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, @@ -251,22 +256,6 @@ def _build_ui(self): 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) - self._apply_optional_field_tooltips() - self._sync_apply_fields_state() - self._sync_optional_fields_state() - self.resize(760, 420) - - def _apply_section_bar_style(self): - _apply_section_bar_style( - self, - self.changes_section_bar, - self.changes_section_label, - ) - - def _apply_footer_style(self): - _apply_footer_separator_style(self, self.footer_separator_line) - - def _apply_optional_field_tooltips(self): _set_optional_field_tooltips( self.interval_spin, self.interval_units, @@ -276,6 +265,11 @@ def _apply_optional_field_tooltips(self): self.max_size_units, inherit_wording=False, ) + self._sync_apply_fields_state() + self._sync_optional_fields_state() + self.resize(760, 420) + + # Les méthodes supprimées ci-dessus sont désormais remplacées par l'utilisation directe des helpers dans _build_ui. def _build_compound_editor( self, primary: QtWidgets.QWidget, secondary: QtWidgets.QWidget diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py index 05175e54cb..cb90a55433 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py @@ -39,7 +39,10 @@ SubscriptionSpec, ) from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults -from opensnitch.plugins.list_subscriptions.models.events import RuntimeEvent +from opensnitch.plugins.list_subscriptions.models.events import ( + RuntimeEventType, + SubscriptionEventPayload, +) from opensnitch.actions import Actions from opensnitch.nodes import Nodes from opensnitch.plugins import PluginSignal @@ -76,13 +79,13 @@ normalize_unit, safe_filename, strip_or_none, - subscription_payload_dict, subscription_rule_dir, timestamp_sort_key, ) from opensnitch.plugins.list_subscriptions.io.storage import ( write_json_atomic_locked, ) +from opensnitch.config import Config from opensnitch.dialogs.ruleseditor import RulesEditorDialog import requests from opensnitch.plugins.list_subscriptions.list_subscriptions import ListSubscriptions @@ -394,21 +397,29 @@ def _build_ui(self): self.status_label.setAlignment( QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter ) - self._apply_section_header_style( + _apply_section_bar_style( + self, self.defaults_section_bar, self.defaults_section_label, + expanding_label=True, ) - self._apply_section_header_style( + _apply_section_bar_style( + self, self.table_section_bar, self.table_section_label, + expanding_label=True, ) - self._apply_section_header_style( + _apply_section_bar_style( + self, self.global_actions_bar, self.global_actions_label, + expanding_label=True, ) - self._apply_section_header_style( + _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) @@ -473,6 +484,8 @@ def _build_ui(self): self.node_label, ): label.setMinimumWidth(defaults_label_width) + self.node_label.hide() + self.nodes_combo.hide() self.default_interval_spin.setRange(1, 999999) self.default_interval_units.clear() @@ -616,16 +629,6 @@ def _build_ui(self): self._set_runtime_state(active=False) self._update_selected_actions_state() - def _apply_section_header_style( - self, container: QtWidgets.QFrame, label: QtWidgets.QLabel - ): - _apply_section_bar_style( - self, - container, - label, - expanding_label=True, - ) - @contextmanager def _sorting_suspended(self): header = self.table.horizontalHeader() @@ -755,7 +758,7 @@ def _runtime_event_items(self, payload: dict[str, Any]): def _runtime_download_message( self, - event_name: RuntimeEvent | None, + event_name: RuntimeEventType | None, payload: dict[str, Any], fallback: str, ): @@ -764,19 +767,19 @@ def _runtime_download_message( return fallback count = len(items) first_name = str(items[0].get("name") or "").strip() - if event_name == RuntimeEvent.DOWNLOAD_STARTED: + 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 == RuntimeEvent.DOWNLOAD_FINISHED: + 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 == RuntimeEvent.DOWNLOAD_FAILED: + if event_name == RuntimeEventType.DOWNLOAD_FAILED: if count == 1 and first_name != "": return QC.translate( "stats", "Subscription '{0}' refresh failed." @@ -1680,7 +1683,10 @@ def refresh_selected_now(self): return target_sub = row_sub if target_sub is None: - self._set_status(QC.translate("stats", "Internal error: target_sub is None."), error=True) + self._set_status( + QC.translate("stats", "Internal error: target_sub is None."), + error=True, + ) return list_path, _ = plug._paths(target_sub) refresh_targets.append((target_sub, list_path)) @@ -1694,12 +1700,12 @@ def refresh_selected_now(self): "action_path": self._action_path, "source": "manual_refresh", "items": [ - subscription_payload_dict( + SubscriptionEventPayload( enabled=target_sub.enabled, name=target_sub.name, url=target_sub.url, filename=target_sub.filename, - list_type=target_sub.format, + format=target_sub.format, groups=list(target_sub.groups), interval=target_sub.interval, interval_units=target_sub.interval_units, @@ -1848,12 +1854,12 @@ def refresh_all_now(self): "action_path": self._action_path, "source": "manual_refresh", "items": [ - subscription_payload_dict( + SubscriptionEventPayload( enabled=sub.enabled, name=sub.name, url=sub.url, filename=sub.filename, - list_type=sub.format, + format=sub.format, groups=list(sub.groups), interval=sub.interval, interval_units=sub.interval_units, @@ -1948,6 +1954,7 @@ def create_rule_from_selected(self): self._rules_dialog.new_rule() if not self._configure_rules_dialog_for_local_user(): return + self._apply_rule_editor_defaults() # Rules editor expects a directory containing one or more hosts files. self._rules_dialog.dstListsCheck.setChecked(True) @@ -1991,6 +1998,7 @@ def create_global_rule(self): self._rules_dialog.new_rule() if not self._configure_rules_dialog_for_local_user(): return + self._apply_rule_editor_defaults() rule_name = "00-blocklist-all" self._rules_dialog.dstListsCheck.setChecked(True) self._rules_dialog.dstListsLine.setText(rule_dir) @@ -2051,6 +2059,20 @@ def _configure_rules_dialog_for_local_user(self): uid_combo.setCurrentText(uid_text) return True + def _apply_rule_editor_defaults(self): + if self._rules_dialog is None: + return + self._rules_dialog.enableCheck.setChecked(True) + duration_idx = self._rules_dialog.durationCombo.findData(Config.DURATION_ALWAYS) + if duration_idx < 0: + duration_idx = self._rules_dialog.durationCombo.findText( + Config.DURATION_ALWAYS, + QtCore.Qt.MatchFlag.MatchFixedString, + ) + if duration_idx < 0: + duration_idx = 8 + self._rules_dialog.durationCombo.setCurrentIndex(duration_idx) + def _choose_group_for_selected(self, rows: list[int]): if not rows: return None @@ -2240,40 +2262,40 @@ def _handle_runtime_event(self, event: dict[str, Any]): event_value = payload.get("event") if isinstance(event_value, int): try: - event_name = RuntimeEvent(event_value) + event_name = RuntimeEventType(event_value) except Exception: event_name = None else: event_name = None is_error = event_name in ( - RuntimeEvent.RUNTIME_ERROR, - RuntimeEvent.DOWNLOAD_FAILED, - RuntimeEvent.FILE_SAVE_ERROR, - RuntimeEvent.FILE_LOAD_ERROR, + RuntimeEventType.RUNTIME_ERROR, + RuntimeEventType.DOWNLOAD_FAILED, + RuntimeEventType.FILE_SAVE_ERROR, + RuntimeEventType.FILE_LOAD_ERROR, ) - if event_name == RuntimeEvent.DOWNLOAD_STARTED: + if event_name == RuntimeEventType.DOWNLOAD_STARTED: for key in event_keys: if key in self._pending_refresh_keys: self._pending_refresh_keys.discard(key) self._active_refresh_keys.add(key) self._set_refresh_busy(True) elif event_name in ( - RuntimeEvent.DOWNLOAD_FINISHED, - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_FINISHED, + RuntimeEventType.DOWNLOAD_FAILED, ): for key in event_keys: self._clear_refresh_key(key) if event_name in ( - RuntimeEvent.DOWNLOAD_FINISHED, - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_FINISHED, + RuntimeEventType.DOWNLOAD_FAILED, ): self.refresh_states() self._update_selected_actions_state() - if event_name == RuntimeEvent.RUNTIME_ENABLED: + if event_name == RuntimeEventType.RUNTIME_ENABLED: self._set_runtime_state(active=True) elif event_name in ( - RuntimeEvent.RUNTIME_DISABLED, - RuntimeEvent.RUNTIME_STOPPED, + RuntimeEventType.RUNTIME_DISABLED, + RuntimeEventType.RUNTIME_STOPPED, ): self._set_runtime_state(active=False) elif self._pending_runtime_reload is not None: @@ -2286,7 +2308,7 @@ def _handle_runtime_event(self, event: dict[str, Any]): active=None, text=QC.translate("stats", "Runtime: error") ) if self._pending_runtime_reload == "waiting_config_reload": - if event_name == RuntimeEvent.CONFIG_RELOADED: + if event_name == RuntimeEventType.CONFIG_RELOADED: self._pending_runtime_reload = None self.load_action_file() return @@ -2297,9 +2319,9 @@ def _handle_runtime_event(self, event: dict[str, Any]): str(event_value or "unknown") ) if event_name in ( - RuntimeEvent.DOWNLOAD_STARTED, - RuntimeEvent.DOWNLOAD_FINISHED, - RuntimeEvent.DOWNLOAD_FAILED, + RuntimeEventType.DOWNLOAD_STARTED, + RuntimeEventType.DOWNLOAD_FINISHED, + RuntimeEventType.DOWNLOAD_FAILED, ): message = self._runtime_download_message(event_name, payload, message) if is_error and error_detail != "": diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py index 8888ef08e3..e5527f920a 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py @@ -1,7 +1,6 @@ import logging import os import sys -import threading from typing import Any, TYPE_CHECKING, Final if TYPE_CHECKING: @@ -57,7 +56,6 @@ from opensnitch.plugins.list_subscriptions.ui.toggle_switch_widget import ( _replace_checkbox_with_toggle, ) -import requests SUBSCRIPTION_DIALOG_UI_PATH: Final[str] = os.path.join( RES_DIR, "subscription_dialog.ui" @@ -68,6 +66,41 @@ logger: Final[logging.Logger] = logging.getLogger(__name__) +class UrlTestWorker(QtCore.QThread): + finished = QtCore.pyqtSignal(bool, str) + + def __init__(self, url: str): + super().__init__() + self.url = url + + def run(self): + import requests + try: + response = requests.head(self.url, allow_redirects=True, timeout=5) + 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 response.status_code in (403, 405): + response = requests.get( + self.url, allow_redirects=True, timeout=5, stream=True + ) + if response.status_code >= 400: + raise requests.HTTPError(f"HTTP {response.status_code}") + final_url = response.url or final_url + response.close() + message = QC.translate("stats", "URL reachable.") + 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.finished.emit(True, f"{message} {final_url}") + return + self.finished.emit(True, message) + except requests.RequestException as exc: + self.finished.emit(False, str(exc)) + + class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): _url_test_finished = QtCore.pyqtSignal(bool, str) @@ -152,6 +185,7 @@ def __init__( ): super().__init__(parent) self.setWindowTitle(QC.translate("stats", title)) + self.setWindowModality(QtCore.Qt.WindowModality.WindowModal) self._title = title self._defaults = defaults self._groups = groups or [] @@ -215,21 +249,28 @@ def _build_ui(self): self.buttons_layout.setSpacing(8) self.bodyLayout.setStretch(0, 1) self.bodyLayout.setStretch(1, 1) - self._apply_dialog_section_bar_style( - self.settings_section_bar, self.settings_section_label + _apply_section_bar_style( + self, self.settings_section_bar, self.settings_section_label ) - self._apply_dialog_section_bar_style( - self.meta_section_bar, self.meta_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, ) - self._apply_dialog_split_header_style() - self._apply_dialog_footer_style(self.footer_separator_line) + _apply_footer_separator_style(self, self.footer_separator_line) self.error_label.setSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, - QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Fixed, ) 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.settings_form.setFieldGrowthPolicy( QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow ) @@ -262,6 +303,13 @@ def _build_ui(self): 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) @@ -392,7 +440,15 @@ def _build_ui(self): 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) - self._apply_optional_field_tooltips() + _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._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", ""))) @@ -407,35 +463,6 @@ def _build_ui(self): self.meta_group.setVisible(False) self.resize(920, 420) - def _apply_dialog_section_bar_style( - self, container: QtWidgets.QFrame, label: QtWidgets.QLabel - ): - _apply_section_bar_style(self, container, label) - - def _apply_dialog_split_header_style(self): - # Match the main dialog split-header pattern: the left section owns - # the center divider, rather than leaving a visual gap between bars. - _apply_section_bar_style( - self, - self.settings_section_bar, - self.settings_section_label, - right_border=True, - ) - - def _apply_dialog_footer_style(self, separator: QtWidgets.QFrame): - _apply_footer_separator_style(self, separator) - - def _apply_optional_field_tooltips(self): - _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, - ) - def _sync_optional_fields_state(self): self.interval_units.setEnabled(self.interval_spin.value() > 0) self.timeout_units.setEnabled(self.timeout_spin.value() > 0) @@ -451,6 +478,7 @@ def _set_dialog_message(self, message: str, error: bool): color = "red" if error else "#2e7d32" self.error_label.setStyleSheet(f"color: {color};") self.error_label.setText(message) + self.error_label.setToolTip(message) def _test_url(self): self.url_error_label.setText("") @@ -474,34 +502,9 @@ def _test_url(self): self.test_url_button.setEnabled(False) self._set_dialog_message(QC.translate("stats", "Testing URL..."), error=False) - def _run_test(): - try: - response = requests.head(url, allow_redirects=True, timeout=5) - 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 url - response.close() - if response.status_code in (403, 405): - response = requests.get( - url, allow_redirects=True, timeout=5, stream=True - ) - if response.status_code >= 400: - raise requests.HTTPError(f"HTTP {response.status_code}") - final_url = response.url or final_url - response.close() - message = QC.translate("stats", "URL reachable.") - if final_url != url: - message = QC.translate( - "stats", "URL reachable via redirect to {0}" - ).format(final_url) - self._url_test_finished.emit(True, message) - except requests.RequestException as exc: - self._url_test_finished.emit(False, str(exc)) - - threading.Thread(target=_run_test, daemon=True).start() + self._url_worker = UrlTestWorker(url) + self._url_worker.finished.connect(self._url_test_finished.emit) + self._url_worker.start() def _handle_url_test_finished(self, success: bool, message: str): self.test_url_button.setEnabled(True) @@ -511,9 +514,10 @@ def _handle_url_test_finished(self, success: bool, message: str): return self.url_error_label.setText(QC.translate("stats", "URL check failed.")) self._set_dialog_message( - QC.translate("stats", "URL test failed: {0}").format(message), + QC.translate("stats", "URL test failed. See details in the tooltip."), error=True, ) + self.error_label.setToolTip(message) def _validate_then_accept(self): self._clear_field_errors() From b30e186f92a193edd3a397d660f4f43d26587c3f Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Fri, 13 Mar 2026 07:02:50 +0100 Subject: [PATCH 12/13] complete refactoring of runtime + ui (MVC, qt signals, qt threads, rules management) --- .../list_subscriptions/_annotations.py | 57 + .../plugins/list_subscriptions/_compat.py | 10 + .../list_subscriptions/list_subscriptions.py | 52 +- .../res/attached_rules_dialog.ui | 91 + .../res/status_log_dialog.ui | 70 + .../res/subscription_dialog.ui | 42 +- .../res/subscription_status_dialog.ui | 95 + .../res/text_inspect_dialog.ui | 70 + .../plugins/list_subscriptions/ui/__init__.py | 41 + .../ui/controllers/__init__.py | 0 .../ui/controllers/action_file_controller.py | 279 ++ .../ui/controllers/bulk_edit_controller.py | 134 + .../ui/controllers/context_menu_controller.py | 105 + .../ui/controllers/defaults_ui_controller.py | 77 + .../ui/controllers/inspector_controller.py | 409 +++ .../rules_attachment_controller.py | 313 ++ .../ui/controllers/rules_editor_controller.py | 444 +++ .../ui/controllers/runtime_controller.py | 426 +++ .../ui/controllers/selection_controller.py | 79 + .../ui/controllers/status_controller.py | 187 ++ .../subscription_dialog_controller.py | 196 ++ .../subscription_edit_controller.py | 267 ++ .../subscription_status_controller.py | 86 + .../ui/controllers/table_data_controller.py | 1099 +++++++ .../ui/controllers/table_view_controller.py | 193 ++ .../plugins/list_subscriptions/ui/helpers.py | 171 -- .../ui/list_subscriptions_dialog.py | 2684 ----------------- .../list_subscriptions/ui/views/__init__.py | 0 .../ui/views/attached_rules_dialog.py | 274 ++ .../ui/{ => views}/bulk_edit_dialog.py | 176 +- .../list_subscriptions/ui/views/helpers.py | 116 + .../ui/views/inspector_panel.py | 260 ++ .../ui/views/list_subscriptions_dialog.py | 769 +++++ .../ui/views/status_log_dialog.py | 116 + .../ui/{ => views}/subscription_dialog.py | 336 +-- .../ui/views/subscription_status_dialog.py | 192 ++ .../ui/views/text_inspect_dialog.py | 54 + .../list_subscriptions/ui/widgets/__init__.py | 0 .../list_subscriptions/ui/widgets/helpers.py | 91 + .../ui/widgets/table_widgets.py | 139 + .../ui/{ => widgets}/toggle_switch_widget.py | 35 +- .../list_subscriptions/ui/workers/__init__.py | 0 .../ui/workers/subscription_workers.py | 37 + 43 files changed, 7026 insertions(+), 3246 deletions(-) create mode 100644 ui/opensnitch/plugins/list_subscriptions/_annotations.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/_compat.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/res/attached_rules_dialog.ui create mode 100644 ui/opensnitch/plugins/list_subscriptions/res/status_log_dialog.ui create mode 100644 ui/opensnitch/plugins/list_subscriptions/res/subscription_status_dialog.ui create mode 100644 ui/opensnitch/plugins/list_subscriptions/res/text_inspect_dialog.ui create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/__init__.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/action_file_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/bulk_edit_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/context_menu_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/defaults_ui_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/selection_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_status_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py delete mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/helpers.py delete mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/views/__init__.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/views/attached_rules_dialog.py rename ui/opensnitch/plugins/list_subscriptions/ui/{ => views}/bulk_edit_dialog.py (68%) create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/views/helpers.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py rename ui/opensnitch/plugins/list_subscriptions/ui/{ => views}/subscription_dialog.py (62%) create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_status_dialog.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/views/text_inspect_dialog.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/widgets/__init__.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/widgets/helpers.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/widgets/table_widgets.py rename ui/opensnitch/plugins/list_subscriptions/ui/{ => widgets}/toggle_switch_widget.py (88%) create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/workers/subscription_workers.py diff --git a/ui/opensnitch/plugins/list_subscriptions/_annotations.py b/ui/opensnitch/plugins/list_subscriptions/_annotations.py new file mode 100644 index 0000000000..1402b7a049 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/_annotations.py @@ -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: ... diff --git a/ui/opensnitch/plugins/list_subscriptions/_compat.py b/ui/opensnitch/plugins/list_subscriptions/_compat.py new file mode 100644 index 0000000000..785c2951a2 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/_compat.py @@ -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"] diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index c1c8963d0f..fa7f0d9aa4 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -4,13 +4,15 @@ import threading import shutil import sys -from typing import Any, ClassVar, Final +from typing import TYPE_CHECKING, Any, ClassVar, Final, cast from abc import ABCMeta from datetime import datetime, timedelta from queue import Queue import requests -if "PyQt6" in sys.modules: +if TYPE_CHECKING: + from PyQt6 import QtCore, QtGui, QtWidgets +elif "PyQt6" in sys.modules: from PyQt6 import QtCore, QtGui, QtWidgets elif "PyQt5" in sys.modules: from PyQt5 import QtCore, QtGui, QtWidgets @@ -20,10 +22,12 @@ except Exception: from PyQt5 import QtCore, QtGui, QtWidgets -try: - from opensnitch.dialogs.events import StatsDialog -except ImportError: - from opensnitch.dialogs.stats import StatsDialog # type: ignore +from opensnitch.plugins.list_subscriptions._compat import StatsDialog +from opensnitch.plugins.list_subscriptions._annotations import StatsDialogProto +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) from opensnitch.plugins.list_subscriptions.io.lock import FileLock from opensnitch.plugins.list_subscriptions.io.storage import read_json_locked @@ -34,7 +38,6 @@ ) from opensnitch.plugins.list_subscriptions.models.metadata import ListMetadata from opensnitch.plugins.list_subscriptions.models.subscriptions import SubscriptionSpec -from opensnitch.proto import ui_pb2 from opensnitch.config import Config from opensnitch.nodes import Nodes from opensnitch.notifications import DesktopNotifications @@ -57,6 +60,8 @@ from opensnitch.plugins.list_subscriptions.io.storage import ( write_json_atomic_locked, ) +from opensnitch.proto import ui_pb2 as ui_pb2 + ch: Final[logging.StreamHandler] = logging.StreamHandler() # ch.setLevel(logging.ERROR) @@ -139,8 +144,8 @@ def __init__(self, config: dict[str, Any] | None = None): self._app_icon = os.path.join( os.path.abspath(os.path.dirname(__file__)), "../../res/icon-white.svg" ) - self._cfg_dialog: Any = None - self._cfg_action: Any = None + self._cfg_dialog: ListSubscriptionsDialog | None = None + self._cfg_action: QtGui.QAction | None = None self._cfg_toolbar_button: QtWidgets.QPushButton | None = None self.scheduled_tasks = {} self._startup_recheck_lock = threading.Lock() @@ -583,7 +588,7 @@ def _reload_rules_for_updated_subscription(self, sub: SubscriptionSpec): continue matched = False while records.next(): - rule = Rule.new_from_records(records) + rule = cast(ui_pb2.Rule, Rule.new_from_records(records)) if rule.operator.operand == Config.OPERAND_LIST_DOMAINS: direct_dir = os.path.normpath( str(rule.operator.data or "").strip() @@ -603,8 +608,8 @@ def _reload_rules_for_updated_subscription(self, sub: SubscriptionSpec): if not matched: continue - notification = ui_pb2.Notification( # type: ignore - type=ui_pb2.CHANGE_RULE, # type: ignore + notification = ui_pb2.Notification( + type=ui_pb2.CHANGE_RULE, rules=[rule], ) self._nodes.send_notification(addr, notification, None) @@ -633,7 +638,7 @@ def _sub_key(self, sub: SubscriptionSpec): base = f"{sub.url}|{sub.filename}" return hashlib.sha1(base.encode("utf-8")).hexdigest()[:16] - def configure(self, parent: Any = None): + def configure(self, parent: StatsDialogProto | None = None): if isinstance(parent, StatsDialog): if self._cfg_action is not None or self._cfg_toolbar_button is not None: return @@ -649,7 +654,7 @@ def configure(self, parent: Any = None): return self._install_menu_action(parent, icon) - def _install_toolbar_button(self, parent: StatsDialog, icon: QtGui.QIcon): + def _install_toolbar_button(self, parent: StatsDialogProto, icon: QtGui.QIcon): actions_button = getattr(parent, "actionsButton", None) if not isinstance(actions_button, QtWidgets.QPushButton): return False @@ -686,7 +691,7 @@ def _install_toolbar_button(self, parent: StatsDialog, icon: QtGui.QIcon): button.setStatusTip("Open list subscriptions") button.setFlat(True) if not icon.isNull(): - button.setIcon(icon) # type: ignore[arg-type] + button.setIcon(icon) button.setCursor(reference_button.cursor()) # pyright: ignore[reportArgumentType] button.setFocusPolicy(reference_button.focusPolicy()) # pyright: ignore[reportArgumentType] button.setSizePolicy(reference_button.sizePolicy()) # pyright: ignore[reportArgumentType] @@ -724,7 +729,7 @@ def _find_layout_containing_widget( return found return None - def _install_menu_action(self, parent: StatsDialog, icon: QtGui.QIcon): + def _install_menu_action(self, parent: StatsDialogProto, icon: QtGui.QIcon): menu = parent.actionsButton.menu() if menu is None: return @@ -745,9 +750,10 @@ def _install_menu_action(self, parent: StatsDialog, icon: QtGui.QIcon): else: self._cfg_action = menu.addAction("List subscriptions") - self._cfg_action.triggered.connect(lambda *_: self._open_config_dialog(parent)) + if self._cfg_action is not None: + self._cfg_action.triggered.connect(lambda *_: self._open_config_dialog(parent)) - def _remove_menu_action(self, parent: StatsDialog): + def _remove_menu_action(self, parent: StatsDialogProto): menu = parent.actionsButton.menu() if menu is None: return @@ -759,7 +765,7 @@ def _remove_menu_action(self, parent: StatsDialog): self._cfg_action = None break - def _find_quit_action(self, menu: Any): + def _find_quit_action(self, menu: QtWidgets.QMenu) -> QtGui.QAction | None: qt_key = getattr(getattr(QtCore, "Qt", object()), "Key", None) key_q = getattr(qt_key, "Key_Q", None) if qt_key is not None else None for act in menu.actions(): @@ -781,8 +787,10 @@ def _find_quit_action(self, menu: Any): return acts[-1] return None - def _open_config_dialog(self, parent: Any): - from opensnitch.plugins.list_subscriptions.ui.list_subscriptions_dialog import ListSubscriptionsDialog + def _open_config_dialog(self, parent: StatsDialogProto): + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) appicon = None try: @@ -843,7 +851,7 @@ def compile(self): self._sync_sources_dirs() self._sync_global_symlinks() - def run(self, parent: Any = None, args: tuple[Any, ...] = ()): # type: ignore[override] + def run(self, parent: StatsDialogProto | None = None, args: tuple[Any, ...] = ()): # type: ignore[override] """ Start timers. """ diff --git a/ui/opensnitch/plugins/list_subscriptions/res/attached_rules_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/attached_rules_dialog.ui new file mode 100644 index 0000000000..19bc2c6d4b --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/attached_rules_dialog.ui @@ -0,0 +1,91 @@ + + + AttachedRulesDialog + + + + 0 + 0 + 760 + 420 + + + + Attached rules + + + + 12 + + + 12 + + + 12 + + + 12 + + + 8 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Create rule + + + + + + + Edit selected + + + + + + + Disable + + + + + + + Remove + + + + + + + Close + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/status_log_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/status_log_dialog.ui new file mode 100644 index 0000000000..a9f028dc7c --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/status_log_dialog.ui @@ -0,0 +1,70 @@ + + + StatusLogDialog + + + + 0 + 0 + 760 + 420 + + + + Status log + + + + 12 + + + 12 + + + 12 + + + 12 + + + 8 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Copy + + + + + + + Close + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui index 3e08d1e77f..54bc143ebf 100644 --- a/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui +++ b/ui/opensnitch/plugins/list_subscriptions/res/subscription_dialog.ui @@ -233,9 +233,9 @@ 12 - + - List file present + State @@ -250,91 +250,91 @@ - + - + - List meta present + Last checked - + - + - State + Last updated - + - + - Last checked + Failures - + - + - Last updated + Error - + - + - Failures + List file present - + - + - Error + List meta present - + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/subscription_status_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/subscription_status_dialog.ui new file mode 100644 index 0000000000..91767cf75a --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/subscription_status_dialog.ui @@ -0,0 +1,95 @@ + + + SubscriptionStatusDialog + + + + 0 + 0 + 700 + 440 + + + + Subscription status + + + + 12 + + + 12 + + + 12 + + + 12 + + + 8 + + + + + + + + true + + + + + + + QFrame::NoFrame + + + true + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Refresh + + + + + + + Edit + + + + + + + Close + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/res/text_inspect_dialog.ui b/ui/opensnitch/plugins/list_subscriptions/res/text_inspect_dialog.ui new file mode 100644 index 0000000000..f0427e52b0 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/res/text_inspect_dialog.ui @@ -0,0 +1,70 @@ + + + TextInspectDialog + + + + 0 + 0 + 760 + 420 + + + + Inspect + + + + 12 + + + 12 + + + 12 + + + 12 + + + 8 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Copy + + + + + + + Close + + + + + + + + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py index 8b13789179..7e1f069ef2 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/__init__.py @@ -1 +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", +] diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/action_file_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/action_file_controller.py new file mode 100644 index 0000000000..a8bf5376b1 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/action_file_controller.py @@ -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, + ) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/bulk_edit_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/bulk_edit_controller.py new file mode 100644 index 0000000000..84e0a9215b --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/bulk_edit_controller.py @@ -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, + ) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/context_menu_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/context_menu_controller.py new file mode 100644 index 0000000000..f4286c0371 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/context_menu_controller.py @@ -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() \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/defaults_ui_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/defaults_ui_controller.py new file mode 100644 index 0000000000..c5f8e68316 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/defaults_ui_controller.py @@ -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) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py new file mode 100644 index 0000000000..d1db7a2c0f --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py @@ -0,0 +1,409 @@ +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", ""), + "rule_attached": meta.get("rule_attached_detail", meta.get("rule_attached", "")), + "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 + if key == "rule_attached": + self.set_rule_attached_preview(row, text) + 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_rule_attached_preview(self, row: int, text: str): + attached_label = self._dialog._inspect_value_labels.get("rule_attached") + if attached_label is None: + return + + normalized = (text or "").strip() + if normalized == "": + normalized = "-" + attached_label.setText(normalized) + attached_label.setToolTip(normalized) + + attached_rules = self._dialog._table_data_controller.attached_rules_for_row( + row, + include_disabled=True, + ) + self._dialog._inspect_attached_rule_names = [ + str(entry.get("name", "")).strip() for entry in attached_rules + ] + self._dialog._inspect_attached_rule_names = [ + name for name in self._dialog._inspect_attached_rule_names if name != "" + ] + + if self._dialog._inspect_rules_button is None: + return + self._dialog._inspect_rules_button.setVisible(len(attached_rules) > 0) + self._dialog._inspect_rules_button.setEnabled(len(attached_rules) > 0) + + 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, + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py new file mode 100644 index 0000000000..758dcbf491 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py @@ -0,0 +1,313 @@ +import os +from typing import TYPE_CHECKING, Any, cast + +from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC +from opensnitch.plugins.list_subscriptions.ui.views.attached_rules_dialog import ( + AttachedRulesDialog, +) +from opensnitch.config import Config +from opensnitch.rules import Rule +from opensnitch.proto import ui_pb2 as ui_pb2 + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class RulesAttachmentController: + def __init__(self, *, dialog: "ListSubscriptionsDialog"): + self._dialog = dialog + + def attached_rules_snapshot(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 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] + dlg = AttachedRulesDialog( + self._dialog, + get_attached_rules=lambda: self.aggregate_attached_rules( + self._dialog._table_data_controller.attached_rules_for_row( + row, + include_disabled=True, + ) + ), + 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() + + 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._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._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._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() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py new file mode 100644 index 0000000000..0cc21d7d62 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py @@ -0,0 +1,444 @@ +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 + 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(), + } + + 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 + ) + + 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, + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py new file mode 100644 index 0000000000..66483d6638 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py @@ -0,0 +1,426 @@ +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) + + # -- 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.set_runtime_state(active=False) + self.set_refresh_busy(False) + return None + + def bind_runtime_plugin(self, plug: ListSubscriptions | None): + if plug is None: + return + try: + plug.signal_out.disconnect(self.handle_runtime_event) + except Exception: + pass + try: + plug.signal_out.connect(self.handle_runtime_event) + self._dialog._runtime_plugin = plug + except Exception: + 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 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/selection_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/selection_controller.py new file mode 100644 index 0000000000..61bc3abc61 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/selection_controller.py @@ -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) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py new file mode 100644 index 0000000000..57ea00f032 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py @@ -0,0 +1,187 @@ +from collections.abc import Callable +from typing import Literal + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtWidgets + +EmptyButtonBehavior = Literal["hide", "show-if-logs"] + + +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, + 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") + entries.append(f"[{timestamp}] [{log_level}] {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 + + @property + def full_text(self): + return self._full_text + + @property + def log_entries(self): + return self._log_entries + + def append_log( + self, + message: str, + *, + error: bool = False, + level: str | None = None, + dedupe: bool = False, + ): + append_log_entry( + self._log_entries, + message=message, + error=error, + level=level, + dedupe=dedupe, + last_signature=self._last_signature, + timestamp_format=self._timestamp_format, + limit=self._log_limit, + ) + + def set_status(self, message: str, *, error: bool = False): + 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 + + signature = (full_text, bool(error)) + if signature != self._last_signature: + self.append_log(full_text, error=error) + 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, + ) + dlg = StatusLogDialog( + parent, + title=title, + lines=self._log_entries[:], + fallback_text=self._full_text, + level_color=level_color, + timestamp_color=timestamp_color, + ) + dlg.exec() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py new file mode 100644 index 0000000000..ec64000525 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py @@ -0,0 +1,196 @@ +from typing import TYPE_CHECKING, Any + +from opensnitch.plugins.list_subscriptions.ui import 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.views.text_inspect_dialog import ( + TextInspectDialog, +) +from opensnitch.plugins.list_subscriptions.ui.workers.subscription_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 + + # -- 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: + 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) + + def show_dialog_message_inspect_dialog(self) -> None: + text = "\n".join(self._dialog._dialog_message_controller.log_entries).strip() + if text == "": + text = (self._dialog._dialog_message_controller.full_text or "").strip() + dlg = TextInspectDialog( + self._dialog, + title=QC.translate("stats", "Status log"), + text=text, + ) + dlg.exec() + + # -- URL test ----------------------------------------------------------- + + def test_url(self) -> None: + 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._url_worker = UrlTestWorker(url) + self._url_worker.finished.connect(self._dialog._url_test_finished.emit) + self._url_worker.start() + + def handle_url_test_finished(self, success: bool, message: str) -> None: + 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() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py new file mode 100644 index 0000000000..6852d1170f --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py @@ -0,0 +1,267 @@ +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", + ) + 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._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() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_status_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_status_controller.py new file mode 100644 index 0000000000..f76a8c0ce6 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_status_controller.py @@ -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) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py new file mode 100644 index 0000000000..9381c5fe48 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py @@ -0,0 +1,1099 @@ +import json +import os +from typing import TYPE_CHECKING, Any + +import requests + +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtGui, QtWidgets, QC +from opensnitch.plugins.list_subscriptions.models.subscriptions import ( + MutableSubscriptionSpec, + SubscriptionSpec, +) +from opensnitch.plugins.list_subscriptions.models.events import ( + SubscriptionEventPayload, +) +from opensnitch.plugins.list_subscriptions.ui.widgets.table_widgets import ( + SortableTableWidgetItem, +) +from opensnitch.plugins.list_subscriptions._utils import ( + DEFAULT_LISTS_DIR, + INTERVAL_UNITS, + SIZE_UNITS, + TIMEOUT_UNITS, + derive_filename, + display_str, + ensure_filename_type_suffix, + filename_from_content_disposition, + list_file_path, + normalize_groups, + normalize_lists_dir, + safe_filename, + strip_or_none, + subscription_rule_dir, + timestamp_sort_key, +) + +if TYPE_CHECKING: + from opensnitch.plugins.list_subscriptions.ui.views.list_subscriptions_dialog import ( + ListSubscriptionsDialog, + ) + + +class TableDataController: + def __init__( + self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] + ): + self._dialog = dialog + self._cols = columns + self._poll_timer = QtCore.QTimer(dialog) + self._poll_timer.setInterval(2000) + self._poll_timer.timeout.connect( + lambda: dialog.isVisible() and (not dialog._loading) and self.refresh_states() + ) + + def start_poll(self): + if not self._poll_timer.isActive(): + self._poll_timer.start() + + def stop_poll(self): + if self._poll_timer.isActive(): + self._poll_timer.stop() + + # -- Shared primitives ------------------------------------------------- + def _col(self, key: str): + return self._cols[key] + + def new_enabled_item(self, enabled: bool) -> SortableTableWidgetItem: + item = SortableTableWidgetItem("") + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState( + QtCore.Qt.CheckState.Checked if enabled else QtCore.Qt.CheckState.Unchecked + ) + item.setData(QtCore.Qt.ItemDataRole.UserRole, 1 if enabled else 0) + return item + + def update_row_sort_keys(self, row: int): + enabled_item = self._dialog.table.item(row, self._col("enabled")) + if enabled_item is None: + return + enabled_rank = ( + 0 if enabled_item.checkState() == QtCore.Qt.CheckState.Checked else 1 + ) + enabled_item.setData(QtCore.Qt.ItemDataRole.UserRole, enabled_rank) + + def sort_key_for_column(self, col: int, text: str): + value = (text or "").strip() + if col in ( + self._col("interval"), + self._col("timeout"), + self._col("max_size"), + ): + if value == "": + return -1 + try: + return int(value) + except Exception: + return value.lower() + if col in (self._col("last_checked"), self._col("last_updated")): + return timestamp_sort_key(value) + if col == self._col("state"): + normalized = (value or "").strip().lower() + if normalized in ("updated", "not_modified"): + return 0, normalized + if normalized == "pending": + return 1, normalized + return 2, normalized + return value.lower() + + def state_text_color(self, state: str): + palette = self._dialog.table.palette() + dark_theme = palette.base().color().lightness() < 128 + + if dark_theme: + colors = { + "disabled": "#B8C0CC", + "pending": "#F5D76E", + "busy": "#F5D76E", + "missing": "#FF8A80", + "updated": "#7CE3A1", + "not_modified": "#86C5FF", + "error": "#FF8A80", + "write_error": "#FF8A80", + "request_error": "#FF8A80", + "unexpected_error": "#FF8A80", + "bad_format": "#FF8A80", + "too_large": "#FF8A80", + "other": "#F7E37A", + } + else: + colors = { + "disabled": "#6B7280", + "pending": "#9A6700", + "busy": "#9A6700", + "missing": "#C62828", + "updated": "#0F8A4B", + "not_modified": "#1565C0", + "error": "#C62828", + "write_error": "#C62828", + "request_error": "#C62828", + "unexpected_error": "#C62828", + "bad_format": "#C62828", + "too_large": "#C62828", + "other": "#8D6E00", + } + + return QtGui.QColor(colors.get(state, colors["other"])) + + def status_log_level_color(self, level: str) -> str: + normalized = (level or "").strip().upper() + if normalized == "ERROR": + return self.state_text_color("error").name() + if normalized == "WARN": + return self.state_text_color("pending").name() + return self.state_text_color("updated").name() + + # -- Table interaction ------------------------------------------------- + def handle_table_clicked(self, index: QtCore.QModelIndex): + if not index.isValid() or index.column() != self._col("enabled"): + return + item = self._dialog.table.item(index.row(), self._col("enabled")) + if item is None: + return + checked = item.checkState() != QtCore.Qt.CheckState.Checked + item.setCheckState( + QtCore.Qt.CheckState.Checked if checked else QtCore.Qt.CheckState.Unchecked + ) + self.update_row_sort_keys(index.row()) + self._dialog._table_view_controller.apply_table_view_mode() + + # -- Runtime refresh helpers ------------------------------------------ + def _find_runtime_subscription(self, plug: Any, url: str, filename: str): + try: + for sub in plug._config.subscriptions: + if sub.url == url and sub.filename == filename: + return sub + except Exception: + return None + return None + + def _build_subscription_from_row( + self, + *, + plug: Any, + row: int, + enabled_from_row: bool, + ): + try: + interval_ok, interval_val = self.optional_int_from_text( + self.cell_text(row, self._col("interval")), + "Interval", + row=row, + ) + timeout_ok, timeout_val = self.optional_int_from_text( + self.cell_text(row, self._col("timeout")), + "Timeout", + row=row, + ) + max_size_ok, max_size_val = self.optional_int_from_text( + self.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 None + + enabled = True + if enabled_from_row: + enabled_item = self._dialog.table.item(row, self._col("enabled")) + enabled = ( + enabled_item is None + or enabled_item.checkState() == QtCore.Qt.CheckState.Checked + ) + + row_sub_edit = MutableSubscriptionSpec( + enabled=enabled, + name=self.cell_text(row, self._col("name")), + url=self.cell_text(row, self._col("url")), + filename=self.cell_text(row, self._col("filename")), + format=self.cell_text(row, self._col("format")) or "hosts", + groups=normalize_groups(self.cell_text(row, self._col("group"))), + interval=interval_val, + interval_units=strip_or_none( + self.cell_text(row, self._col("interval_units")) + ), + timeout=timeout_val, + timeout_units=strip_or_none( + self.cell_text(row, self._col("timeout_units")) + ), + max_size=max_size_val, + max_size_units=strip_or_none( + self.cell_text(row, self._col("max_size_units")) + ), + ) + return SubscriptionSpec.from_dict( + row_sub_edit.to_dict(), + plug._config.defaults, + ) + except Exception: + return None + + def _resolve_target_subscription( + self, + *, + plug: Any, + row: int, + enabled_from_row: bool, + ): + url = self.cell_text(row, self._col("url")) + filename = self.cell_text(row, self._col("filename")) + target_sub = self._find_runtime_subscription(plug, url, filename) + if target_sub is not None: + return target_sub + return self._build_subscription_from_row( + plug=plug, + row=row, + enabled_from_row=enabled_from_row, + ) + + # -- Runtime refresh actions ------------------------------------------ + def refresh_selected_now(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 + + loaded = self._dialog._runtime_controller.find_loaded_action() + _, _, plug = loaded if loaded is not None else (None, None, None) + if plug is None: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Plugin is not loaded. Save configuration first." + ), + error=True, + ) + return + + refresh_targets: list[tuple[SubscriptionSpec, str]] = [] + filename_changed = False + for row in rows: + url = self.cell_text(row, self._col("url")) + filename, row_filename_changed = self.ensure_row_final_filename(row) + if url == "" or filename == "": + self._dialog._status_controller.set_status( + QC.translate( + "stats", "URL and filename cannot be empty (row {0})." + ).format(row + 1), + error=True, + ) + return + filename_changed = filename_changed or row_filename_changed + + if filename_changed: + self._dialog._action_file_controller.save_action_file() + + for row in rows: + target_sub = self._resolve_target_subscription( + plug=plug, + row=row, + enabled_from_row=False, + ) + if target_sub is None: + self._dialog._status_controller.set_status( + QC.translate("stats", "Internal error: target_sub is None."), + error=True, + ) + return + list_path, _ = plug._paths(target_sub) + refresh_targets.append((target_sub, list_path)) + + refresh_keys = {plug._sub_key(target_sub) for target_sub, _ in refresh_targets} + self._dialog._runtime_controller.track_refresh_keys(refresh_keys) + self._dialog._status_controller.append_log( + QC.translate( + "stats", "Manual refresh requested for {0} selected subscription(s)." + ).format(len(refresh_targets)), + ) + plug.signal_in.emit( + { + "plugin": plug.get_name(), + "signal": plug.REFRESH_SUBSCRIPTIONS_SIGNAL, + "action_path": self._dialog._action_path, + "source": "manual_refresh", + "items": [ + SubscriptionEventPayload( + enabled=target_sub.enabled, + name=target_sub.name, + url=target_sub.url, + filename=target_sub.filename, + format=target_sub.format, + groups=list(target_sub.groups), + interval=target_sub.interval, + interval_units=target_sub.interval_units, + timeout=target_sub.timeout, + timeout_units=target_sub.timeout_units, + max_size=target_sub.max_size, + max_size_units=target_sub.max_size_units, + ) + for target_sub, _ in refresh_targets + ], + } + ) + if len(refresh_targets) == 1: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Subscription refresh triggered. Destination: {0}" + ).format(refresh_targets[0][1]), + error=False, + ) + return + + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Bulk refresh triggered for {0} selected subscriptions." + ).format(len(refresh_targets)), + error=False, + ) + + def refresh_all_now(self): + loaded = self._dialog._runtime_controller.find_loaded_action() + _, _, plug = loaded if loaded is not None else (None, None, None) + if plug is None: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Plugin is not loaded. Save configuration first." + ), + error=True, + ) + return + + rows = list(range(self._dialog.table.rowCount())) + if not rows: + self._dialog._status_controller.set_status( + QC.translate("stats", "No subscriptions available to refresh."), + error=True, + ) + return + + filename_changed = False + for row in rows: + url = self.cell_text(row, self._col("url")) + filename, row_filename_changed = self.ensure_row_final_filename(row) + if url == "" or filename == "": + self._dialog._status_controller.set_status( + QC.translate( + "stats", "URL and filename cannot be empty (row {0})." + ).format(row + 1), + error=True, + ) + return + filename_changed = filename_changed or row_filename_changed + + if filename_changed: + self._dialog._action_file_controller.save_action_file() + loaded = self._dialog._runtime_controller.find_loaded_action() + _, _, plug = loaded if loaded is not None else (None, None, None) + if plug is None: + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Plugin is not loaded. Save configuration first." + ), + error=True, + ) + return + + refresh_targets: list[SubscriptionSpec] = [] + for row in rows: + target_sub = self._resolve_target_subscription( + plug=plug, + row=row, + enabled_from_row=True, + ) + if target_sub is not None: + refresh_targets.append(target_sub) + + if not refresh_targets: + self._dialog._status_controller.set_status( + QC.translate("stats", "No subscriptions available to refresh."), + error=True, + ) + return + + refresh_keys = {plug._sub_key(sub) for sub in refresh_targets} + self._dialog._runtime_controller.track_refresh_keys(refresh_keys) + self._dialog._status_controller.append_log( + QC.translate( + "stats", "Manual refresh requested for all listed subscriptions ({0})." + ).format(len(refresh_targets)), + ) + plug.signal_in.emit( + { + "plugin": plug.get_name(), + "signal": plug.REFRESH_SUBSCRIPTIONS_SIGNAL, + "action_path": self._dialog._action_path, + "source": "manual_refresh", + "items": [ + SubscriptionEventPayload( + enabled=sub.enabled, + name=sub.name, + url=sub.url, + filename=sub.filename, + format=sub.format, + groups=list(sub.groups), + 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, + ) + for sub in refresh_targets + ], + } + ) + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Bulk refresh triggered for all listed subscriptions." + ), + error=False, + ) + + # -- Subscription data collection ------------------------------------- + def collect_subscriptions(self): + out: list[MutableSubscriptionSpec] = [] + auto_filled = 0 + for row in range(self._dialog.table.rowCount()): + enabled_item = self._dialog.table.item(row, self._col("enabled")) + interval = self.cell_text(row, self._col("interval")) + interval_units = self.cell_text(row, self._col("interval_units")) + timeout = self.cell_text(row, self._col("timeout")) + timeout_units = self.cell_text(row, self._col("timeout_units")) + max_size = self.cell_text(row, self._col("max_size")) + max_size_units = self.cell_text(row, self._col("max_size_units")) + name = self.cell_text(row, self._col("name")) + url = self.cell_text(row, self._col("url")) + list_type = ( + self.cell_text(row, self._col("format")) or "hosts" + ).strip().lower() + groups = normalize_groups(self.cell_text(row, self._col("group"))) + filename = safe_filename(self.cell_text(row, self._col("filename"))) + if filename == "": + filename = self.guess_filename(name, url) + if filename != "": + auto_filled += 1 + filename = ensure_filename_type_suffix(filename, list_type) + self.set_text_item(row, self._col("filename"), filename) + interval_ok, interval_val = self.optional_int_from_text( + interval, "Interval", row=row + ) + timeout_ok, timeout_val = self.optional_int_from_text( + timeout, "Timeout", row=row + ) + max_size_ok, max_size_val = self.optional_int_from_text( + max_size, "Max size", row=row + ) + if not interval_ok or not timeout_ok or not max_size_ok: + return None + sub = MutableSubscriptionSpec( + enabled=enabled_item is not None + and enabled_item.checkState() == QtCore.Qt.CheckState.Checked, + name=name, + url=url, + filename=filename, + format=list_type, + groups=groups, + interval=interval_val, + interval_units=strip_or_none(interval_units), + timeout=timeout_val, + timeout_units=strip_or_none(timeout_units), + max_size=max_size_val, + max_size_units=strip_or_none(max_size_units), + ) + if sub.url == "" or sub.filename == "": + self._dialog._status_controller.set_status( + QC.translate( + "stats", "URL and filename cannot be empty (row {0})." + ).format(row + 1), + error=True, + ) + return None + out.append(sub) + + if auto_filled > 0: + self._dialog._status_controller.append_log( + QC.translate( + "stats", "Auto-filled filename for {0} subscription(s)." + ).format(auto_filled), + level="WARN", + ) + self._dialog._status_controller.set_status( + QC.translate( + "stats", "Auto-filled filename for {0} subscription(s)." + ).format(auto_filled), + error=False, + ) + return out + + # -- Filename derivation and normalization ---------------------------- + def guess_filename(self, name: str, url: str): + from_header = self.filename_from_headers(url) + return safe_filename(derive_filename(name, url, "", from_header)) + + def filename_from_headers(self, url: str): + if (url or "").strip() == "": + return "" + try: + r = requests.head(url, allow_redirects=True, timeout=5) + cd = r.headers.get("Content-Disposition", "") + if cd: + return filename_from_content_disposition(cd) + except Exception: + return "" + return "" + + def ensure_row_final_filename(self, row: int): + name = self.cell_text(row, self._col("name")) + url = self.cell_text(row, self._col("url")) + list_type = ( + self.cell_text(row, self._col("format")) or "hosts" + ).strip().lower() + original = safe_filename(self.cell_text(row, self._col("filename"))) + final_name = original + changed = False + + if final_name == "": + final_name = self.guess_filename(name, url) + changed = final_name != "" + final_name = ensure_filename_type_suffix(final_name, list_type) + if final_name != original: + changed = True + + if final_name != "": + key = final_name + existing: set[str] = set() + for i in range(self._dialog.table.rowCount()): + if i == row: + continue + other = safe_filename(self.cell_text(i, self._col("filename"))) + if other != "": + existing.add(other) + if key in existing: + base, ext = os.path.splitext(final_name) + n = 2 + candidate = final_name + while candidate in existing: + suffix = f"-{n}" + candidate = f"{base}{suffix}{ext}" if ext else f"{base}{suffix}" + n += 1 + final_name = candidate + changed = True + + if changed: + self.set_text_item(row, self._col("filename"), final_name) + return final_name, changed + + # -- Row state and metadata ------------------------------------------- + def row_meta_snapshot(self, row: int): + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + attached_rules_by_dir = ( + self._dialog._rules_attachment_controller.attached_rules_snapshot() + ) + filename = safe_filename(self.cell_text(row, self._col("filename"))) + list_type = ( + self.cell_text(row, self._col("format")) or "hosts" + ).strip().lower() + groups = normalize_groups(self.cell_text(row, self._col("group"))) + attachment_matches = self.rule_attachment_matches( + lists_dir, + filename, + list_type, + groups, + attached_rules_by_dir=attached_rules_by_dir, + ) + list_path = list_file_path(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 = {} + + return { + "file_present": "yes" if file_exists else "no", + "meta_present": "yes" if meta_exists else "no", + "state": str( + meta.get( + "last_result", + self.cell_text(row, self._col("state")) or "never", + ) + ), + "rule_attached": "yes" if attachment_matches else "no", + "rule_attached_detail": self.rule_attachment_detail(attachment_matches), + "last_checked": str( + meta.get( + "last_checked", + self.cell_text(row, self._col("last_checked")) or "", + ) + ), + "last_updated": str( + meta.get( + "last_updated", + self.cell_text(row, self._col("last_updated")) or "", + ) + ), + "failures": str(meta.get("fail_count", "0")), + "error": str(meta.get("last_error", "")), + "list_path": list_path, + "meta_path": meta_path, + } + + def apply_url_error_indicator( + self, + row: int, + *, + enabled: bool, + state: str, + last_error: str, + ): + url_item = self._dialog.table.item(row, self._col("url")) + if url_item is None: + return + + normalized_state = (state or "").strip().lower() + has_error = enabled and ( + normalized_state + in { + "missing", + "error", + "write_error", + "request_error", + "unexpected_error", + "bad_format", + "too_large", + } + or (last_error or "").strip() != "" + ) + + if not has_error: + url_item.setIcon(QtGui.QIcon()) + url_item.setToolTip(url_item.text()) + url_item.setForeground( + self._dialog.table.palette().brush(QtGui.QPalette.ColorRole.Text) + ) + return + + style = self._dialog.table.style() + if style is not None: + url_item.setIcon( + style.standardIcon(QtWidgets.QStyle.StandardPixmap.SP_MessageBoxWarning) + ) + if (last_error or "").strip() != "": + url_item.setToolTip( + QC.translate("stats", "Subscription error: {0}").format(last_error) + ) + else: + url_item.setToolTip( + QC.translate("stats", "Subscription error state: {0}").format( + normalized_state + ) + ) + url_item.setForeground(QtGui.QBrush(self.state_text_color("other"))) + + def refresh_states(self): + with self._dialog._table_view_controller.sorting_suspended(): + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + attached_rules_by_dir = ( + self._dialog._rules_attachment_controller.attached_rules_snapshot() + ) + for row in range(self._dialog.table.rowCount()): + filename_item = self._dialog.table.item(row, self._col("filename")) + enabled_item = self._dialog.table.item(row, self._col("enabled")) + if filename_item is None or enabled_item is None: + continue + + url = self.cell_text(row, self._col("url")) + filename = safe_filename(filename_item.text()) + list_type = (self.cell_text(row, self._col("format")) or "hosts").strip().lower() + enabled = enabled_item.checkState() == QtCore.Qt.CheckState.Checked + list_path = list_file_path(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 = {} + 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 "" + groups = normalize_groups(self.cell_text(row, self._col("group"))) + attachment_matches = self.rule_attachment_matches( + lists_dir, + filename, + list_type, + groups, + attached_rules_by_dir=attached_rules_by_dir, + ) + rule_attached = "yes" if attachment_matches else "no" + rule_attached_detail = self.rule_attachment_detail(attachment_matches) + fg_color: QtGui.QColor + + if not enabled: + state = "disabled" + fg_color = self.state_text_color("disabled") + elif not file_exists: + # New/manual subscriptions may not be downloaded yet. + # Expose that as pending instead of an error-like missing state. + if not meta_exists or last_result in ("never", "", "busy"): + state = "pending" + fg_color = self.state_text_color("pending") + else: + state = "missing" + fg_color = self.state_text_color("missing") + elif last_result in ("updated", "not_modified"): + state = last_result + fg_color = self.state_text_color(last_result) + elif last_result in ( + "error", + "write_error", + "request_error", + "unexpected_error", + "bad_format", + "too_large", + ): + state = last_result + fg_color = self.state_text_color(last_result) + elif last_result == "busy": + state = "busy" + fg_color = self.state_text_color("busy") + else: + state = last_result + fg_color = self.state_text_color("other") + + self.set_text_item( + row, self._col("file"), "yes" if file_exists else "no", editable=False + ) + self.set_text_item( + row, self._col("meta"), "yes" if meta_exists else "no", editable=False + ) + self.set_text_item(row, self._col("state"), state, editable=False) + self.set_text_item( + row, + self._col("rule_attached"), + rule_attached, + editable=False, + ) + self.set_text_item( + row, + self._col("last_checked"), + last_checked, + editable=False, + ) + self.set_text_item( + row, + self._col("last_updated"), + last_updated, + editable=False, + ) + self.apply_url_error_indicator( + row, + enabled=enabled, + state=state, + last_error=last_error, + ) + + for col in ( + self._col("file"), + self._col("meta"), + self._col("state"), + self._col("rule_attached"), + self._col("last_checked"), + self._col("last_updated"), + ): + item = self._dialog.table.item(row, col) + if item is not None: + item.setForeground(fg_color) + self.update_row_sort_keys(row) + self._dialog.subscription_state_refreshed.emit( + url, + filename, + { + "file_present": "yes" if file_exists else "no", + "meta_present": "yes" if meta_exists else "no", + "state": state, + "rule_attached": rule_attached, + "rule_attached_detail": rule_attached_detail, + "last_checked": last_checked, + "last_updated": last_updated, + "failures": fail_count, + "error": last_error, + "list_path": list_path, + "meta_path": meta_path, + }, + ) + + def rule_attachment_matches( + self, + lists_dir: str, + filename: str, + list_type: str, + groups: list[str], + *, + attached_rules_by_dir: dict[str, list[dict[str, Any]]] | None = None, + include_disabled: bool = False, + ): + snapshot = ( + attached_rules_by_dir + if attached_rules_by_dir is not None + else self._dialog._rules_attachment_controller.attached_rules_snapshot() + ) + 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 snapshot.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 == "": + continue + if not include_disabled and 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 + + def rule_attachment_detail(self, matches: list[dict[str, Any]]): + if not matches: + return "no" + + unique_rules = {(entry["addr"], entry["name"]) for entry in matches} + sources_text = ( + self._dialog._rules_attachment_controller.rule_attachment_scope_summary( + matches + ) + ) + return QC.translate("stats", "yes ({0} rules via {1})").format( + len(unique_rules), + sources_text, + ) + + def rule_attached_value( + self, + lists_dir: str, + filename: str, + list_type: str, + groups: list[str], + *, + attached_rules_by_dir: dict[str, list[dict[str, Any]]] | None = None, + ): + matches = self.rule_attachment_matches( + lists_dir, + filename, + list_type, + groups, + attached_rules_by_dir=attached_rules_by_dir, + ) + return "yes" if matches else "no" + + def attached_rules_for_row( + self, + row: int, + *, + include_disabled: bool = False, + ): + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + filename = safe_filename(self.cell_text(row, self._col("filename"))) + list_type = ( + self.cell_text(row, self._col("format")) or "hosts" + ).strip().lower() + groups = normalize_groups(self.cell_text(row, self._col("group"))) + return self.rule_attachment_matches( + lists_dir, + filename, + list_type, + groups, + include_disabled=include_disabled, + ) + + # -- Row creation and cell access ------------------------------------- + def append_row(self, sub: MutableSubscriptionSpec): + row = self._dialog.table.rowCount() + self._dialog.table.insertRow(row) + enabled_item = self.new_enabled_item(bool(sub.enabled)) + self._dialog.table.setItem(row, self._col("enabled"), enabled_item) + + self.set_text_item(row, self._col("name"), str(sub.name)) + self.set_text_item(row, self._col("url"), str(sub.url)) + self.set_text_item(row, self._col("filename"), safe_filename(sub.filename)) + self.set_text_item(row, self._col("format"), str(sub.format)) + groups = normalize_groups(sub.groups) + self.set_text_item(row, self._col("group"), ", ".join(groups)) + interval = sub.interval + timeout = sub.timeout + max_size = sub.max_size + interval_units = sub.interval_units + timeout_units = sub.timeout_units + max_size_units = sub.max_size_units + self.set_text_item(row, self._col("interval"), display_str(interval)) + self.set_text_item( + row, + self._col("interval_units"), + display_str(interval_units), + ) + self.set_text_item(row, self._col("timeout"), display_str(timeout)) + self.set_text_item(row, self._col("timeout_units"), display_str(timeout_units)) + self.set_text_item(row, self._col("max_size"), display_str(max_size)) + self.set_text_item( + row, + self._col("max_size_units"), + display_str(max_size_units), + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, + self._col("interval_units"), + INTERVAL_UNITS, + display_str(interval_units), + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, + self._col("timeout_units"), + TIMEOUT_UNITS, + display_str(timeout_units), + ) + self._dialog._defaults_ui_controller.set_units_combo( + row, + self._col("max_size_units"), + SIZE_UNITS, + display_str(max_size_units), + ) + + self.set_text_item(row, self._col("file"), "", editable=False) + self.set_text_item(row, self._col("meta"), "", editable=False) + self.set_text_item(row, self._col("state"), "", editable=False) + self.set_text_item(row, self._col("rule_attached"), "", editable=False) + self.set_text_item(row, self._col("last_checked"), "", editable=False) + self.set_text_item(row, self._col("last_updated"), "", editable=False) + self.apply_url_error_indicator( + row, + enabled=bool(sub.enabled), + state="", + last_error="", + ) + self.update_row_sort_keys(row) + + def set_text_item(self, row: int, col: int, text: str, editable: bool = True): + item = self._dialog.table.item(row, col) + if item is None: + item = SortableTableWidgetItem() + self._dialog.table.setItem(row, col, item) + item.setText(text) + item.setData( + QtCore.Qt.ItemDataRole.UserRole, + self.sort_key_for_column(col, text), + ) + if editable: + item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsEditable) + else: + item.setFlags(item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) + + def cell_text(self, row: int, col: int): + widget = self._dialog.table.cellWidget(row, col) + if isinstance(widget, QtWidgets.QComboBox): + return (widget.currentText() or "").strip() + item = self._dialog.table.item(row, col) + if item is None: + return "" + return (item.text() or "").strip() + + def optional_int_from_text( + self, value: Any, field_name: str, row: int | None = None + ): + if value == "": + return True, None + parsed = self.to_int_or_keep(value, field_name, row=row) + if parsed is None: + return False, None + return True, parsed + + def to_int_or_keep(self, value: Any, field_name: str, row: int | None = None): + try: + parsed = int(value) + except Exception: + row_suffix = ( + QC.translate("stats", " (row {0})").format(row + 1) + if row is not None + else "" + ) + self._dialog._status_controller.set_status( + QC.translate("stats", "{0} must be a positive integer{1}.").format( + field_name, row_suffix + ), + error=True, + ) + return None + if parsed < 1: + row_suffix = ( + QC.translate("stats", " (row {0})").format(row + 1) + if row is not None + else "" + ) + self._dialog._status_controller.set_status( + QC.translate("stats", "{0} must be a positive integer{1}.").format( + field_name, row_suffix + ), + error=True, + ) + return None + return parsed diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py new file mode 100644 index 0000000000..63f190e74a --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py @@ -0,0 +1,193 @@ +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] + + @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"), + } + monitoring_only = { + self._col("state"), + self._col("rule_attached"), + 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 + + 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("rule_attached"), 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("rule_attached") not in resized_columns: + self._dialog.table.setColumnWidth(self._col("rule_attached"), 130) + 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) + 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) + 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) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/helpers.py b/ui/opensnitch/plugins/list_subscriptions/ui/helpers.py deleted file mode 100644 index fa774265ed..0000000000 --- a/ui/opensnitch/plugins/list_subscriptions/ui/helpers.py +++ /dev/null @@ -1,171 +0,0 @@ -import sys -from typing import TYPE_CHECKING - -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 # noqa: F401 - from PyQt5.QtCore import QCoreApplication as QC - - load_ui_type = uic.loadUiType - - -def _is_dark_palette(widget: QtWidgets.QWidget): - return widget.palette().color(QtGui.QPalette.ColorRole.Window).lightness() < 128 - - -def _section_background_color_name(widget: QtWidgets.QWidget): - bg_role = ( - QtGui.QPalette.ColorRole.AlternateBase - if _is_dark_palette(widget) - else QtGui.QPalette.ColorRole.Button - ) - return widget.palette().color(bg_role).name() - - -def _section_border_color_name(widget: QtWidgets.QWidget): - border_role = ( - QtGui.QPalette.ColorRole.Midlight - if _is_dark_palette(widget) - else QtGui.QPalette.ColorRole.Mid - ) - return widget.palette().color(border_role).name() - - -def _footer_separator_color_name(widget: QtWidgets.QWidget): - footer_role = ( - QtGui.QPalette.ColorRole.Midlight - if _is_dark_palette(widget) - else QtGui.QPalette.ColorRole.Dark - ) - return widget.palette().color(footer_role).name() - - -def _apply_section_bar_style( - widget: QtWidgets.QWidget, - container: QtWidgets.QFrame, - label: QtWidgets.QLabel, - *, - right_border: bool = False, - expanding_label: bool = False, -): - bg = _section_background_color_name(widget) - 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): - footer_color = _footer_separator_color_name(widget) - separator.setFixedHeight(1) - separator.setFrameShape(QtWidgets.QFrame.Shape.NoFrame) - separator.setStyleSheet( - f"QFrame {{ color: {footer_color}; background-color: {footer_color}; }}" - ) - - -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.") - ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py deleted file mode 100644 index cb90a55433..0000000000 --- a/ui/opensnitch/plugins/list_subscriptions/ui/list_subscriptions_dialog.py +++ /dev/null @@ -1,2684 +0,0 @@ -import json -import logging -import os -import sys -from contextlib import contextmanager -from typing import cast, Any, TYPE_CHECKING, Final - -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 # noqa: F401 - from PyQt5.QtCore import QCoreApplication as QC - - load_ui_type = uic.loadUiType - -from opensnitch.plugins.list_subscriptions.io.storage import read_json_locked -from opensnitch.plugins.list_subscriptions.models.config import PluginConfig -from opensnitch.plugins.list_subscriptions.models.subscriptions import ( - MutableSubscriptionSpec, - SubscriptionSpec, -) -from opensnitch.plugins.list_subscriptions.models.global_defaults import GlobalDefaults -from opensnitch.plugins.list_subscriptions.models.events import ( - RuntimeEventType, - SubscriptionEventPayload, -) -from opensnitch.actions import Actions -from opensnitch.nodes import Nodes -from opensnitch.plugins import PluginSignal -from opensnitch.plugins.list_subscriptions.models.action import ( - MutableActionConfig, -) -from opensnitch.plugins.list_subscriptions.ui.helpers import ( - _section_background_color_name, - _section_border_color_name, - _apply_section_bar_style, -) -from opensnitch.plugins.list_subscriptions.ui.toggle_switch_widget import ( - _replace_checkbox_with_toggle, -) -from opensnitch.plugins.list_subscriptions.ui.subscription_dialog import ( - SubscriptionDialog, -) -from opensnitch.plugins.list_subscriptions.ui.bulk_edit_dialog import BulkEditDialog -from opensnitch.plugins.list_subscriptions._utils import ( - ACTION_FILE, - DEFAULT_LISTS_DIR, - RES_DIR, - INTERVAL_UNITS, - TIMEOUT_UNITS, - SIZE_UNITS, - display_str, - derive_filename, - ensure_filename_type_suffix, - filename_from_content_disposition, - list_file_path, - normalize_group, - normalize_groups, - normalize_lists_dir, - normalize_unit, - safe_filename, - strip_or_none, - subscription_rule_dir, - timestamp_sort_key, -) -from opensnitch.plugins.list_subscriptions.io.storage import ( - write_json_atomic_locked, -) -from opensnitch.config import Config -from opensnitch.dialogs.ruleseditor import RulesEditorDialog -import requests -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_LAST_CHECKED: Final[int] = 15 -COL_LAST_UPDATED: Final[int] = 16 -COL_FAILS: Final[int] = 17 -COL_ERROR: Final[int] = 18 - -logger: Final[logging.Logger] = logging.getLogger(__name__) - - -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) - - -class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): - 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 - _nodes: Nodes - _actions: Actions - _action_path: str - _loading: bool - _global_defaults: GlobalDefaults - _state_poll_timer: QtCore.QTimer - _runtime_plugin: ListSubscriptions | None - _pending_runtime_reload: str | None - _pending_refresh_keys: set[str] - _active_refresh_keys: set[str] - - 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: RulesEditorDialog | 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._state_poll_timer = QtCore.QTimer(self) - self._state_poll_timer.setInterval(2000) - self._state_poll_timer.timeout.connect(self._refresh_states_if_visible) - self._build_ui() - - def showEvent(self, event: QtGui.QShowEvent | None): # type: ignore[override] - super().showEvent(event) - self.load_action_file() - if not self._state_poll_timer.isActive(): - self._state_poll_timer.start() - - def hideEvent(self, event: QtGui.QHideEvent | None): # type: ignore[override] - if self._state_poll_timer.isActive(): - self._state_poll_timer.stop() - super().hideEvent(event) - - def closeEvent(self, event: QtGui.QCloseEvent | None): # type: ignore[override] - if self._state_poll_timer.isActive(): - self._state_poll_timer.stop() - super().closeEvent(event) - - def _build_ui(self): - self.setupUi(self) - self.enable_plugin_check = _replace_checkbox_with_toggle( - self.enable_plugin_check - ) - 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() - section_border_color = _section_border_color_name(self) - self.global_actions_bar.setStyleSheet( - "QFrame {" - f"background-color: {_section_background_color_name(self)};" - 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; }}" - ) - 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() - - self.default_interval_spin.setRange(1, 999999) - self.default_interval_units.clear() - self.default_interval_units.addItems(INTERVAL_UNITS) - self.default_timeout_spin.setRange(1, 999999) - self.default_timeout_units.clear() - self.default_timeout_units.addItems(TIMEOUT_UNITS) - self.default_max_size_spin.setRange(1, 999999) - self.default_max_size_units.clear() - self.default_max_size_units.addItems(SIZE_UNITS) - - self.table.setColumnCount(19) - 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", "Last checked"), - QC.translate("stats", "Last updated"), - QC.translate("stats", "Failures"), - QC.translate("stats", "Error"), - ] - ) - 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_LAST_CHECKED, - COL_LAST_UPDATED, - ): - self.table.setItemDelegateForColumn(col, state_delegate) - header = self.table.horizontalHeader() - if header is not None: - header.setStretchLastSection(True) - 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.Stretch - ) - header.setSectionResizeMode( - COL_ERROR, QtWidgets.QHeaderView.ResizeMode.Stretch - ) - 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. - for col in ( - COL_INTERVAL, - COL_INTERVAL_UNITS, - COL_TIMEOUT, - COL_TIMEOUT_UNITS, - COL_MAX_SIZE, - COL_MAX_SIZE_UNITS, - COL_FILE, - COL_META, - COL_FAILS, - COL_ERROR, - ): - self.table.setColumnHidden(col, True) - - self.create_file_button.clicked.connect(self.create_action_file) - self.save_button.clicked.connect(self.save_action_file) - self.reload_button.clicked.connect(self.reload_runtime_and_config) - self.start_runtime_button.clicked.connect(self.start_runtime_clicked) - self.stop_runtime_button.clicked.connect(self.stop_runtime_clicked) - self.add_sub_button.clicked.connect(self.add_subscription_row) - self.create_global_rule_button.clicked.connect(self.create_global_rule) - self.edit_sub_button.clicked.connect(self.edit_action_clicked) - self.remove_sub_button.clicked.connect(self.remove_selected_subscription) - self.refresh_state_button.clicked.connect(self.refresh_all_now) - self.refresh_now_button.clicked.connect(self.refresh_selected_now) - self.create_rule_button.clicked.connect(self.create_rule_from_selected) - self.table.itemDoubleClicked.connect( - lambda *_: self.edit_selected_subscription() - ) - self.table.clicked.connect(self._handle_table_clicked) - self.table.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) - self.table.customContextMenuRequested.connect(self._open_table_context_menu) - sel_model = self.table.selectionModel() - if sel_model is not None: - sel_model.selectionChanged.connect( - lambda *_: self._update_selected_actions_state() - ) - self._set_runtime_state(active=False) - self._update_selected_actions_state() - - @contextmanager - def _sorting_suspended(self): - header = self.table.horizontalHeader() - sorting_enabled = self.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.table.setSortingEnabled(False) - try: - yield - finally: - self.table.setSortingEnabled(sorting_enabled) - if sorting_enabled and header is not None and sort_section >= 0: - self.table.sortItems(sort_section, sort_order) - - def _sort_key_for_column(self, col: int, text: str): - value = (text or "").strip() - if col in ( - COL_INTERVAL, - COL_TIMEOUT, - COL_MAX_SIZE, - COL_FAILS, - ): - if value == "": - return -1 - try: - return int(value) - except Exception: - return value.lower() - if col in (COL_LAST_CHECKED, COL_LAST_UPDATED): - return timestamp_sort_key(value) - if col == COL_STATE: - return self._state_sort_value(value) - return value.lower() - - def _state_sort_value(self, value: str): - normalized = (value or "").strip().lower() - if normalized in ("updated", "not_modified"): - return 0, normalized - if normalized == "pending": - return 1, normalized - return 2, normalized - - def _update_row_sort_keys(self, row: int): - enabled_item = self.table.item(row, COL_ENABLED) - state_item = self.table.item(row, COL_STATE) - last_checked_item = self.table.item(row, COL_LAST_CHECKED) - if enabled_item is None: - return - - enabled_rank = ( - 0 if enabled_item.checkState() == QtCore.Qt.CheckState.Checked else 1 - ) - state_text = state_item.text() if state_item is not None else "" - state_rank = self._state_sort_value(state_text) - last_checked_text = ( - last_checked_item.text() if last_checked_item is not None else "" - ) - last_checked_rank = timestamp_sort_key(last_checked_text) - combined_rank = (enabled_rank, state_rank, last_checked_rank) - - enabled_item.setData(QtCore.Qt.ItemDataRole.UserRole, combined_rank) - - def _new_enabled_item(self, enabled: bool) -> SortableTableWidgetItem: - item = SortableTableWidgetItem("") - item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsUserCheckable) - item.setCheckState( - QtCore.Qt.CheckState.Checked if enabled else QtCore.Qt.CheckState.Unchecked - ) - item.setData(QtCore.Qt.ItemDataRole.UserRole, 1 if enabled else 0) - return item - - def _sync_runtime_binding_state(self): - runtime_plugin = ListSubscriptions.get_instance() - if runtime_plugin is None: - _action_key, _action_obj, loaded_plugin = self._find_loaded_action() - runtime_plugin = loaded_plugin - - 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._runtime_plugin = None - self._set_runtime_state(active=False) - self._set_refresh_busy(False) - return None - - def _set_refresh_busy(self, busy: bool): - self.refresh_state_button.setEnabled(not busy) - self.refresh_now_button.setEnabled(not busy and len(self._selected_rows()) > 0) - - def _track_refresh_keys(self, keys: set[str]): - if not keys: - return - self._pending_refresh_keys.update(keys) - self._set_refresh_busy(True) - - def _clear_refresh_key(self, key: str): - self._pending_refresh_keys.discard(key) - self._active_refresh_keys.discard(key) - if not self._pending_refresh_keys and not self._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 load_action_file(self): - with self._sorting_suspended(): - self._loading = True - self._set_status("") - self._reload_nodes() - self.table.setRowCount(0) - self.create_file_button.setVisible(True) - self.lists_dir_edit.setText(DEFAULT_LISTS_DIR) - self.enable_plugin_check.setChecked(False) - self._set_runtime_state(active=False) - self._global_defaults = GlobalDefaults.from_dict( - {}, lists_dir=DEFAULT_LISTS_DIR - ) - self._apply_defaults_to_widgets() - - if not os.path.exists(self._action_path): - self._set_status( - QC.translate( - "stats", "Action file not found. Click 'Create action file'." - ), - error=False, - ) - self._loading = False - return - - try: - data = read_json_locked(self._action_path) - except Exception as e: - self._set_status( - QC.translate("stats", "Error reading action file: {0}").format( - str(e) - ), - error=True, - ) - self._loading = False - return - - action_model = MutableActionConfig.from_action_dict( - data, lists_dir=DEFAULT_LISTS_DIR - ) - self._global_defaults = action_model.plugin.defaults - self.enable_plugin_check.setChecked(action_model.enabled) - self._sync_runtime_binding_state() - self.lists_dir_edit.setText( - normalize_lists_dir(self._global_defaults.lists_dir) - ) - self._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._append_row(sub) - - self._loading = False - self.refresh_states() - self._update_selected_actions_state() - self.create_file_button.setVisible(False) - if migrated_legacy_group: - self.save_action_file() - self._set_status( - QC.translate( - "stats", - "Migrated legacy 'group' entries to 'groups' and auto-saved configuration.", - ), - error=False, - ) - return - if fixed_count > 0: - self._set_status( - QC.translate( - "stats", - "Loaded configuration with normalized subscription fields.", - ), - error=False, - ) - else: - self._set_status( - QC.translate("stats", "List subscriptions configuration loaded."), - error=False, - ) - - 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._set_status(QC.translate("stats", "Runtime is already active.")) - return - - if not os.path.exists(self._action_path): - self._set_status( - QC.translate( - "stats", - "Action file not found. Create and save the configuration first.", - ), - error=True, - ) - return - - if runtime_plugin is not None: - self._bind_runtime_plugin(runtime_plugin) - self._set_runtime_state( - active=None, text=QC.translate("stats", "Runtime: starting") - ) - try: - runtime_plugin.signal_in.emit( - { - "plugin": runtime_plugin.get_name(), - "signal": PluginSignal.ENABLE, - "action_path": self._action_path, - } - ) - except Exception: - self._set_runtime_state(active=False) - self._set_status( - QC.translate("stats", "Failed to start runtime."), - error=True, - ) - return - - plug = ListSubscriptions({}) - self._bind_runtime_plugin(plug) - self._set_runtime_state( - active=None, - text=QC.translate("stats", "Runtime: starting"), - ) - try: - plug.signal_in.emit( - { - "plugin": plug.get_name(), - "signal": PluginSignal.ENABLE, - "action_path": self._action_path, - } - ) - except Exception: - self._set_runtime_state(active=False) - self._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._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") - ) - try: - runtime_plugin.signal_in.emit( - { - "plugin": runtime_plugin.get_name(), - "signal": PluginSignal.DISABLE, - "action_path": self._action_path, - } - ) - except Exception: - self._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.load_action_file() - return - - self._bind_runtime_plugin(runtime_plugin) - self._pending_runtime_reload = "waiting_config_reload" - try: - runtime_plugin.signal_in.emit( - { - "plugin": runtime_plugin.get_name(), - "signal": PluginSignal.CONFIG_UPDATE, - "action_path": self._action_path, - } - ) - except Exception: - self._pending_runtime_reload = None - self._set_status( - QC.translate("stats", "Runtime reload failed to start. Restart UI."), - error=True, - ) - - def create_action_file(self): - try: - os.makedirs(os.path.dirname(self._action_path), mode=0o700, exist_ok=True) - if not os.path.exists(self._action_path): - action_model = MutableActionConfig.default(DEFAULT_LISTS_DIR) - write_json_atomic_locked( - self._action_path, - action_model.to_action_dict(), - ) - self.load_action_file() - self._set_status(QC.translate("stats", "Action file created."), error=False) - except Exception as e: - self._set_status( - QC.translate("stats", "Error creating action file: {0}").format(str(e)), - error=True, - ) - - def save_action_file(self): - if self._loading: - return - - if not os.path.exists(self._action_path): - self.create_action_file() - if not os.path.exists(self._action_path): - return - - subscriptions = self._collect_subscriptions() - if subscriptions is None: - return - - lists_dir = normalize_lists_dir( - self.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.default_interval_spin.value())), - interval_units=self.default_interval_units.currentText(), - timeout=max(1, int(self.default_timeout_spin.value())), - timeout_units=self.default_timeout_units.currentText(), - max_size=max(1, int(self.default_max_size_spin.value())), - max_size_units=self.default_max_size_units.currentText(), - user_agent=(self.default_user_agent.text() or "").strip(), - ) - action_model = MutableActionConfig.default(lists_dir) - action_model.enabled = self.enable_plugin_check.isChecked() - 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._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._set_status( - QC.translate( - "stats", "Invalid subscriptions: URL and filename are mandatory." - ), - error=True, - ) - return - - for row, sub in enumerate(normalized_subscriptions): - self._set_text_item(row, COL_NAME, sub.name) - self._set_text_item(row, COL_FILENAME, safe_filename(sub.filename)) - - try: - write_json_atomic_locked(self._action_path, action) - except Exception as e: - self._set_status( - QC.translate("stats", "Error saving action file: {0}").format(str(e)), - error=True, - ) - return - - self._apply_runtime_state(action_model.enabled) - self.refresh_states() - self._set_status( - QC.translate("stats", "List subscriptions configuration saved."), - error=False, - ) - - def refresh_states(self): - with self._sorting_suspended(): - lists_dir = normalize_lists_dir( - self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR - ) - for row in range(self.table.rowCount()): - filename_item = self.table.item(row, COL_FILENAME) - enabled_item = self.table.item(row, COL_ENABLED) - if filename_item is None or enabled_item is None: - continue - - filename = safe_filename(filename_item.text()) - list_type = ( - (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() - ) - enabled = enabled_item.checkState() == QtCore.Qt.CheckState.Checked - list_path = list_file_path(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 = {} - 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 "" - fg_color: QtGui.QColor - - if not enabled: - state = "disabled" - fg_color = self._state_text_color("disabled") - elif not file_exists: - # New/manual subscriptions may not be downloaded yet. - # Expose that as pending instead of an error-like missing state. - if not meta_exists or last_result in ("never", "", "busy"): - state = "pending" - fg_color = self._state_text_color("pending") - else: - state = "missing" - fg_color = self._state_text_color("missing") - elif last_result in ("updated", "not_modified"): - state = last_result - fg_color = self._state_text_color(last_result) - elif last_result in ( - "error", - "write_error", - "request_error", - "unexpected_error", - "bad_format", - "too_large", - ): - state = last_result - fg_color = self._state_text_color(last_result) - elif last_result == "busy": - state = "busy" - fg_color = self._state_text_color("busy") - else: - state = last_result - fg_color = self._state_text_color("other") - - self._set_text_item( - row, COL_FILE, "yes" if file_exists else "no", editable=False - ) - self._set_text_item( - row, COL_META, "yes" if meta_exists else "no", editable=False - ) - self._set_text_item(row, COL_STATE, state, editable=False) - self._set_text_item(row, COL_LAST_CHECKED, last_checked, editable=False) - self._set_text_item(row, COL_LAST_UPDATED, last_updated, editable=False) - self._set_text_item(row, COL_FAILS, fail_count, editable=False) - self._set_text_item(row, COL_ERROR, last_error, editable=False) - - for col in ( - COL_FILE, - COL_META, - COL_STATE, - COL_LAST_CHECKED, - COL_LAST_UPDATED, - COL_FAILS, - COL_ERROR, - ): - item = self.table.item(row, col) - if item is not None: - item.setForeground(fg_color) - self._update_row_sort_keys(row) - - def _state_text_color(self, state: str): - palette = self.table.palette() - dark_theme = palette.base().color().lightness() < 128 - - if dark_theme: - colors = { - "disabled": "#B8C0CC", - "pending": "#F5D76E", - "busy": "#F5D76E", - "missing": "#FF8A80", - "updated": "#7CE3A1", - "not_modified": "#86C5FF", - "error": "#FF8A80", - "write_error": "#FF8A80", - "request_error": "#FF8A80", - "unexpected_error": "#FF8A80", - "bad_format": "#FF8A80", - "too_large": "#FF8A80", - "other": "#F7E37A", - } - else: - colors = { - "disabled": "#6B7280", - "pending": "#9A6700", - "busy": "#9A6700", - "missing": "#C62828", - "updated": "#0F8A4B", - "not_modified": "#1565C0", - "error": "#C62828", - "write_error": "#C62828", - "request_error": "#C62828", - "unexpected_error": "#C62828", - "bad_format": "#C62828", - "too_large": "#C62828", - "other": "#8D6E00", - } - - return QtGui.QColor(colors.get(state, colors["other"])) - - def add_subscription_row(self): - dlg = SubscriptionDialog( - self, - self._global_defaults, - groups=self._known_groups(), - sub=MutableSubscriptionSpec.from_dict( - {"enabled": True}, - defaults=self._global_defaults, - require_url=False, - ensure_suffix=False, - ), - title="New subscription", - ) - if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: - return - - sub = dlg.subscription_spec() - with self._sorting_suspended(): - self._append_row(sub) - row = self.table.rowCount() - 1 - _, changed = self._ensure_row_final_filename(row) - if changed: - self.refresh_states() - - if not os.path.exists(self._action_path): - self.create_action_file() - self.save_action_file() - self._update_selected_actions_state() - - def edit_selected_subscription(self): - row = self.table.currentRow() - if row < 0: - self._set_status( - QC.translate("stats", "Select a subscription row first."), error=True - ) - return - with self._sorting_suspended(): - enabled_item = self.table.item(row, COL_ENABLED) - if enabled_item is None: - enabled_item = self._new_enabled_item(False) - self.table.setItem(row, COL_ENABLED, enabled_item) - - interval_ok, interval_val = self._optional_int_from_text( - self._cell_text(row, COL_INTERVAL), "Interval", row=row - ) - timeout_ok, timeout_val = self._optional_int_from_text( - self._cell_text(row, COL_TIMEOUT), "Timeout", row=row - ) - max_size_ok, max_size_val = self._optional_int_from_text( - self._cell_text(row, 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._cell_text(row, COL_NAME), - url=self._cell_text(row, COL_URL), - filename=self._cell_text(row, COL_FILENAME), - format=self._cell_text(row, COL_FORMAT) or "hosts", - groups=normalize_groups(self._cell_text(row, COL_GROUP)), - interval=interval_val, - interval_units=strip_or_none(self._cell_text(row, COL_INTERVAL_UNITS)), - timeout=timeout_val, - timeout_units=strip_or_none(self._cell_text(row, COL_TIMEOUT_UNITS)), - max_size=max_size_val, - max_size_units=strip_or_none(self._cell_text(row, COL_MAX_SIZE_UNITS)), - ) - meta = self._row_meta_snapshot(row) - dlg = SubscriptionDialog( - self, - self._global_defaults, - groups=self._known_groups(), - sub=sub, - meta=meta, - title="Edit subscription", - ) - if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: - return - updated = dlg.subscription_spec() - - with self._sorting_suspended(): - enabled_item = self.table.item(row, COL_ENABLED) - if enabled_item is None: - enabled_item = self._new_enabled_item(False) - self.table.setItem(row, 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._set_text_item(row, COL_NAME, updated.name) - self._set_text_item(row, COL_URL, updated.url) - self._set_text_item(row, COL_FILENAME, safe_filename(updated.filename)) - self._set_text_item(row, COL_FORMAT, updated.format) - self._set_text_item( - row, COL_GROUP, ", ".join(normalize_groups(updated.groups)) - ) - self._set_text_item(row, COL_INTERVAL, display_str(updated.interval)) - interval_units_val = display_str(updated.interval_units) - self._set_text_item(row, COL_INTERVAL_UNITS, interval_units_val) - self._set_text_item(row, COL_TIMEOUT, display_str(updated.timeout)) - timeout_units_val = display_str(updated.timeout_units) - self._set_text_item(row, COL_TIMEOUT_UNITS, timeout_units_val) - self._set_text_item(row, COL_MAX_SIZE, display_str(updated.max_size)) - max_size_units_val = display_str(updated.max_size_units) - self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units_val) - self._set_units_combo( - row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units_val - ) - self._set_units_combo( - row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units_val - ) - self._set_units_combo( - row, COL_MAX_SIZE_UNITS, SIZE_UNITS, max_size_units_val - ) - - _, changed = self._ensure_row_final_filename(row) - self._update_row_sort_keys(row) - self.save_action_file() - self.refresh_states() - if changed: - self._set_status( - QC.translate("stats", "Subscription updated and filename normalized."), - error=False, - ) - else: - self._set_status( - QC.translate("stats", "Subscription updated."), error=False - ) - - def edit_action_clicked(self): - rows = self._selected_rows() - if len(rows) == 0: - self._set_status( - QC.translate("stats", "Select one or more subscriptions first."), - error=True, - ) - return - if len(rows) == 1: - self.edit_selected_subscription() - return - self._bulk_edit(rows) - - def remove_selected_subscription(self): - rows = self._selected_rows() - if not rows: - row = self.table.currentRow() - if row >= 0: - rows = [row] - if not rows: - self._set_status( - QC.translate("stats", "Select one or more subscription rows first."), - error=True, - ) - return - for row in sorted(rows, reverse=True): - self.table.removeRow(row) - self.save_action_file() - self.refresh_states() - self._update_selected_actions_state() - self._set_status( - QC.translate("stats", "Selected subscriptions removed."), error=False - ) - - def _selected_rows(self): - idx = self.table.selectionModel() - if idx is None: - return [] - return sorted({i.row() for i in idx.selectedRows()}) - - def _handle_table_clicked(self, index: QtCore.QModelIndex): - if not index.isValid() or index.column() != COL_ENABLED: - return - item = self.table.item(index.row(), COL_ENABLED) - if item is None: - return - checked = item.checkState() != QtCore.Qt.CheckState.Checked - item.setCheckState( - QtCore.Qt.CheckState.Checked if checked else QtCore.Qt.CheckState.Unchecked - ) - self._update_row_sort_keys(index.row()) - header = self.table.horizontalHeader() - if ( - self.table.isSortingEnabled() - and header is not None - and header.sortIndicatorSection() - in (COL_ENABLED, COL_STATE, COL_LAST_CHECKED) - ): - self.table.sortItems( - header.sortIndicatorSection(), header.sortIndicatorOrder() - ) - - def _update_selected_actions_state(self): - count = len(self._selected_rows()) - has_selection = count > 0 - self.edit_sub_button.setEnabled(has_selection) - self.remove_sub_button.setEnabled(has_selection) - self.refresh_now_button.setEnabled( - has_selection - and not self._pending_refresh_keys - and not self._active_refresh_keys - ) - self.create_rule_button.setEnabled(has_selection) - - def _open_table_context_menu(self, pos: QtCore.QPoint): - rows = self._selected_rows() - if not rows: - row = self.table.rowAt(pos.y()) - if row >= 0: - self.table.selectRow(row) - rows = [row] - if not rows: - return - - menu = QtWidgets.QMenu(self.table) - viewport = self.table.viewport() - if viewport is None: - return - if len(rows) == 1: - 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")) - chosen = QtWidgets.QMenu.exec( - menu.actions(), - viewport.mapToGlobal(pos), - None, - menu, - ) - if chosen is act_edit: - self.edit_selected_subscription() - elif chosen is act_remove: - self.remove_selected_subscription() - elif chosen is act_refresh: - self.refresh_selected_now() - elif chosen is act_rule: - self.create_rule_from_selected() - return - - 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")) - chosen = QtWidgets.QMenu.exec( - menu.actions(), - viewport.mapToGlobal(pos), - None, - menu, - ) - if chosen is act_edit: - self._bulk_edit(rows) - elif chosen is act_remove: - self.remove_selected_subscription() - elif chosen is act_refresh: - self.refresh_selected_now() - elif chosen is act_rule: - self.create_rule_from_selected() - - def _bulk_edit(self, rows: list[int]): - if not rows: - return - dlg = BulkEditDialog( - self, - self._global_defaults, - groups=self._known_groups(), - selected_count=len(rows), - ) - if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: - return - values = dlg.values() - with self._sorting_suspended(): - for row in rows: - if values.get("enabled") is not None: - enabled_item = self.table.item(row, COL_ENABLED) - if enabled_item is None: - enabled_item = self._new_enabled_item(False) - self.table.setItem(row, 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._set_text_item( - row, COL_GROUP, ", ".join(normalize_groups(values["groups"])) - ) - if values.get("format") is not None: - self._set_text_item(row, COL_FORMAT, str(values["format"])) - if values.get("apply_interval"): - self._set_text_item( - row, COL_INTERVAL, display_str(values.get("interval")) - ) - interval_units = display_str(values.get("interval_units")) - self._set_text_item(row, COL_INTERVAL_UNITS, interval_units) - self._set_units_combo( - row, COL_INTERVAL_UNITS, INTERVAL_UNITS, interval_units - ) - if values.get("apply_timeout"): - self._set_text_item( - row, COL_TIMEOUT, display_str(values.get("timeout")) - ) - timeout_units = display_str(values.get("timeout_units")) - self._set_text_item(row, COL_TIMEOUT_UNITS, timeout_units) - self._set_units_combo( - row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, timeout_units - ) - if values.get("apply_max_size"): - self._set_text_item( - row, COL_MAX_SIZE, display_str(values.get("max_size")) - ) - max_size_units = display_str(values.get("max_size_units")) - self._set_text_item(row, COL_MAX_SIZE_UNITS, max_size_units) - self._set_units_combo( - row, COL_MAX_SIZE_UNITS, SIZE_UNITS, max_size_units - ) - self._ensure_row_final_filename(row) - self._update_row_sort_keys(row) - self.save_action_file() - self.refresh_states() - self._set_status( - QC.translate("stats", "Updated {0} selected subscriptions.").format( - len(rows) - ), - error=False, - ) - - def _known_groups(self): - groups: set[str] = set() - for row in range(self.table.rowCount()): - for g in normalize_groups(self._cell_text(row, COL_GROUP)): - if g not in ("", "all"): - groups.add(g) - return sorted(groups) - - def refresh_selected_now(self): - rows = self._selected_rows() - if not rows: - row = self.table.currentRow() - if row >= 0: - rows = [row] - if not rows: - self._set_status( - QC.translate("stats", "Select one or more subscription rows first."), - error=True, - ) - return - - _, _, plug = self._find_loaded_action() - if plug is None: - self._set_status( - QC.translate( - "stats", "Plugin is not loaded. Save configuration first." - ), - error=True, - ) - return - - refresh_targets: list[tuple[SubscriptionSpec, str]] = [] - filename_changed = False - for row in rows: - url = self._cell_text(row, COL_URL) - filename, row_filename_changed = self._ensure_row_final_filename(row) - if url == "" or filename == "": - self._set_status( - QC.translate( - "stats", "URL and filename cannot be empty (row {0})." - ).format(row + 1), - error=True, - ) - return - filename_changed = filename_changed or row_filename_changed - - if filename_changed: - self.save_action_file() - - for row in rows: - url = self._cell_text(row, COL_URL) - filename = self._cell_text(row, COL_FILENAME) - target_sub: SubscriptionSpec | None = None - try: - for sub in plug._config.subscriptions: - if sub.url == url and sub.filename == filename: - target_sub = sub - break - except Exception: - target_sub = None - - if target_sub is None: - try: - interval_ok, interval_val = self._optional_int_from_text( - self._cell_text(row, COL_INTERVAL), "Interval", row=row - ) - timeout_ok, timeout_val = self._optional_int_from_text( - self._cell_text(row, COL_TIMEOUT), "Timeout", row=row - ) - max_size_ok, max_size_val = self._optional_int_from_text( - self._cell_text(row, COL_MAX_SIZE), "Max size", row=row - ) - if not interval_ok or not timeout_ok or not max_size_ok: - return - row_sub_edit = MutableSubscriptionSpec( - enabled=True, - name=self._cell_text(row, COL_NAME), - url=url, - filename=filename, - format=self._cell_text(row, COL_FORMAT) or "hosts", - groups=normalize_groups(self._cell_text(row, COL_GROUP)), - interval=interval_val, - interval_units=strip_or_none( - self._cell_text(row, COL_INTERVAL_UNITS) - ), - timeout=timeout_val, - timeout_units=strip_or_none( - self._cell_text(row, COL_TIMEOUT_UNITS) - ), - max_size=max_size_val, - max_size_units=strip_or_none( - self._cell_text(row, COL_MAX_SIZE_UNITS) - ), - ) - row_sub = SubscriptionSpec.from_dict( - row_sub_edit.to_dict(), - plug._config.defaults, - ) - except Exception: - row_sub = None - if row_sub is None: - self._set_status( - QC.translate( - "stats", - "Subscription not found in runtime config. Save first, then retry.", - ), - error=True, - ) - return - target_sub = row_sub - if target_sub is None: - self._set_status( - QC.translate("stats", "Internal error: target_sub is None."), - error=True, - ) - return - list_path, _ = plug._paths(target_sub) - refresh_targets.append((target_sub, list_path)) - - refresh_keys = {plug._sub_key(target_sub) for target_sub, _ in refresh_targets} - self._track_refresh_keys(refresh_keys) - plug.signal_in.emit( - { - "plugin": plug.get_name(), - "signal": plug.REFRESH_SUBSCRIPTIONS_SIGNAL, - "action_path": self._action_path, - "source": "manual_refresh", - "items": [ - SubscriptionEventPayload( - enabled=target_sub.enabled, - name=target_sub.name, - url=target_sub.url, - filename=target_sub.filename, - format=target_sub.format, - groups=list(target_sub.groups), - interval=target_sub.interval, - interval_units=target_sub.interval_units, - timeout=target_sub.timeout, - timeout_units=target_sub.timeout_units, - max_size=target_sub.max_size, - max_size_units=target_sub.max_size_units, - ) - for target_sub, _ in refresh_targets - ], - } - ) - if len(refresh_targets) == 1: - self._set_status( - QC.translate( - "stats", "Subscription refresh triggered. Destination: {0}" - ).format(refresh_targets[0][1]), - error=False, - ) - return - - self._set_status( - QC.translate( - "stats", "Bulk refresh triggered for {0} selected subscriptions." - ).format(len(refresh_targets)), - error=False, - ) - - def refresh_all_now(self): - _, _, plug = self._find_loaded_action() - if plug is None: - self._set_status( - QC.translate( - "stats", "Plugin is not loaded. Save configuration first." - ), - error=True, - ) - return - - rows = list(range(self.table.rowCount())) - if not rows: - self._set_status( - QC.translate("stats", "No subscriptions available to refresh."), - error=True, - ) - return - - filename_changed = False - for row in rows: - url = self._cell_text(row, COL_URL) - filename, row_filename_changed = self._ensure_row_final_filename(row) - if url == "" or filename == "": - self._set_status( - QC.translate( - "stats", "URL and filename cannot be empty (row {0})." - ).format(row + 1), - error=True, - ) - return - filename_changed = filename_changed or row_filename_changed - - if filename_changed: - self.save_action_file() - _, _, plug = self._find_loaded_action() - if plug is None: - self._set_status( - QC.translate( - "stats", "Plugin is not loaded. Save configuration first." - ), - error=True, - ) - return - - refresh_targets: list[SubscriptionSpec] = [] - for row in rows: - url = self._cell_text(row, COL_URL) - filename = self._cell_text(row, COL_FILENAME) - target_sub: SubscriptionSpec | None = None - try: - for sub in plug._config.subscriptions: - if sub.url == url and sub.filename == filename: - target_sub = sub - break - except Exception: - target_sub = None - - if target_sub is None: - try: - enabled_item = self.table.item(row, COL_ENABLED) - interval_ok, interval_val = self._optional_int_from_text( - self._cell_text(row, COL_INTERVAL), "Interval", row=row - ) - timeout_ok, timeout_val = self._optional_int_from_text( - self._cell_text(row, COL_TIMEOUT), "Timeout", row=row - ) - max_size_ok, max_size_val = self._optional_int_from_text( - self._cell_text(row, COL_MAX_SIZE), "Max size", row=row - ) - if not interval_ok or not timeout_ok or not max_size_ok: - return - row_sub_edit = MutableSubscriptionSpec( - enabled=( - enabled_item is None - or enabled_item.checkState() == QtCore.Qt.CheckState.Checked - ), - name=self._cell_text(row, COL_NAME), - url=url, - filename=filename, - format=self._cell_text(row, COL_FORMAT) or "hosts", - groups=normalize_groups(self._cell_text(row, COL_GROUP)), - interval=interval_val, - interval_units=strip_or_none( - self._cell_text(row, COL_INTERVAL_UNITS) - ), - timeout=timeout_val, - timeout_units=strip_or_none( - self._cell_text(row, COL_TIMEOUT_UNITS) - ), - max_size=max_size_val, - max_size_units=strip_or_none( - self._cell_text(row, COL_MAX_SIZE_UNITS) - ), - ) - target_sub = SubscriptionSpec.from_dict( - row_sub_edit.to_dict(), - plug._config.defaults, - ) - except Exception: - target_sub = None - if target_sub is not None: - refresh_targets.append(target_sub) - - if not refresh_targets: - self._set_status( - QC.translate("stats", "No subscriptions available to refresh."), - error=True, - ) - return - - refresh_keys = {plug._sub_key(sub) for sub in refresh_targets} - self._track_refresh_keys(refresh_keys) - plug.signal_in.emit( - { - "plugin": plug.get_name(), - "signal": plug.REFRESH_SUBSCRIPTIONS_SIGNAL, - "action_path": self._action_path, - "source": "manual_refresh", - "items": [ - SubscriptionEventPayload( - enabled=sub.enabled, - name=sub.name, - url=sub.url, - filename=sub.filename, - format=sub.format, - groups=list(sub.groups), - 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, - ) - for sub in refresh_targets - ], - } - ) - self._set_status( - QC.translate( - "stats", "Bulk refresh triggered for all listed subscriptions." - ), - error=False, - ) - - def create_rule_from_selected(self): - rows = self._selected_rows() - if not rows: - row = self.table.currentRow() - if row >= 0: - rows = [row] - if not rows: - self._set_status( - QC.translate("stats", "Select one or more subscriptions first."), - error=True, - ) - return - - lists_dir = normalize_lists_dir( - self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR - ) - if len(rows) == 1: - row = rows[0] - url = self._cell_text(row, COL_URL) - filename, filename_changed = self._ensure_row_final_filename(row) - if url == "" or filename == "": - self._set_status( - QC.translate("stats", "URL and filename cannot be empty."), - error=True, - ) - return - if filename_changed: - # Persist resolved filename so subsequent plugin runs keep the same path. - self.save_action_file() - - list_type = (self._cell_text(row, COL_FORMAT) or "hosts").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.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._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}" - - if self._rules_dialog is None: - appicon = self.windowIcon() if self.windowIcon() is not None else None - try: - self._rules_dialog = RulesEditorDialog(parent=None, appicon=appicon) - except TypeError: - self._rules_dialog = RulesEditorDialog() - - self._rules_dialog.new_rule() - if not self._configure_rules_dialog_for_local_user(): - return - self._apply_rule_editor_defaults() - - # Rules editor expects a directory containing one or more hosts files. - self._rules_dialog.dstListsCheck.setChecked(True) - self._rules_dialog.dstListsLine.setText(rule_dir) - if self._rules_dialog.ruleNameEdit.text().strip() == "": - self._rules_dialog.ruleNameEdit.setText(rule_name) - if self._rules_dialog.ruleDescEdit.toPlainText().strip() == "": - self._rules_dialog.ruleDescEdit.setPlainText(desc) - self._rules_dialog.raise_() - self._rules_dialog.activateWindow() - self._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.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._set_status( - QC.translate( - "stats", "Error preparing global rule directory: {0}" - ).format(str(e)), - error=True, - ) - return - - if self._rules_dialog is None: - appicon = self.windowIcon() if self.windowIcon() is not None else None - try: - self._rules_dialog = RulesEditorDialog(parent=None, appicon=appicon) - except TypeError: - self._rules_dialog = RulesEditorDialog() - - self._rules_dialog.new_rule() - if not self._configure_rules_dialog_for_local_user(): - return - self._apply_rule_editor_defaults() - rule_name = "00-blocklist-all" - self._rules_dialog.dstListsCheck.setChecked(True) - self._rules_dialog.dstListsLine.setText(rule_dir) - if self._rules_dialog.ruleNameEdit.text().strip() == "": - self._rules_dialog.ruleNameEdit.setText(rule_name) - if self._rules_dialog.ruleDescEdit.toPlainText().strip() == "": - self._rules_dialog.ruleDescEdit.setPlainText("From list subscription : all") - self._rules_dialog.raise_() - self._rules_dialog.activateWindow() - self._set_status( - QC.translate( - "stats", "Rules Editor opened with global list directory path." - ), - error=False, - ) - - def _configure_rules_dialog_for_local_user(self): - if self._rules_dialog is None: - return False - - local_addr = None - for addr in self._nodes.get().keys(): - try: - if self._nodes.is_local(addr): - local_addr = addr - break - except Exception: - continue - - if local_addr is None: - self._set_status( - QC.translate( - "stats", - "No local OpenSnitch node is connected. Rules can only be created for the local user.", - ), - error=True, - ) - self._rules_dialog.hide() - return False - - nodes_combo = self._rules_dialog.nodesCombo - node_idx = nodes_combo.findData(local_addr) - if node_idx != -1: - nodes_combo.setCurrentIndex(node_idx) - nodes_combo.setEnabled(False) - self._rules_dialog.nodeApplyAllCheck.setChecked(False) - self._rules_dialog.nodeApplyAllCheck.setEnabled(False) - self._rules_dialog.nodeApplyAllCheck.setVisible(False) - - uid_text = str(os.getuid()) - uid_combo = self._rules_dialog.uidCombo - uid_idx = uid_combo.findData(int(uid_text)) - self._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): - if self._rules_dialog is None: - return - self._rules_dialog.enableCheck.setChecked(True) - duration_idx = self._rules_dialog.durationCombo.findData(Config.DURATION_ALWAYS) - if duration_idx < 0: - duration_idx = self._rules_dialog.durationCombo.findText( - Config.DURATION_ALWAYS, - QtCore.Qt.MatchFlag.MatchFixedString, - ) - if duration_idx < 0: - duration_idx = 8 - self._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._cell_text(r, COL_GROUP))) for r in rows - ] - common = ( - set.intersection(*selected_group_sets) if selected_group_sets else set() - ) - known = self._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, - 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._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._cell_text(row, COL_GROUP)) - groups.append(target_group) - groups = normalize_groups(groups) - self._set_text_item(row, 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._set_status( - QC.translate( - "stats", "Error preparing list rule directory: {0}" - ).format(str(e)), - error=True, - ) - return None - - def _apply_runtime_state(self, enabled: bool): - old_key, _old_action, old_plugin = self._find_loaded_action() - 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._action_path, - } - ) - except Exception: - self._set_status( - QC.translate( - "stats", "Config saved but runtime reload failed. Restart UI." - ), - error=True, - ) - return - if not enabled and old_key is not None: - self._actions.delete(old_key) - return - - if not enabled: - if old_key is not None: - self._actions.delete(old_key) - return - - obj, compiled = self._actions.load(self._action_path) - if obj is None or compiled is None: - self._set_status( - QC.translate( - "stats", "Config saved but runtime reload failed. Restart UI." - ), - 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._actions.delete(old_key) - if isinstance(action_name, str) and action_name != "": - self._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._set_status( - QC.translate( - "stats", "Config saved but runtime reload failed. Restart UI." - ), - error=True, - ) - return - self._bind_runtime_plugin(plug) - try: - plug.signal_in.emit( - { - "plugin": plug.get_name(), - "signal": PluginSignal.ENABLE, - "action_path": self._action_path, - } - ) - except Exception: - self._set_status( - QC.translate( - "stats", "Config saved but runtime reload failed. Restart UI." - ), - error=True, - ) - - def _bind_runtime_plugin(self, plug: ListSubscriptions | None): - if plug is None: - return - try: - plug.signal_out.disconnect(self._handle_runtime_event) - except Exception: - pass - try: - plug.signal_out.connect(self._handle_runtime_event) - self._runtime_plugin = plug - except Exception: - self._runtime_plugin = None - - 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._pending_refresh_keys: - self._pending_refresh_keys.discard(key) - self._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.refresh_states() - self._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._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._pending_runtime_reload == "waiting_config_reload": - if event_name == RuntimeEventType.CONFIG_RELOADED: - self._pending_runtime_reload = None - self.load_action_file() - return - if is_error: - self._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._set_status(message, error=is_error) - - 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.runtime_status_label.setStyleSheet(style) - self.runtime_status_label.setText(text) - self.start_runtime_button.setEnabled(active is not True) - self.stop_runtime_button.setEnabled(active is not False) - - def _find_loaded_action(self): - for action_key, action_obj in self._actions.getAll().items(): - if action_obj is None: - continue - action_obj_dict = cast(dict[str, Any], action_obj) - action_cfg: 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 - return None, None, None - - def _collect_subscriptions(self): - out: list[MutableSubscriptionSpec] = [] - auto_filled = 0 - for row in range(self.table.rowCount()): - enabled_item = self.table.item(row, COL_ENABLED) - interval = self._cell_text(row, COL_INTERVAL) - interval_units = self._cell_text(row, COL_INTERVAL_UNITS) - timeout = self._cell_text(row, COL_TIMEOUT) - timeout_units = self._cell_text(row, COL_TIMEOUT_UNITS) - max_size = self._cell_text(row, COL_MAX_SIZE) - max_size_units = self._cell_text(row, COL_MAX_SIZE_UNITS) - name = self._cell_text(row, COL_NAME) - url = self._cell_text(row, COL_URL) - list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() - groups = normalize_groups(self._cell_text(row, COL_GROUP)) - filename = safe_filename(self._cell_text(row, COL_FILENAME)) - if filename == "": - filename = self._guess_filename(name, url) - if filename != "": - auto_filled += 1 - filename = ensure_filename_type_suffix(filename, list_type) - self._set_text_item(row, COL_FILENAME, filename) - interval_ok, interval_val = self._optional_int_from_text( - interval, "Interval", row=row - ) - timeout_ok, timeout_val = self._optional_int_from_text( - timeout, "Timeout", row=row - ) - max_size_ok, max_size_val = self._optional_int_from_text( - max_size, "Max size", row=row - ) - if not interval_ok or not timeout_ok or not max_size_ok: - return None - sub = MutableSubscriptionSpec( - enabled=enabled_item is not None - and enabled_item.checkState() == QtCore.Qt.CheckState.Checked, - name=name, - url=url, - filename=filename, - format=list_type, - groups=groups, - interval=interval_val, - interval_units=strip_or_none(interval_units), - timeout=timeout_val, - timeout_units=strip_or_none(timeout_units), - max_size=max_size_val, - max_size_units=strip_or_none(max_size_units), - ) - if sub.url == "" or sub.filename == "": - self._set_status( - QC.translate( - "stats", "URL and filename cannot be empty (row {0})." - ).format(row + 1), - error=True, - ) - return None - out.append(sub) - - if auto_filled > 0: - self._set_status( - QC.translate( - "stats", "Auto-filled filename for {0} subscription(s)." - ).format(auto_filled), - error=False, - ) - return out - - def _row_meta_snapshot(self, row: int): - lists_dir = normalize_lists_dir( - self.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR - ) - filename = safe_filename(self._cell_text(row, COL_FILENAME)) - list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() - list_path = list_file_path(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 = {} - - return { - "file_present": "yes" if file_exists else "no", - "meta_present": "yes" if meta_exists else "no", - "state": str( - meta.get("last_result", self._cell_text(row, COL_STATE) or "never") - ), - "last_checked": str( - meta.get("last_checked", self._cell_text(row, COL_LAST_CHECKED) or "") - ), - "last_updated": str( - meta.get("last_updated", self._cell_text(row, COL_LAST_UPDATED) or "") - ), - "failures": str( - meta.get("fail_count", self._cell_text(row, COL_FAILS) or "0") - ), - "error": str(meta.get("last_error", self._cell_text(row, COL_ERROR) or "")), - "list_path": list_path, - "meta_path": meta_path, - } - - def _ensure_row_final_filename(self, row: int): - name = self._cell_text(row, COL_NAME) - url = self._cell_text(row, COL_URL) - list_type = (self._cell_text(row, COL_FORMAT) or "hosts").strip().lower() - original = safe_filename(self._cell_text(row, COL_FILENAME)) - final_name = original - changed = False - - if final_name == "": - final_name = self._guess_filename(name, url) - changed = final_name != "" - final_name = ensure_filename_type_suffix(final_name, list_type) - if final_name != original: - changed = True - - if final_name != "": - key = final_name - existing: set[str] = set() - for i in range(self.table.rowCount()): - if i == row: - continue - other = safe_filename(self._cell_text(i, COL_FILENAME)) - if other != "": - existing.add(other) - if key in existing: - base, ext = os.path.splitext(final_name) - n = 2 - candidate = final_name - while candidate in existing: - suffix = f"-{n}" - candidate = f"{base}{suffix}{ext}" if ext else f"{base}{suffix}" - n += 1 - final_name = candidate - changed = True - - if changed: - self._set_text_item(row, COL_FILENAME, final_name) - return final_name, changed - - def _append_row(self, sub: MutableSubscriptionSpec): - row = self.table.rowCount() - self.table.insertRow(row) - enabled_item = self._new_enabled_item(bool(sub.enabled)) - self.table.setItem(row, COL_ENABLED, enabled_item) - - self._set_text_item(row, COL_NAME, str(sub.name)) - self._set_text_item(row, COL_URL, str(sub.url)) - self._set_text_item(row, COL_FILENAME, safe_filename(sub.filename)) - self._set_text_item(row, COL_FORMAT, str(sub.format)) - groups = normalize_groups(sub.groups) - self._set_text_item(row, COL_GROUP, ", ".join(groups)) - interval = sub.interval - timeout = sub.timeout - max_size = sub.max_size - interval_units = sub.interval_units - timeout_units = sub.timeout_units - max_size_units = sub.max_size_units - self._set_text_item(row, COL_INTERVAL, display_str(interval)) - self._set_text_item(row, COL_INTERVAL_UNITS, display_str(interval_units)) - self._set_text_item(row, COL_TIMEOUT, display_str(timeout)) - self._set_text_item(row, COL_TIMEOUT_UNITS, display_str(timeout_units)) - self._set_text_item(row, COL_MAX_SIZE, display_str(max_size)) - self._set_text_item(row, COL_MAX_SIZE_UNITS, display_str(max_size_units)) - self._set_units_combo( - row, COL_INTERVAL_UNITS, INTERVAL_UNITS, display_str(interval_units) - ) - self._set_units_combo( - row, COL_TIMEOUT_UNITS, TIMEOUT_UNITS, display_str(timeout_units) - ) - self._set_units_combo( - row, COL_MAX_SIZE_UNITS, SIZE_UNITS, display_str(max_size_units) - ) - - self._set_text_item(row, COL_FILE, "", editable=False) - self._set_text_item(row, COL_META, "", editable=False) - self._set_text_item(row, COL_STATE, "", editable=False) - self._set_text_item(row, COL_LAST_CHECKED, "", editable=False) - self._set_text_item(row, COL_LAST_UPDATED, "", editable=False) - self._set_text_item(row, COL_FAILS, "", editable=False) - self._set_text_item(row, COL_ERROR, "", editable=False) - self._update_row_sort_keys(row) - - def _reload_nodes(self): - self.nodes_combo.blockSignals(True) - self.nodes_combo.clear() - for addr in self._nodes.get_nodes(): - self.nodes_combo.addItem(addr, addr) - self.nodes_combo.blockSignals(False) - - def _apply_defaults_to_widgets(self): - self.default_interval_spin.setValue(max(1, int(self._global_defaults.interval))) - self.default_interval_units.setCurrentText( - normalize_unit( - self._global_defaults.interval_units, INTERVAL_UNITS, "hours" - ) - ) - self.default_timeout_spin.setValue(max(1, int(self._global_defaults.timeout))) - self.default_timeout_units.setCurrentText( - normalize_unit( - self._global_defaults.timeout_units, TIMEOUT_UNITS, "seconds" - ) - ) - self.default_max_size_spin.setValue(max(1, int(self._global_defaults.max_size))) - self.default_max_size_units.setCurrentText( - normalize_unit(self._global_defaults.max_size_units, SIZE_UNITS, "MB") - ) - self.default_user_agent.setText( - (self._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.table.setCellWidget(row, col, combo) - - def _guess_filename(self, name: str, url: str): - from_header = self._filename_from_headers(url) - return safe_filename(derive_filename(name, url, "", from_header)) - - def _filename_from_headers(self, url: str): - if (url or "").strip() == "": - return "" - try: - r = requests.head(url, allow_redirects=True, timeout=5) - cd = r.headers.get("Content-Disposition", "") - if cd: - return filename_from_content_disposition(cd) - except Exception: - return "" - return "" - - def _set_text_item(self, row: int, col: int, text: str, editable: bool = True): - item = self.table.item(row, col) - if item is None: - item = SortableTableWidgetItem() - self.table.setItem(row, col, item) - item.setText(text) - item.setData( - QtCore.Qt.ItemDataRole.UserRole, - self._sort_key_for_column(col, text), - ) - if editable: - item.setFlags(item.flags() | QtCore.Qt.ItemFlag.ItemIsEditable) - else: - item.setFlags(item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) - - def _cell_text(self, row: int, col: int): - w = self.table.cellWidget(row, col) - if isinstance(w, QtWidgets.QComboBox): - return (w.currentText() or "").strip() - item = self.table.item(row, col) - if item is None: - return "" - return (item.text() or "").strip() - - def _optional_int_from_text( - self, value: Any, field_name: str, row: int | None = None - ): - if value == "": - return True, None - parsed = self._to_int_or_keep(value, field_name, row=row) - if parsed is None: - return False, None - return True, parsed - - def _to_int_or_keep(self, value: Any, field_name: str, row: int | None = None): - try: - parsed = int(value) - except Exception: - row_suffix = ( - QC.translate("stats", " (row {0})").format(row + 1) - if row is not None - else "" - ) - self._set_status( - QC.translate("stats", "{0} must be a positive integer{1}.").format( - field_name, row_suffix - ), - error=True, - ) - return None - if parsed < 1: - row_suffix = ( - QC.translate("stats", " (row {0})").format(row + 1) - if row is not None - else "" - ) - self._set_status( - QC.translate("stats", "{0} must be a positive integer{1}.").format( - field_name, row_suffix - ), - error=True, - ) - return None - return parsed - - def _set_status(self, msg: str, error: bool = False): - self.status_label.setStyleSheet("color: red;" if error else "color: green;") - self.status_label.setText(msg) - - def _refresh_states_if_visible(self): - if self.isVisible() and not self._loading: - self.refresh_states() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/attached_rules_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/attached_rules_dialog.py new file mode 100644 index 0000000000..bb0dec493b --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/attached_rules_dialog.py @@ -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() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/bulk_edit_dialog.py similarity index 68% rename from ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py rename to ui/opensnitch/plugins/list_subscriptions/ui/views/bulk_edit_dialog.py index b2dd8f9730..72730ca3b0 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/bulk_edit_dialog.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/bulk_edit_dialog.py @@ -1,41 +1,25 @@ import logging import os -import sys from typing import Any, TYPE_CHECKING, Final -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 # noqa: F401 - from PyQt5.QtCore import QCoreApplication as QC - - load_ui_type = uic.loadUiType +from opensnitch.plugins.list_subscriptions.ui import ( + QtCore, + QtWidgets, + QC, + load_ui_type, +) -from opensnitch.plugins.list_subscriptions.ui.helpers import ( +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.toggle_switch_widget import ToggleSwitch +from opensnitch.plugins.list_subscriptions.ui.widgets.toggle_switch_widget import ( + ToggleSwitch, +) from opensnitch.plugins.list_subscriptions.models.global_defaults import ( GlobalDefaults, ) @@ -46,7 +30,6 @@ SIZE_UNITS, normalize_group, normalize_groups, - normalize_unit, ) @@ -176,7 +159,7 @@ def _build_ui(self): 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.save_button.clicked.connect(self.validate_then_accept) self.enabled_value.setChecked(True) self.group_value.clear() @@ -187,44 +170,47 @@ def _build_ui(self): self.group_value.setCurrentText("") self.format_value.clear() self.format_value.addItems(("hosts",)) - self.interval_spin.setRange(0, 999999) - self.interval_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( + _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, - ) - ) - self.interval_spin.setValue(0) - self.interval_units.clear() - self.interval_units.addItems(INTERVAL_UNITS) - self.interval_units.setCurrentText( - normalize_unit(self._defaults.interval_units, INTERVAL_UNITS, "hours") + ), ) - self.timeout_spin.setRange(0, 999999) - self.timeout_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( + _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, - ) - ) - self.timeout_spin.setValue(0) - self.timeout_units.clear() - self.timeout_units.addItems(TIMEOUT_UNITS) - self.timeout_units.setCurrentText( - normalize_unit(self._defaults.timeout_units, TIMEOUT_UNITS, "seconds") + ), ) - self.max_size_spin.setRange(0, 999999) - self.max_size_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( + _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.max_size_spin.setValue(0) - self.max_size_units.clear() - self.max_size_units.addItems(SIZE_UNITS) - self.max_size_units.setCurrentText( - normalize_unit(self._defaults.max_size_units, SIZE_UNITS, "MB") + ), ) self._add_change_row( @@ -251,11 +237,11 @@ def _build_ui(self): 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.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) + 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, @@ -265,12 +251,10 @@ def _build_ui(self): self.max_size_units, inherit_wording=False, ) - self._sync_apply_fields_state() - self._sync_optional_fields_state() + self.sync_apply_fields_state() + self.sync_optional_fields_state() self.resize(760, 420) - # Les méthodes supprimées ci-dessus sont désormais remplacées par l'utilisation directe des helpers dans _build_ui. - def _build_compound_editor( self, primary: QtWidgets.QWidget, secondary: QtWidgets.QWidget ): @@ -290,39 +274,43 @@ def _add_change_row(self, key: str, label: str, editor: QtWidgets.QWidget): self.changes_tree.setItemWidget(item, 1, editor) self._field_items[key] = item - def _is_field_applied(self, key: str): + # -- 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): + def handle_item_changed(self, item: QtWidgets.QTreeWidgetItem, column: int) -> None: if column != 0: return - self._sync_apply_fields_state() + self.sync_apply_fields_state() - def _sync_optional_fields_state(self): + def sync_optional_fields_state(self) -> None: self.interval_units.setEnabled( - self._is_field_applied("interval") and self.interval_spin.value() > 0 + 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.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 + self.is_field_applied("max_size") and self.max_size_spin.value() > 0 ) - def _sync_apply_fields_state(self): - 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() + 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): - if not any(self._is_field_applied(key) for key in self._field_items): + 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.") ) @@ -330,38 +318,40 @@ def _validate_then_accept(self): self.error_label.setText("") self.accept() - def values(self): + # -- Result extraction -------------------------------------------------- + + def values(self) -> dict[str, Any]: return { "enabled": ( self.enabled_value.isChecked() - if self._is_field_applied("enabled") + if self.is_field_applied("enabled") else None ), "groups": ( normalize_groups(self.group_value.currentText()) - if self._is_field_applied("groups") + if self.is_field_applied("groups") else None ), "format": ( (self.format_value.currentText() or "hosts").strip().lower() - if self._is_field_applied("format") + if self.is_field_applied("format") else None ), - "apply_interval": self._is_field_applied("interval"), + "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"), + "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"), + "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() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/helpers.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/helpers.py new file mode 100644 index 0000000000..300807ff59 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/helpers.py @@ -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) + + diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py new file mode 100644 index 0000000000..ac750dd42c --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py @@ -0,0 +1,260 @@ +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_rules_button = None + dialog._inspect_error_full_text = "" + dialog._inspect_attached_rule_names = [] + for key, label in ( + ("name", QC.translate("stats", "Name")), + ("enabled", QC.translate("stats", "Enabled")), + ("state", QC.translate("stats", "State")), + ("rule_attached", QC.translate("stats", "Rule attached")), + ("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 in ("error", "rule_attached"): + 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) + else: + rules_button: QtWidgets.QPushButton = QtWidgets.QPushButton( + QC.translate("stats", "Rules"), field_widget + ) + rules_button.setVisible(False) + rules_button.clicked.connect( + dialog._rules_attachment_controller.show_attached_rules_dialog + ) + dialog._inspect_rules_button = rules_button + field_layout.addWidget(rules_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) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py new file mode 100644 index 0000000000..d1424af2f7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py @@ -0,0 +1,769 @@ +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_rules_button: QtWidgets.QPushButton | None + _inspect_error_full_text: str + _inspect_attached_rule_names: list[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._user_resized_columns_by_tab: dict[int, set[int]] = {} + self._applying_table_column_sizing = False + 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() + + def hideEvent(self, event: QtGui.QHideEvent | None): # type: ignore[override] + self._table_data_controller.stop_poll() + super().hideEvent(event) + + def closeEvent(self, event: QtGui.QCloseEvent | None): # type: ignore[override] + self._table_data_controller.stop_poll() + 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() + + + diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py new file mode 100644 index 0000000000..23ff83c585 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py @@ -0,0 +1,116 @@ +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) + display_lines = lines[:] + if not display_lines and (fallback_text or "").strip() != "": + display_lines = [fallback_text] + self._has_content = len(display_lines) > 0 + if not self._has_content: + return + + html_text = self._entries_html(display_lines, level_color, timestamp_color) + 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.text_view.setHtml(html_text) + + _wire_copy_close_buttons( + self, + self.copy_button, + self.close_button, + self.text_view, + ) + + @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(" ") + 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"[{timestamp_html}] " + ) + html_lines.append( + "" + f"{timestamp_prefix}" + f"[{level_html}] " + f"{message_html}" + "" + ) + + body = "
".join(html_lines) + return ( + "' + f"{body}" + "" + ) + + def exec(self) -> int: + if not self._has_content: + return int(QtWidgets.QDialog.DialogCode.Rejected) + return int(super().exec()) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py similarity index 62% rename from ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py rename to ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py index e5527f920a..8fcfbd9fdb 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/subscription_dialog.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py @@ -1,34 +1,14 @@ import logging import os -import sys from typing import Any, TYPE_CHECKING, Final -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 # noqa: F401 - from PyQt5.QtCore import QCoreApplication as QC - - load_ui_type = uic.loadUiType +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 ( @@ -39,68 +19,38 @@ INTERVAL_UNITS, TIMEOUT_UNITS, SIZE_UNITS, - deslugify_filename, - derive_filename, - ensure_filename_type_suffix, - is_valid_url, normalize_group, normalize_groups, - normalize_unit, - safe_filename, ) -from opensnitch.plugins.list_subscriptions.ui.helpers import ( +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.toggle_switch_widget import ( +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__) -class UrlTestWorker(QtCore.QThread): - finished = QtCore.pyqtSignal(bool, str) - - def __init__(self, url: str): - super().__init__() - self.url = url - - def run(self): - import requests - try: - response = requests.head(self.url, allow_redirects=True, timeout=5) - 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 response.status_code in (403, 405): - response = requests.get( - self.url, allow_redirects=True, timeout=5, stream=True - ) - if response.status_code >= 400: - raise requests.HTTPError(f"HTTP {response.status_code}") - final_url = response.url or final_url - response.close() - message = QC.translate("stats", "URL reachable.") - 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.finished.emit(True, f"{message} {final_url}") - return - self.finished.emit(True, message) - except requests.RequestException as exc: - self.finished.emit(False, str(exc)) - - class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): _url_test_finished = QtCore.pyqtSignal(bool, str) @@ -224,12 +174,13 @@ def __init__( ) raise self._meta = meta or {} + self._dialog_message_inspect_button: QtWidgets.QPushButton | None = None self._build_ui() + self.finished.connect(lambda _: self._subscription_dialog_controller.disconnect_signal()) def _build_ui(self): self.setupUi(self) self.enabled_check = _replace_checkbox_with_toggle(self.enabled_check) - self._set_dialog_message("", error=False) self.rootLayout.setContentsMargins(0, 0, 0, 0) self.rootLayout.setSpacing(0) self.bodyLayout.setContentsMargins(0, 0, 0, 0) @@ -262,6 +213,12 @@ def _build_ui(self): 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.error_label.setSizePolicy( QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed, @@ -271,6 +228,47 @@ def _build_ui(self): ) self.error_label.setWordWrap(False) self.error_label.setTextFormat(QtCore.Qt.TextFormat.PlainText) + self._dialog_message_inspect_button = QtWidgets.QPushButton( + QC.translate("stats", "Log"), + self, + ) + self._dialog_message_inspect_button.setVisible(False) + self._dialog_message_controller = DialogStatusController( + label=self.error_label, + inspect_button=self._dialog_message_inspect_button, + 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_inspect_button.clicked.connect( + self._subscription_dialog_controller.show_dialog_message_inspect_dialog + ) + self._dialog_message_controller.set_status("", error=False) + error_index = self.rootLayout.indexOf(self.error_label) + if error_index >= 0: + error_row = QtWidgets.QWidget(self) + error_row.setStyleSheet( + "QWidget {" + f"border-top: 1px solid {footer_border};" + f"background-color: {self.palette().color(QtGui.QPalette.ColorRole.Window).name()};" + "}" + ) + error_row_layout = QtWidgets.QHBoxLayout(error_row) + error_row_layout.setContentsMargins(12, 0, 12, 0) + error_row_layout.setSpacing(8) + self.rootLayout.removeWidget(self.error_label) + self.error_label.setParent(error_row) + error_row_layout.addWidget( + self._dialog_message_inspect_button, + 0, + QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + error_row_layout.addWidget(self.error_label, 1) + self.rootLayout.insertWidget(error_index, error_row) self.settings_form.setFieldGrowthPolicy( QtWidgets.QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow ) @@ -362,9 +360,9 @@ def _build_ui(self): "Optional explicit groups. Every subscription is always included in the global 'all' rules directory.", ) ) - self._url_test_finished.connect(self._handle_url_test_finished) - self.add_button.clicked.connect(self._validate_then_accept) - self.test_url_button.clicked.connect(self._test_url) + 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)) @@ -386,60 +384,51 @@ def _build_ui(self): ): self.group_combo.addItem(current_group_text) self.group_combo.setCurrentText(current_group_text) - self.interval_spin.setRange(0, 999999) - self.interval_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( + _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, - ) - ) - self.interval_spin.setValue(max(0, int(self._sub.interval or 0))) - self.interval_units.clear() - self.interval_units.addItems(INTERVAL_UNITS) - self.interval_units.setCurrentText( - normalize_unit( - str(self._sub.interval_units or self._defaults.interval_units), - INTERVAL_UNITS, - "hours", - ) + ), ) - self.timeout_spin.setRange(0, 999999) - self.timeout_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( + _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, - ) - ) - self.timeout_spin.setValue(max(0, int(self._sub.timeout or 0))) - self.timeout_units.clear() - self.timeout_units.addItems(TIMEOUT_UNITS) - self.timeout_units.setCurrentText( - normalize_unit( - str(self._sub.timeout_units or self._defaults.timeout_units), - TIMEOUT_UNITS, - "seconds", - ) + ), ) - self.max_size_spin.setRange(0, 999999) - self.max_size_spin.setSpecialValueText( - QC.translate("stats", "Use global default ({0} {1})").format( + _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.max_size_spin.setValue(max(0, int(self._sub.max_size or 0))) - self.max_size_units.clear() - self.max_size_units.addItems(SIZE_UNITS) - self.max_size_units.setCurrentText( - normalize_unit( - str(self._sub.max_size_units or self._defaults.max_size_units), - SIZE_UNITS, - "MB", - ) + ), ) - 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) + 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, @@ -449,10 +438,11 @@ def _build_ui(self): self.max_size_units, inherit_wording=True, ) - self._sync_optional_fields_state() + 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", ""))) @@ -463,112 +453,6 @@ def _build_ui(self): self.meta_group.setVisible(False) self.resize(920, 420) - def _sync_optional_fields_state(self): - self.interval_units.setEnabled(self.interval_spin.value() > 0) - self.timeout_units.setEnabled(self.timeout_spin.value() > 0) - self.max_size_units.setEnabled(self.max_size_spin.value() > 0) - - def _clear_field_errors(self): - self._set_dialog_message("", error=False) - self.name_error_label.setText("") - self.url_error_label.setText("") - self.filename_error_label.setText("") - - def _set_dialog_message(self, message: str, error: bool): - color = "red" if error else "#2e7d32" - self.error_label.setStyleSheet(f"color: {color};") - self.error_label.setText(message) - self.error_label.setToolTip(message) - - def _test_url(self): - self.url_error_label.setText("") - self._set_dialog_message("", error=False) - url = (self.url_edit.text() or "").strip() - if url == "": - self.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.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.test_url_button.setEnabled(False) - self._set_dialog_message(QC.translate("stats", "Testing URL..."), error=False) - - self._url_worker = UrlTestWorker(url) - self._url_worker.finished.connect(self._url_test_finished.emit) - self._url_worker.start() - - def _handle_url_test_finished(self, success: bool, message: str): - self.test_url_button.setEnabled(True) - if success: - self.url_error_label.setText("") - self._set_dialog_message(message, error=False) - return - self.url_error_label.setText(QC.translate("stats", "URL check failed.")) - self._set_dialog_message( - QC.translate("stats", "URL test failed. See details in the tooltip."), - error=True, - ) - self.error_label.setToolTip(message) - - def _validate_then_accept(self): - self._clear_field_errors() - raw_url = (self.url_edit.text() or "").strip() - raw_name = (self.name_edit.text() or "").strip() - raw_filename = (self.filename_edit.text() or "").strip() - list_type = (self.format_combo.currentText() or "hosts").strip().lower() - name = raw_name - filename = safe_filename(raw_filename) - has_error = False - - if raw_url == "": - self.url_error_label.setText(QC.translate("stats", "URL is required.")) - has_error = True - elif not is_valid_url(raw_url): - self.url_error_label.setText( - QC.translate("stats", "Enter a valid http:// or https:// URL.") - ) - has_error = True - - if raw_name == "" and raw_filename == "": - self.name_error_label.setText( - QC.translate("stats", "Provide a name or filename.") - ) - self.filename_error_label.setText( - QC.translate("stats", "Provide a filename or name.") - ) - has_error = True - elif raw_filename != "" and filename != raw_filename: - self.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.name_edit.setText(name) - self.filename_edit.setText(filename) - self.accept() - def subscription_spec(self): groups = normalize_groups((self.group_combo.currentText() or "").strip()) return MutableSubscriptionSpec( diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_status_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_status_dialog.py new file mode 100644 index 0000000000..4b48c9e38c --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_status_dialog.py @@ -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() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/text_inspect_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/text_inspect_dialog.py new file mode 100644 index 0000000000..77144097a9 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/text_inspect_dialog.py @@ -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()) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/widgets/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/widgets/helpers.py b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/helpers.py new file mode 100644 index 0000000000..28cf441344 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/helpers.py @@ -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 + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/widgets/table_widgets.py b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/table_widgets.py new file mode 100644 index 0000000000..eb4e3c0683 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/table_widgets.py @@ -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) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/toggle_switch_widget.py b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/toggle_switch_widget.py similarity index 88% rename from ui/opensnitch/plugins/list_subscriptions/ui/toggle_switch_widget.py rename to ui/opensnitch/plugins/list_subscriptions/ui/widgets/toggle_switch_widget.py index d4f1f919e5..dd546112cb 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/toggle_switch_widget.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/widgets/toggle_switch_widget.py @@ -1,32 +1,9 @@ -import sys -from typing import TYPE_CHECKING - -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 +from opensnitch.plugins.list_subscriptions.ui import ( + QtCore, + QtGui, + QtWidgets, + QC, +) class ToggleSwitch(QtWidgets.QCheckBox): diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/subscription_workers.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/subscription_workers.py new file mode 100644 index 0000000000..bc69e6c618 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/workers/subscription_workers.py @@ -0,0 +1,37 @@ +from opensnitch.plugins.list_subscriptions.ui import QtCore, QC + + +class UrlTestWorker(QtCore.QThread): + finished = QtCore.pyqtSignal(bool, str) + + def __init__(self, url: str): + super().__init__() + self.url = url + + def run(self): + import requests + + try: + response = requests.head(self.url, allow_redirects=True, timeout=5) + 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 response.status_code in (403, 405): + response = requests.get( + self.url, allow_redirects=True, timeout=5, stream=True + ) + if response.status_code >= 400: + raise requests.HTTPError(f"HTTP {response.status_code}") + final_url = response.url or final_url + response.close() + message = QC.translate("stats", "URL reachable.") + 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.finished.emit(True, f"{message} {final_url}") + return + self.finished.emit(True, message) + except requests.RequestException as exc: + self.finished.emit(False, str(exc)) \ No newline at end of file From 8a3be740b26fd61bbdf0a0bc9e5ea3c378e7dcef Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Fri, 13 Mar 2026 21:34:03 +0100 Subject: [PATCH 13/13] QObject Thread safeguards for DB rules enumeration and matching against existing attached list subs --- .../list_subscriptions/list_subscriptions.py | 172 ++++- .../ui/controllers/attached_rules_index.py | 118 ++++ .../ui/controllers/inspector_controller.py | 31 - .../rules_attachment_controller.py | 642 +++++++++++++++++- .../ui/controllers/rules_editor_controller.py | 18 + .../ui/controllers/runtime_controller.py | 15 +- .../ui/controllers/status_controller.py | 187 ++++- .../subscription_dialog_controller.py | 143 +++- .../subscription_edit_controller.py | 2 + .../ui/controllers/table_data_controller.py | 362 +++++++--- .../ui/controllers/table_view_controller.py | 45 +- .../ui/views/inspector_panel.py | 15 +- .../ui/views/list_subscriptions_dialog.py | 87 ++- .../ui/views/status_log_dialog.py | 64 +- .../ui/views/subscription_dialog.py | 98 ++- .../list_subscriptions/ui/workers/__init__.py | 17 + .../workers/attached_rules_snapshot_worker.py | 308 +++++++++ .../ui/workers/state_refresh_worker.py | 177 +++++ .../ui/workers/subscription_workers.py | 37 - .../ui/workers/url_test_worker.py | 108 +++ 20 files changed, 2351 insertions(+), 295 deletions(-) create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/controllers/attached_rules_index.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/workers/attached_rules_snapshot_worker.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/workers/state_refresh_worker.py delete mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/workers/subscription_workers.py create mode 100644 ui/opensnitch/plugins/list_subscriptions/ui/workers/url_test_worker.py diff --git a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py index fa7f0d9aa4..f9fcf01be9 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -77,6 +77,52 @@ # -------------------- plugin core -------------------- +class _LogSignalWrapper(QtCore.QObject): + """Thin QObject container for the (message, level) log signal. + Follows the same pattern as PluginSignal so the runtime can emit + structured log entries directly to the UI status controller. + """ + signal = QtCore.pyqtSignal(str, str, str) + + def emit(self, message: str, level: str = "INFO", origin: str = "backend") -> None: + self.signal.emit(message, level, origin) + + def connect(self, callback: Any) -> None: + self.signal.connect(callback) + + def disconnect(self, callback: Any) -> None: # pyright: ignore[reportIncompatibleMethodOverride] + self.signal.disconnect(callback) + + +class _UiLogBridgeHandler(logging.Handler): + """Relays backend logger records into the UI live log signal.""" + + def __init__(self, sink: _LogSignalWrapper): + super().__init__(level=logging.DEBUG) + self._sink = sink + + @staticmethod + def _level_name(record: logging.LogRecord) -> str: + if record.levelno >= logging.ERROR: + return "ERROR" + if record.levelno >= logging.WARNING: + return "WARN" + if record.levelno >= logging.INFO: + return "INFO" + return "DEBUG" + + def emit(self, record: logging.LogRecord) -> None: + try: + if bool(getattr(record, "_skip_ui_bridge", False)): + return + message = record.getMessage().strip() + if message == "": + return + self._sink.emit(message, self._level_name(record), "backend") + except Exception: + pass + + class SingletonABCMeta(ABCMeta): _instances: dict[type, object] = {} _lock = threading.Lock() @@ -112,6 +158,11 @@ class ListSubscriptions(PluginBase, metaclass=SingletonABCMeta): # default TYPE: ClassVar[list[Any]] = [PluginBase.TYPE_GLOBAL] + # UI log signal — connect to DialogStatusController.log to forward + # runtime messages to the main window's live log facility. + log_out: ClassVar[_LogSignalWrapper] = _LogSignalWrapper() + _ui_log_bridge_handler_installed: ClassVar[bool] = False + # runtime state scheduled_tasks: dict[str, GenericTimer] = {} default_conf: ClassVar[str] = ACTION_FILE @@ -132,6 +183,7 @@ def __init__(self, config: dict[str, Any] | None = None): return self._initialized = True + self._ensure_ui_log_bridge_handler() self.signal_in.connect(self.cb_signal) self._desktop_notifications = DesktopNotifications() self._db = Database.instance() @@ -163,6 +215,67 @@ def __init__(self, config: dict[str, Any] | None = None): else: self._session.headers.update({"User-Agent": DEFAULT_UA}) + @classmethod + def _ensure_ui_log_bridge_handler(cls) -> None: + if cls._ui_log_bridge_handler_installed: + return + logger.addHandler(_UiLogBridgeHandler(cls.log_out)) + cls._ui_log_bridge_handler_installed = True + + @staticmethod + def _backend_level(level: str) -> int: + normalized = (level or "INFO").strip().upper() + if normalized in ("TRACE", "DEBUG"): + return logging.DEBUG + if normalized in ("WARN", "WARNING"): + return logging.WARNING + if normalized == "ERROR": + return logging.ERROR + if normalized == "CRITICAL": + return logging.CRITICAL + return logging.INFO + + def _log_backend( + self, + message: str, + level: str = "INFO", + *, + suppress_ui_bridge: bool = False, + ) -> None: + full_text = (message or "").strip() + if full_text == "": + return + logger.log( + self._backend_level(level), + full_text, + extra={"_skip_ui_bridge": bool(suppress_ui_bridge)}, + ) + + def ingest_ui_log( + self, + message: str, + level: str = "INFO", + origin: str = "ui", + ) -> None: + """UI -> backend bridge: write UI logs to the backend true logger.""" + self._log_backend( + f"[{origin}] {message}", + level, + suppress_ui_bridge=True, + ) + + def log_debug(self, message: str) -> None: + self._log_backend(message, "DEBUG") + + def log_info(self, message: str) -> None: + self._log_backend(message, "INFO") + + def log_warn(self, message: str) -> None: + self._log_backend(message, "WARN") + + def log_error(self, message: str) -> None: + self._log_backend(message, "ERROR") + def _emit_runtime_event( self, event: RuntimeEventType, @@ -275,7 +388,7 @@ def _start_runtime(self, *, recheck: bool): else: with self._startup_recheck_lock: self._startup_recheck_pending = True - logger.warning( + logger.info( "deferring startup refresh until a local node is connected" ) @@ -317,7 +430,7 @@ def _on_nodes_updated(self, total: int): with self._startup_recheck_lock: pending = self._startup_recheck_pending if pending and self._has_ready_local_node(): - logger.warning("local node connected, running deferred startup refresh") + logger.info("local node connected, running deferred startup refresh") self._schedule_startup_recheck(delay=0.5) def _reload_from_action_file(self, action_path: str | None = None): @@ -614,14 +727,14 @@ def _reload_rules_for_updated_subscription(self, sub: SubscriptionSpec): ) self._nodes.send_notification(addr, notification, None) found_match = True - logger.warning( + logger.info( "signaling affected rule '%s' for updated subscription '%s'", rule.name, sub.name, ) break if found_match is False: - logger.warning( + logger.info( "no matching rules found for updated subscription '%s'", sub.name, ) @@ -866,7 +979,7 @@ def _startup_recheck_all(self): if not self._has_ready_local_node(): with self._startup_recheck_lock: self._startup_recheck_pending = True - logger.warning("startup refresh skipped, no local node is ready yet") + logger.info("startup refresh skipped, no local node is ready yet") return for sub in self._config.subscriptions: if not sub.enabled: @@ -874,7 +987,7 @@ def _startup_recheck_all(self): try: self.refresh_subscriptions(sub, source="startup_recheck") except Exception as e: - logger.warning( + logger.error( "startup recheck error name='%s' err=%s", sub.name, repr(e), @@ -909,10 +1022,10 @@ def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): meta = self._load_meta(meta_path) if self._in_backoff(meta): - logger.warning("skip '%s' (in backoff)", sub.name) + logger.info("skip '%s' (in backoff)", sub.name) return if not self._is_due(meta, sub): - logger.warning("skip '%s' (not due yet)", sub.name) + logger.info("skip '%s' (not due yet)", sub.name) return th = threading.Thread(target=self.download, args=(sub,)) @@ -931,7 +1044,7 @@ def cb_run_tasks(self, args: tuple[str, SubscriptionSpec]): self._resultsQueue.put(item) if not matched: - logger.debug("cb_run_tasks() no result for key=%s sub=%s", key, sub.name) + logger.debug("cb_run_tasks: no result for key=%s sub=%s", key, sub.name) return updated: bool = False @@ -974,7 +1087,7 @@ def cb_signal(self, signal: dict[str, Any]): action_path = signal.get("action_path") if sig == PluginSignal.ENABLE: - logger.warning( + logger.debug( "cb_signal: ENABLE action_path=%r", action_path, ) @@ -987,6 +1100,7 @@ def cb_signal(self, signal: dict[str, Any]): "Plugin runtime enabled.", action_path=action_path, ) + logger.info("plugin runtime enabled") else: self._emit_runtime_event( RuntimeEventType.RUNTIME_ERROR, @@ -994,6 +1108,8 @@ def cb_signal(self, signal: dict[str, Any]): error=err, action_path=action_path, ) + logger.error("Failed to enable plugin runtime: %s", repr(err)) + return if sig == self.REFRESH_SUBSCRIPTIONS_SIGNAL: @@ -1029,7 +1145,7 @@ def cb_signal(self, signal: dict[str, Any]): return if sig == PluginSignal.CONFIG_UPDATE: - logger.warning( + logger.debug( "cb_signal: CONFIG_UPDATE action_path=%r", action_path, ) @@ -1049,6 +1165,7 @@ def cb_signal(self, signal: dict[str, Any]): "Plugin runtime configuration reloaded.", action_path=action_path, ) + logger.info("plugin runtime configuration reloaded") else: self._emit_runtime_event( RuntimeEventType.RUNTIME_ERROR, @@ -1056,10 +1173,11 @@ def cb_signal(self, signal: dict[str, Any]): error=err, action_path=action_path, ) + logger.error("Failed to reload plugin runtime configuration: %s", repr(err)) return if sig == PluginSignal.DISABLE or sig == PluginSignal.STOP: - logger.warning( + logger.debug( "cb_signal: %s action_path=%r", "DISABLE" if sig == PluginSignal.DISABLE else "STOP", action_path, @@ -1079,6 +1197,10 @@ def cb_signal(self, signal: dict[str, Any]): ), action_path=action_path, ) + logger.info( + "plugin runtime %s", + "disabled" if sig == PluginSignal.DISABLE else "stopped", + ) return if sig == PluginSignal.ERROR: @@ -1093,7 +1215,7 @@ def cb_signal(self, signal: dict[str, Any]): raise ValueError(f"unrecognized signal: {sig}") except Exception as e: - logger.warning("cb_signal() exception: %s", repr(e)) + logger.error("cb_signal: exception: %s", repr(e)) def _in_backoff(self, meta: ListMetadata): if not meta.backoff_until: @@ -1260,7 +1382,7 @@ def _download_one( ], ) self._resultsQueue.put((key, True, "not_modified")) - logger.warning("subscription not-modified name='%s'", sub.name) + logger.info("subscription not-modified name='%s'", sub.name) return True if r.status_code != 200: @@ -1288,7 +1410,7 @@ def _download_one( ], ) self._resultsQueue.put((key, False, f"http_{r.status_code}")) - logger.warning( + logger.error( "subscription download http-error name='%s' code=%s", sub.name, r.status_code, @@ -1323,7 +1445,7 @@ def _download_one( ], ) self._resultsQueue.put((key, False, "too_large")) - logger.warning( + logger.error( "subscription download too-large name='%s' len=%s", sub.name, cl, @@ -1391,7 +1513,7 @@ def _download_one( ], ) self._resultsQueue.put((key, False, "bad_format")) - logger.warning( + logger.error( "subscription file bad-format name='%s'", sub.name, ) @@ -1458,7 +1580,7 @@ def _download_one( ], ) self._resultsQueue.put((key, False, "write_error")) - logger.warning( + logger.error( "subscription file write-error name='%s' err=%s", sub.name, repr(e), @@ -1499,7 +1621,7 @@ def _download_one( ) ], ) - logger.warning( + logger.info( "subscription updated name='%s' bytes=%s", sub.name, downloaded, @@ -1537,7 +1659,7 @@ def _download_one( ], ) self._resultsQueue.put((key, False, "unexpected_error")) - logger.warning( + logger.error( "subscription download unexpected-error name='%s' err=%s", sub.name, repr(e), @@ -1602,7 +1724,7 @@ def download( had_errors = True except Exception as exc: had_errors = True - logger.warning( + logger.error( "batch download failed for '%s': %r", sub.name, exc, @@ -1639,4 +1761,12 @@ def download( state="batch_failed" if had_errors else "batch_finished", items=items, ) + logger.error( + "batch subscription refresh failed for %d/%d items", + sum(1 for i in items if i.get('state') != "updated"), + len(items), + ) if had_errors else logger.info( + "batch subscription refresh finished for %d items", + len(items), + ) return not had_errors diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/attached_rules_index.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/attached_rules_index.py new file mode 100644 index 0000000000..81f6039724 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/attached_rules_index.py @@ -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), + } + ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py index d1db7a2c0f..c341dc5135 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/inspector_controller.py @@ -175,7 +175,6 @@ def set_inspector_values( part for part in (max_size_value, max_size_units) if (part or "").strip() != "" ), "state": meta.get("state", ""), - "rule_attached": meta.get("rule_attached_detail", meta.get("rule_attached", "")), "last_checked": meta.get("last_checked", ""), "last_updated": meta.get("last_updated", ""), "failures": meta.get("failures", ""), @@ -197,9 +196,6 @@ def set_inspector_values( f"color: {self.state_bucket_color(text).name()};" ) continue - if key == "rule_attached": - self.set_rule_attached_preview(row, text) - continue label.setText(text) def state_bucket_color(self, state: str): @@ -239,33 +235,6 @@ def set_error_preview(self, text: str): self._dialog._inspect_error_button.setVisible(True) self._dialog._inspect_error_button.setEnabled(True) - def set_rule_attached_preview(self, row: int, text: str): - attached_label = self._dialog._inspect_value_labels.get("rule_attached") - if attached_label is None: - return - - normalized = (text or "").strip() - if normalized == "": - normalized = "-" - attached_label.setText(normalized) - attached_label.setToolTip(normalized) - - attached_rules = self._dialog._table_data_controller.attached_rules_for_row( - row, - include_disabled=True, - ) - self._dialog._inspect_attached_rule_names = [ - str(entry.get("name", "")).strip() for entry in attached_rules - ] - self._dialog._inspect_attached_rule_names = [ - name for name in self._dialog._inspect_attached_rule_names if name != "" - ] - - if self._dialog._inspect_rules_button is None: - return - self._dialog._inspect_rules_button.setVisible(len(attached_rules) > 0) - self._dialog._inspect_rules_button.setEnabled(len(attached_rules) > 0) - def set_inspector_multi_selection_mode(self, enabled: bool): if enabled: self._dialog._inspect_details_widget.setVisible(False) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py index 758dcbf491..473592731c 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_attachment_controller.py @@ -1,12 +1,19 @@ import os +import time +from collections.abc import Callable from typing import TYPE_CHECKING, Any, cast -from opensnitch.plugins.list_subscriptions.ui import QtWidgets, QC +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 +from opensnitch.rules import Rule, Rules from opensnitch.proto import ui_pb2 as ui_pb2 if TYPE_CHECKING: @@ -15,11 +22,160 @@ ) +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(): @@ -76,6 +232,402 @@ def attached_rules_snapshot(self): 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: @@ -86,20 +638,79 @@ def show_attached_rules_dialog(self): return row = rows[0] - dlg = AttachedRulesDialog( - self._dialog, - get_attached_rules=lambda: self.aggregate_attached_rules( - self._dialog._table_data_controller.attached_rules_for_row( - row, - include_disabled=True, + + 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, + ) ) - ), - 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 = 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, ) - dlg.exec() + 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() @@ -237,6 +848,7 @@ def toggle_attached_rule_entry(self, entry: dict[str, Any]): 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, @@ -252,6 +864,7 @@ def toggle_attached_rule_entry(self, entry: dict[str, Any]): ), 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, @@ -286,6 +899,7 @@ def remove_attached_rule_entry(self, entry: dict[str, Any]): ) 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), diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py index 0cc21d7d62..a51d0bc1b4 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/rules_editor_controller.py @@ -98,10 +98,21 @@ 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): @@ -137,6 +148,13 @@ def _finalize_pending_rule_change(self): 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() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py index 66483d6638..0fd11013e2 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/runtime_controller.py @@ -192,7 +192,11 @@ def handle_runtime_event(self, event: dict[str, Any]): 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) + self._dialog._status_controller.set_status( + message, + error=is_error, + origin="backend:event", + ) # -- Lifecycle ---------------------------------------------------------- @@ -398,21 +402,30 @@ def sync_runtime_binding_state(self): 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): diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py index 57ea00f032..b59850947c 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/status_controller.py @@ -1,9 +1,17 @@ from collections.abc import Callable -from typing import Literal +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( @@ -34,6 +42,7 @@ def append_log_entry( 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", @@ -49,7 +58,8 @@ def append_log_entry( timestamp = QtCore.QDateTime.currentDateTime().toString(timestamp_format) log_level = level or ("ERROR" if error else "INFO") - entries.append(f"[{timestamp}] [{log_level}] {full_text}") + log_origin = (origin or "ui").strip() + entries.append(f"[{timestamp}] [{log_level}] [{log_origin}] {full_text}") if len(entries) > limit: del entries[:-limit] @@ -116,6 +126,57 @@ def __init__( 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): @@ -125,26 +186,102 @@ def full_text(self): 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=message, + message=full_text, error=error, - level=level, + 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 set_status(self, message: str, *, error: bool = False): + 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, @@ -160,9 +297,12 @@ def set_status(self, message: str, *, error: bool = False): 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) + self.append_log(full_text, error=error, origin=origin) self._last_signature = signature def show_log_dialog( @@ -176,12 +316,29 @@ def show_log_dialog( from opensnitch.plugins.list_subscriptions.ui.views.status_log_dialog import ( StatusLogDialog, ) - dlg = StatusLogDialog( - parent, - title=title, - lines=self._log_entries[:], - fallback_text=self._full_text, - level_color=level_color, - timestamp_color=timestamp_color, - ) - dlg.exec() + 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() diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py index ec64000525..a59933711a 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_dialog_controller.py @@ -1,6 +1,7 @@ +from collections.abc import Callable from typing import TYPE_CHECKING, Any -from opensnitch.plugins.list_subscriptions.ui import QtGui, QC +from opensnitch.plugins.list_subscriptions.ui import QtCore, QtGui, QC from opensnitch.plugins.list_subscriptions._utils import ( deslugify_filename, derive_filename, @@ -8,12 +9,7 @@ is_valid_url, safe_filename, ) -from opensnitch.plugins.list_subscriptions.ui.views.text_inspect_dialog import ( - TextInspectDialog, -) -from opensnitch.plugins.list_subscriptions.ui.workers.subscription_workers import ( - UrlTestWorker, -) +from opensnitch.plugins.list_subscriptions.ui.workers import UrlTestWorker if TYPE_CHECKING: from opensnitch.plugins.list_subscriptions.ui.views.subscription_dialog import ( @@ -25,6 +21,89 @@ 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 ------------------------------------------------------- @@ -74,6 +153,7 @@ def apply_meta_state_color(self, state: str) -> None: 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) @@ -96,21 +176,18 @@ def clear_field_errors(self) -> None: def set_dialog_message(self, message: str, error: bool) -> None: self._dialog._dialog_message_controller.set_status(message, error=error) - - def show_dialog_message_inspect_dialog(self) -> None: - text = "\n".join(self._dialog._dialog_message_controller.log_entries).strip() - if text == "": - text = (self._dialog._dialog_message_controller.full_text or "").strip() - dlg = TextInspectDialog( - self._dialog, - title=QC.translate("stats", "Status log"), - text=text, - ) - dlg.exec() + 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() @@ -130,11 +207,35 @@ def test_url(self) -> None: return self._dialog.test_url_button.setEnabled(False) self.set_dialog_message(QC.translate("stats", "Testing URL..."), error=False) - self._url_worker = UrlTestWorker(url) - self._url_worker.finished.connect(self._dialog._url_test_finished.emit) - self._url_worker.start() + 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("") diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py index 6852d1170f..8850b012dc 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/subscription_edit_controller.py @@ -47,6 +47,7 @@ def add_subscription_row(self): ), title="New subscription", ) + dlg.log_message.connect(self._dialog._status_controller.log) if dlg.exec() != QtWidgets.QDialog.DialogCode.Accepted: return @@ -134,6 +135,7 @@ def edit_selected_subscription(self): 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 ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py index 9381c5fe48..191ab8879e 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_data_controller.py @@ -1,5 +1,6 @@ import json import os +from collections.abc import Callable from typing import TYPE_CHECKING, Any import requests @@ -15,6 +16,9 @@ from opensnitch.plugins.list_subscriptions.ui.widgets.table_widgets import ( SortableTableWidgetItem, ) +from opensnitch.plugins.list_subscriptions.ui.workers import ( + SubscriptionStateRefreshWorker, +) from opensnitch.plugins.list_subscriptions._utils import ( DEFAULT_LISTS_DIR, INTERVAL_UNITS, @@ -39,6 +43,9 @@ ) +ATTACHED_RULES_REFRESH_INTERVAL_MS = 2_000 + + class TableDataController: def __init__( self, *, dialog: "ListSubscriptionsDialog", columns: dict[str, int] @@ -48,10 +55,27 @@ def __init__( self._poll_timer = QtCore.QTimer(dialog) self._poll_timer.setInterval(2000) self._poll_timer.timeout.connect( - lambda: dialog.isVisible() and (not dialog._loading) and self.refresh_states() + lambda: dialog.isVisible() + and dialog.isActiveWindow() + and (not dialog._loading) + and self.refresh_attached_rules_only() ) + self._refresh_generation = 0 + self._refresh_worker: SubscriptionStateRefreshWorker | None = None + self._refresh_thread: QtCore.QThread | None = None + self._refresh_stopped_callbacks: list[Callable[[], None]] = [] + self._pending_refresh_job: dict[str, Any] | None = None + self._pending_attached_rules_refresh = False + self._last_attached_rules_refresh_ms = 0 + self._shutting_down = False + dialog.destroyed.connect(self._on_dialog_destroyed) + app = QtCore.QCoreApplication.instance() + if app is not None: + app.aboutToQuit.connect(self._on_app_about_to_quit) def start_poll(self): + if self._shutting_down: + return if not self._poll_timer.isActive(): self._poll_timer.start() @@ -59,6 +83,96 @@ def stop_poll(self): if self._poll_timer.isActive(): self._poll_timer.stop() + def pause_for_focus_loss(self): + self.stop_poll() + self.cancel_active_refresh() + + def resume_for_focus_gain(self): + if self._dialog.isVisible() and not self._dialog._loading: + self.start_poll() + + def _on_dialog_destroyed(self, *_args): + self.shutdown_refresh_worker(wait_ms=3000) + + def _on_app_about_to_quit(self): + self.shutdown_refresh_worker(wait_ms=3000) + + def has_active_refresh(self) -> bool: + thread = self._refresh_thread + return bool(thread is not None and thread.isRunning()) + + def on_refresh_stopped(self, callback: Callable[[], None]) -> None: + if not self.has_active_refresh(): + callback() + return + self._refresh_stopped_callbacks.append(callback) + + def cancel_active_refresh(self): + worker = self._refresh_worker + thread = self._refresh_thread + if worker is None or thread is None or not thread.isRunning(): + return + worker.stop() + thread.quit() + + def wait_for_active_refresh_stop(self, wait_ms: int = 1200) -> bool: + worker = self._refresh_worker + thread = self._refresh_thread + if worker is None or thread is None: + return True + if not thread.isRunning(): + self._refresh_worker = None + self._refresh_thread = None + return True + + worker.stop() + thread.quit() + if wait_ms <= 0: + stopped = thread.wait() + else: + stopped = thread.wait(wait_ms) + if stopped: + self._refresh_worker = None + self._refresh_thread = None + return bool(stopped) + + def shutdown_refresh_worker(self, wait_ms: int = 2000) -> bool: + self._shutting_down = True + self._pending_refresh_job = None + self._refresh_generation += 1 + + worker = self._refresh_worker + thread = self._refresh_thread + if worker is None or thread is None: + self._refresh_worker = None + self._refresh_thread = None + return True + + try: + worker.refresh_done.disconnect(self._on_state_refresh_worker_finished) + 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._refresh_worker = worker + self._refresh_thread = thread + return False + + self._refresh_worker = None + self._refresh_thread = None + return True + # -- Shared primitives ------------------------------------------------- def _col(self, key: str): return self._cols[key] @@ -146,11 +260,19 @@ def state_text_color(self, state: str): def status_log_level_color(self, level: str) -> str: normalized = (level or "").strip().upper() + if normalized in ("TRACE",): + return self.state_text_color("disabled").name() + if normalized in ("DEBUG",): + return self.state_text_color("other").name() + if normalized in ("INFO",): + return self.state_text_color("not_modified").name() + if normalized in ("SUCCESS",): + return self.state_text_color("updated").name() if normalized == "ERROR": return self.state_text_color("error").name() - if normalized == "WARN": + if normalized in ("WARN", "WARNING"): return self.state_text_color("pending").name() - return self.state_text_color("updated").name() + return self.state_text_color("not_modified").name() # -- Table interaction ------------------------------------------------- def handle_table_clicked(self, index: QtCore.QModelIndex): @@ -603,21 +725,15 @@ def row_meta_snapshot(self, row: int): lists_dir = normalize_lists_dir( self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR ) - attached_rules_by_dir = ( - self._dialog._rules_attachment_controller.attached_rules_snapshot() - ) filename = safe_filename(self.cell_text(row, self._col("filename"))) list_type = ( self.cell_text(row, self._col("format")) or "hosts" ).strip().lower() - groups = normalize_groups(self.cell_text(row, self._col("group"))) - attachment_matches = self.rule_attachment_matches( - lists_dir, - filename, - list_type, - groups, - attached_rules_by_dir=attached_rules_by_dir, - ) + current_rule_attached = ( + self.cell_text(row, self._col("rule_attached")) or "no" + ).strip().lower() + if current_rule_attached not in ("yes", "no"): + current_rule_attached = "no" list_path = list_file_path(lists_dir, filename, list_type) meta_path = list_path + ".meta.json" @@ -640,8 +756,8 @@ def row_meta_snapshot(self, row: int): self.cell_text(row, self._col("state")) or "never", ) ), - "rule_attached": "yes" if attachment_matches else "no", - "rule_attached_detail": self.rule_attachment_detail(attachment_matches), + "rule_attached": current_rule_attached, + "rule_attached_detail": current_rule_attached, "last_checked": str( meta.get( "last_checked", @@ -713,90 +829,139 @@ def apply_url_error_indicator( url_item.setForeground(QtGui.QBrush(self.state_text_color("other"))) def refresh_states(self): - with self._dialog._table_view_controller.sorting_suspended(): - lists_dir = normalize_lists_dir( - self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR - ) - attached_rules_by_dir = ( - self._dialog._rules_attachment_controller.attached_rules_snapshot() + if self._shutting_down: + return + if self._refresh_thread is not None and self._refresh_thread.isRunning(): + # Worker already running: defer the next refresh request without + # recomputing expensive snapshots on the UI thread. + self._pending_refresh_job = {"deferred": True} + return + lists_dir = normalize_lists_dir( + self._dialog.lists_dir_edit.text().strip() or DEFAULT_LISTS_DIR + ) + rows: list[dict[str, Any]] = [] + for row in range(self._dialog.table.rowCount()): + filename_item = self._dialog.table.item(row, self._col("filename")) + enabled_item = self._dialog.table.item(row, self._col("enabled")) + if filename_item is None or enabled_item is None: + continue + rows.append( + { + "row": row, + "url": self.cell_text(row, self._col("url")), + "filename": safe_filename(filename_item.text()), + "list_type": (self.cell_text(row, self._col("format")) or "hosts") + .strip() + .lower(), + "enabled": enabled_item.checkState() + == QtCore.Qt.CheckState.Checked, + "groups": normalize_groups(self.cell_text(row, self._col("group"))), + } ) + + self._refresh_generation += 1 + job = { + "generation": self._refresh_generation, + "lists_dir": lists_dir, + "rows": rows, + # Attached-rules snapshot is intentionally omitted in background + # refresh jobs to avoid automatic DB scans on the UI path. + "attached_rules_by_dir": {}, + } + + self._start_state_refresh_worker(job) + + def refresh_attached_rules_only(self): + # Attached-rules DB scan is on-demand only (explicit user action) + # to avoid blocking UI/daemon during background polling. + return + + def _start_state_refresh_worker(self, job: dict[str, Any]): + if self._shutting_down: + return + self._pending_refresh_job = None + thread = QtCore.QThread(self._dialog) + thread.setObjectName("SubscriptionStateRefreshWorkerThread") + worker = SubscriptionStateRefreshWorker( + generation=int(job["generation"]), + lists_dir=str(job["lists_dir"]), + rows=list(job["rows"]), + attached_rules_by_dir=dict(job["attached_rules_by_dir"]), + ) + worker.setObjectName("SubscriptionStateRefreshWorker") + worker.moveToThread(thread) + self._refresh_worker = worker + self._refresh_thread = thread + thread.started.connect(worker.run) + worker.refresh_done.connect(self._on_state_refresh_worker_finished) + worker.finished.connect(thread.quit) + worker.finished.connect(worker.deleteLater) + thread.finished.connect(self._on_state_refresh_worker_stopped) + thread.finished.connect(thread.deleteLater) + thread.start() + + def _on_state_refresh_worker_stopped(self) -> None: + self._refresh_worker = None + self._refresh_thread = None + callbacks = self._refresh_stopped_callbacks[:] + self._refresh_stopped_callbacks.clear() + for callback in callbacks: + callback() + if not self._shutting_down and self._pending_refresh_job is not None: + job = self._pending_refresh_job + self._pending_refresh_job = None + if bool(job.get("deferred")): + self.refresh_states() + return + self._start_state_refresh_worker(job) + return + if not self._shutting_down and self._pending_attached_rules_refresh: + self._pending_attached_rules_refresh = False + self.refresh_attached_rules_only() + + def _on_state_refresh_worker_finished( + self, + generation: int, + results: list[dict[str, Any]], + ): + if self._shutting_down: + return + + if generation != self._refresh_generation: + return + + result_by_row: dict[int, dict[str, Any]] = { + int(item.get("row", -1)): item for item in results + } + + with self._dialog._table_view_controller.sorting_suspended(): for row in range(self._dialog.table.rowCount()): - filename_item = self._dialog.table.item(row, self._col("filename")) - enabled_item = self._dialog.table.item(row, self._col("enabled")) - if filename_item is None or enabled_item is None: + result = result_by_row.get(row) + if result is None: continue - url = self.cell_text(row, self._col("url")) - filename = safe_filename(filename_item.text()) - list_type = (self.cell_text(row, self._col("format")) or "hosts").strip().lower() - enabled = enabled_item.checkState() == QtCore.Qt.CheckState.Checked - list_path = list_file_path(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 = {} - 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 "" - groups = normalize_groups(self.cell_text(row, self._col("group"))) - attachment_matches = self.rule_attachment_matches( - lists_dir, - filename, - list_type, - groups, - attached_rules_by_dir=attached_rules_by_dir, - ) - rule_attached = "yes" if attachment_matches else "no" + enabled = bool(result.get("enabled", True)) + state = str(result.get("state", "")) + last_error = str(result.get("error", "")) + rule_attached = str(result.get("rule_attached", "no")) + last_checked = str(result.get("last_checked", "")) + last_updated = str(result.get("last_updated", "")) + attachment_matches = list(result.get("attachment_matches", [])) rule_attached_detail = self.rule_attachment_detail(attachment_matches) - fg_color: QtGui.QColor - - if not enabled: - state = "disabled" - fg_color = self.state_text_color("disabled") - elif not file_exists: - # New/manual subscriptions may not be downloaded yet. - # Expose that as pending instead of an error-like missing state. - if not meta_exists or last_result in ("never", "", "busy"): - state = "pending" - fg_color = self.state_text_color("pending") - else: - state = "missing" - fg_color = self.state_text_color("missing") - elif last_result in ("updated", "not_modified"): - state = last_result - fg_color = self.state_text_color(last_result) - elif last_result in ( - "error", - "write_error", - "request_error", - "unexpected_error", - "bad_format", - "too_large", - ): - state = last_result - fg_color = self.state_text_color(last_result) - elif last_result == "busy": - state = "busy" - fg_color = self.state_text_color("busy") - else: - state = last_result - fg_color = self.state_text_color("other") + + fg_color = self.state_text_color(state if state != "" else "other") self.set_text_item( - row, self._col("file"), "yes" if file_exists else "no", editable=False + row, + self._col("file"), + str(result.get("file_present", "no")), + editable=False, ) self.set_text_item( - row, self._col("meta"), "yes" if meta_exists else "no", editable=False + row, + self._col("meta"), + str(result.get("meta_present", "no")), + editable=False, ) self.set_text_item(row, self._col("state"), state, editable=False) self.set_text_item( @@ -835,22 +1000,23 @@ def refresh_states(self): item = self._dialog.table.item(row, col) if item is not None: item.setForeground(fg_color) + self.update_row_sort_keys(row) self._dialog.subscription_state_refreshed.emit( - url, - filename, + str(result.get("url", "")), + str(result.get("filename", "")), { - "file_present": "yes" if file_exists else "no", - "meta_present": "yes" if meta_exists else "no", + "file_present": str(result.get("file_present", "no")), + "meta_present": str(result.get("meta_present", "no")), "state": state, "rule_attached": rule_attached, "rule_attached_detail": rule_attached_detail, "last_checked": last_checked, "last_updated": last_updated, - "failures": fail_count, + "failures": str(result.get("failures", "0")), "error": last_error, - "list_path": list_path, - "meta_path": meta_path, + "list_path": str(result.get("list_path", "")), + "meta_path": str(result.get("meta_path", "")), }, ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py index 63f190e74a..aa94a73251 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/controllers/table_view_controller.py @@ -19,6 +19,36 @@ def __init__( 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() @@ -49,10 +79,10 @@ def on_table_view_tab_changed(self, index: int): self._col("max_size_units"), self._col("file"), self._col("meta"), + self._col("rule_attached"), } monitoring_only = { self._col("state"), - self._col("rule_attached"), self._col("last_checked"), self._col("last_updated"), } @@ -89,15 +119,13 @@ def apply_table_column_sizing(self, index: int | None = None): 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("rule_attached"), QtWidgets.QHeaderView.ResizeMode.Interactive - ) header.setSectionResizeMode( self._col("last_checked"), QtWidgets.QHeaderView.ResizeMode.Interactive ) @@ -108,12 +136,13 @@ def apply_table_column_sizing(self, index: int | None = None): 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("rule_attached") not in resized_columns: - self._dialog.table.setColumnWidth(self._col("rule_attached"), 130) 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. @@ -134,6 +163,10 @@ def apply_table_column_sizing(self, index: int | None = None): 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 diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py index ac750dd42c..a7992790e8 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/inspector_panel.py @@ -122,14 +122,11 @@ def build(self) -> None: ) dialog._inspect_value_labels = {} dialog._inspect_error_button = None - dialog._inspect_rules_button = None dialog._inspect_error_full_text = "" - dialog._inspect_attached_rule_names = [] for key, label in ( ("name", QC.translate("stats", "Name")), ("enabled", QC.translate("stats", "Enabled")), ("state", QC.translate("stats", "State")), - ("rule_attached", QC.translate("stats", "Rule attached")), ("last_checked", QC.translate("stats", "Last checked")), ("last_updated", QC.translate("stats", "Last updated")), ("failures", QC.translate("stats", "Failures")), @@ -162,7 +159,7 @@ def build(self) -> None: QtCore.Qt.TextInteractionFlag.TextSelectableByMouse ) dialog._inspect_value_labels[key] = value_label - if key in ("error", "rule_attached"): + if key == "error": field_widget: QtWidgets.QWidget = QtWidgets.QWidget( dialog._inspect_details_widget ) @@ -182,16 +179,6 @@ def build(self) -> None: ) dialog._inspect_error_button = inspect_button field_layout.addWidget(inspect_button, 0) - else: - rules_button: QtWidgets.QPushButton = QtWidgets.QPushButton( - QC.translate("stats", "Rules"), field_widget - ) - rules_button.setVisible(False) - rules_button.clicked.connect( - dialog._rules_attachment_controller.show_attached_rules_dialog - ) - dialog._inspect_rules_button = rules_button - field_layout.addWidget(rules_button, 0) form.addRow(key_label, field_widget) else: form.addRow(key_label, value_label) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py index d1424af2f7..fec29f6926 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/list_subscriptions_dialog.py @@ -209,9 +209,7 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): _inspect_value_labels: dict[str, QtWidgets.QLabel] _inspect_summary_labels: dict[str, QtWidgets.QLabel] _inspect_error_button: QtWidgets.QPushButton | None - _inspect_rules_button: QtWidgets.QPushButton | None _inspect_error_full_text: str - _inspect_attached_rule_names: list[str] _inspect_collapsed: bool _inspect_default_width: int _inspect_has_selection: bool @@ -241,21 +239,104 @@ def __init__( 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._table_data_controller.stop_poll() + 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: diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py index 23ff83c585..d5eb4e45b3 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/status_log_dialog.py @@ -35,21 +35,22 @@ def __init__( timestamp_color: str, ): super().__init__(parent) - display_lines = lines[:] - if not display_lines and (fallback_text or "").strip() != "": - display_lines = [fallback_text] - self._has_content = len(display_lines) > 0 - if not self._has_content: - return - - html_text = self._entries_html(display_lines, level_color, timestamp_color) 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.text_view.setHtml(html_text) + 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, @@ -58,6 +59,42 @@ def __init__( 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], @@ -111,6 +148,11 @@ def _entries_html( ) def exec(self) -> int: - if not self._has_content: - return int(QtWidgets.QDialog.DialogCode.Rejected) 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()) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py index 8fcfbd9fdb..9a479dd34e 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/views/subscription_dialog.py @@ -51,8 +51,14 @@ 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 @@ -137,6 +143,7 @@ def __init__( 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: @@ -175,9 +182,61 @@ def __init__( 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) @@ -219,23 +278,22 @@ def _build_ui(self): 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_inspect_button = QtWidgets.QPushButton( - QC.translate("stats", "Log"), - self, - ) - self._dialog_message_inspect_button.setVisible(False) self._dialog_message_controller = DialogStatusController( label=self.error_label, - inspect_button=self._dialog_message_inspect_button, + inspect_button=None, preview_limit=DIALOG_MESSAGE_PREVIEW_LIMIT, log_limit=DIALOG_MESSAGE_LOG_LIMIT, timestamp_format="yyyy-MM-ddTHH:mm:ss.zzz", @@ -244,31 +302,25 @@ def _build_ui(self): empty_button_behavior="hide", ) self._subscription_dialog_controller = SubscriptionDialogController(dialog=self) - self._dialog_message_inspect_button.clicked.connect( - self._subscription_dialog_controller.show_dialog_message_inspect_dialog - ) 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: - error_row = QtWidgets.QWidget(self) - error_row.setStyleSheet( + 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()};" "}" ) - error_row_layout = QtWidgets.QHBoxLayout(error_row) - error_row_layout.setContentsMargins(12, 0, 12, 0) - error_row_layout.setSpacing(8) - self.rootLayout.removeWidget(self.error_label) - self.error_label.setParent(error_row) - error_row_layout.addWidget( - self._dialog_message_inspect_button, - 0, - QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - error_row_layout.addWidget(self.error_label, 1) - self.rootLayout.insertWidget(error_index, error_row) + 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 ) diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py index e69de29bb2..06f3817ae9 100644 --- a/ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py +++ b/ui/opensnitch/plugins/list_subscriptions/ui/workers/__init__.py @@ -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", +] diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/attached_rules_snapshot_worker.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/attached_rules_snapshot_worker.py new file mode 100644 index 0000000000..93ad69e8d7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/workers/attached_rules_snapshot_worker.py @@ -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.""" diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/state_refresh_worker.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/state_refresh_worker.py new file mode 100644 index 0000000000..59e64ec1a7 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/workers/state_refresh_worker.py @@ -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() \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/subscription_workers.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/subscription_workers.py deleted file mode 100644 index bc69e6c618..0000000000 --- a/ui/opensnitch/plugins/list_subscriptions/ui/workers/subscription_workers.py +++ /dev/null @@ -1,37 +0,0 @@ -from opensnitch.plugins.list_subscriptions.ui import QtCore, QC - - -class UrlTestWorker(QtCore.QThread): - finished = QtCore.pyqtSignal(bool, str) - - def __init__(self, url: str): - super().__init__() - self.url = url - - def run(self): - import requests - - try: - response = requests.head(self.url, allow_redirects=True, timeout=5) - 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 response.status_code in (403, 405): - response = requests.get( - self.url, allow_redirects=True, timeout=5, stream=True - ) - if response.status_code >= 400: - raise requests.HTTPError(f"HTTP {response.status_code}") - final_url = response.url or final_url - response.close() - message = QC.translate("stats", "URL reachable.") - 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.finished.emit(True, f"{message} {final_url}") - return - self.finished.emit(True, message) - except requests.RequestException as exc: - self.finished.emit(False, str(exc)) \ No newline at end of file diff --git a/ui/opensnitch/plugins/list_subscriptions/ui/workers/url_test_worker.py b/ui/opensnitch/plugins/list_subscriptions/ui/workers/url_test_worker.py new file mode 100644 index 0000000000..f7f7533b39 --- /dev/null +++ b/ui/opensnitch/plugins/list_subscriptions/ui/workers/url_test_worker.py @@ -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() \ No newline at end of file