From fca8c07bacae7dfdcfadbed7a4ade83aa48a7840 Mon Sep 17 00:00:00 2001 From: Nicolas Vandamme Date: Tue, 10 Mar 2026 22:17:08 +0100 Subject: [PATCH] decoupling UI and runtime with signaling + rule signaling upon refresh --- .../plugins/list_subscriptions/__init__.py | 15 + .../plugins/list_subscriptions/_gui.py | 1909 +++++++++++++---- .../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, 2648 insertions(+), 750 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 e69de29b..85b3702a 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 0761891a..981c3286 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_gui.py +++ b/ui/opensnitch/plugins/list_subscriptions/_gui.py @@ -5,7 +5,6 @@ import re 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 @@ else: 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 @@ from opensnitch.plugins.list_subscriptions._models import ( 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 @@ COL_ERROR = 18 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 @@ class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): 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 @@ class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): 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 @@ class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): 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 @@ class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): 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 + 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 - 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.")) + 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 has_error: + self._set_dialog_message( + QC.translate("stats", "Fix the highlighted fields."), error=True + ) return if filename == "" and name != "": @@ -231,12 +472,6 @@ class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): 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 @@ class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): 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 @@ class SubscriptionDialog(QtWidgets.QDialog, SubscriptionDialogUI): 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 @@ class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI): 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 @@ class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI): 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 @@ class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI): 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 @@ class BulkEditDialog(QtWidgets.QDialog, BulkEditDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): ) 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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) + 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 - 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) + 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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: - try: - old_plugin.stop() - except Exception: - pass + 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 old_key is not None: - self._actions.delete(old_key) + 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) + 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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) ) 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_TIMEOUT_UNITS, TIMEOUT_UNITS, self._to_str(timeout_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), + 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 @@ class ListSubscriptionsDialog(QtWidgets.QDialog, ListSubscriptionsDialogUI): 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 c932e800..8a3ffef3 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 @@ class SubscriptionSpec: 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 @@ class SubscriptionSpec: 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 @@ class SubscriptionSpec: 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 @@ class MutableSubscriptionSpec: ) @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 @@ class MutableActionConfig: 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 @@ class MutableActionConfig: @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 398ce9be..b5969640 100644 --- a/ui/opensnitch/plugins/list_subscriptions/_utils.py +++ b/ui/opensnitch/plugins/list_subscriptions/_utils.py @@ -2,8 +2,13 @@ import errno 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 @@ SIZE_MULT = { } +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 1c3d2f4e..39aee9ee 100644 --- a/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py +++ b/ui/opensnitch/plugins/list_subscriptions/list_subscriptions.py @@ -5,10 +5,12 @@ import threading 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 @@ else: 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 @@ from opensnitch.plugins.list_subscriptions._models import ( 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 @@ logger.setLevel(logging.WARNING) # -------------------- 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 @@ class ListSubscriptions(PluginBase): 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 - # -------- metadata sidecar -------- + 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 + + 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): except Exception: pass self.scheduled_tasks.clear() - self._running = False # -------- scheduled execution -------- @@ -448,15 +710,15 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): # 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 @@ class ListSubscriptions(PluginBase): 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 00000000..e69de29b 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 c191cfc6..574b8867 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 e7971b8c..1fea6d33 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 - - - + + + - + + + + + + + + Filename - - - + + + - + + + + + + + + Format - + - + Groups - + - + + + + + + + + Interval - + @@ -96,14 +124,14 @@ - + Timeout - + @@ -113,14 +141,14 @@ - + Max size - + @@ -298,6 +326,13 @@ + + + + Test URL + + +