Advertisement
Guest User

Untitled

a guest
May 7th, 2025
29
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 79.85 KB | Source Code | 0 0
  1. # --- DnsCryptTray - a quick and dirty python script to help easily? whitelist blocked domains
  2. # --- Desoigned and half-assed by Adrian Miller
  3.  
  4. import sys
  5. import os
  6. import re
  7. import threading # Still imported, but watcher logic moved to QThread
  8. import time
  9. import json
  10. import subprocess
  11. import winreg # Used for startup registry key
  12. import tldextract
  13.  
  14. from PyQt5.QtWidgets import (
  15.     QApplication, QWidget, QSystemTrayIcon, QMenu, QAction,
  16.     QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout, QGridLayout, QPushButton, QCheckBox,
  17.     QDialog, QMessageBox, QHeaderView, QAbstractItemView, QSizePolicy, QSpinBox, QLabel, QComboBox,
  18.     QDialogButtonBox # Import QDialogButtonBox for custom buttons in QMessageBox
  19. )
  20. from PyQt5.QtGui import QIcon, QPixmap
  21. from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject, QThread # Import QThread
  22.  
  23. # --- Configuration and Constants ---
  24. # Define BASE_DIR to handle paths correctly when running from source or compiled executable
  25. if getattr(sys, 'frozen', False):
  26.     # Running as bundled executable
  27.     BASE_DIR = os.path.dirname(sys.executable)
  28. else:
  29.     # Running as a script
  30.     BASE_DIR = os.path.dirname(os.path.abspath(__file__))
  31.  
  32. TEXT_FILE_PATH = os.path.join(BASE_DIR, "blocked-names.log")
  33. OUTPUT_FILE_PATH = os.path.join(BASE_DIR, "allowed-names.txt")
  34. CONFIG_FILE_PATH = os.path.join(BASE_DIR, "config.json")
  35. HELPER_SCRIPT_PATH = os.path.join(BASE_DIR, "restart_service_helper.py") # Assuming helper is in base dir
  36.  
  37. APP_NAME = "DnsCryptTray"
  38. DEFAULT_TIMEOUT_SECONDS = 10 # Default for the new message box
  39. DEFAULT_PROMPT_ACTION = "Ignore" # New default action (must match button text)
  40.  
  41. # --- Configuration Loading/Saving ---
  42. def load_config():
  43.     """Loads configuration from JSON file."""
  44.     print(f"Attempting to load config from: {CONFIG_FILE_PATH}") # Debug print
  45.     config = {}
  46.     if os.path.exists(CONFIG_FILE_PATH):
  47.         try:
  48.             with open(CONFIG_FILE_PATH, 'r', encoding='utf-8') as f:
  49.                 config = json.load(f)
  50.             print("Config loaded successfully.") # Debug print
  51.         except json.JSONDecodeError:
  52.             print(f"Warning: Error decoding JSON from {CONFIG_FILE_PATH}. Using defaults.")
  53.         except Exception as e:
  54.             print(f"Warning: Could not load config {CONFIG_FILE_PATH}: {e}. Using defaults.")
  55.     else:
  56.         print(f"Config file not found: {CONFIG_FILE_PATH}. Using defaults.") # Debug print
  57.  
  58.     # Ensure default values are present after loading
  59.     if "messagebox_timeout_seconds" not in config:
  60.          config["messagebox_timeout_seconds"] = DEFAULT_TIMEOUT_SECONDS
  61.     if "default_prompt_action" not in config:
  62.          config["default_prompt_action"] = DEFAULT_PROMPT_ACTION
  63.     if "notifications_enabled" not in config:
  64.          config["notifications_enabled"] = True
  65.     if "run_on_startup" not in config:
  66.          config["run_on_startup"] = False
  67.     if "restart_on_clear" not in config:
  68.          config["restart_on_clear"] = False
  69.  
  70.     return config
  71.  
  72.  
  73. def save_config(config):
  74.     """Saves configuration to JSON file."""
  75.     print(f"Attempting to save config to: {CONFIG_FILE_PATH}") # Debug print
  76.     try:
  77.         # Ensure the directory exists before saving
  78.         config_dir = os.path.dirname(CONFIG_FILE_PATH)
  79.         if config_dir and not os.path.exists(config_dir):
  80.             os.makedirs(config_dir)
  81.             print(f"Created config directory: {config_dir}") # Debug print
  82.  
  83.         with open(CONFIG_FILE_PATH, 'w', encoding='utf-8') as f:
  84.             json.dump(config, f, indent=4) # Indent for readability
  85.         print("Config saved successfully.") # Debug print
  86.     except Exception as e:
  87.         print(f"Error saving config {CONFIG_FILE_PATH}: {e}")
  88.  
  89.  
  90. # --- Config Manager Class ---
  91. class ConfigManager:
  92.     """Manages application configuration."""
  93.     _config = None
  94.  
  95.     @classmethod
  96.     def load(cls):
  97.         """Loads configuration."""
  98.         cls._config = load_config()
  99.         print("ConfigManager loaded config.") # Debug print
  100.  
  101.     @classmethod
  102.     def get(cls, key, default=None):
  103.         """Gets a configuration value, loads config if necessary."""
  104.         if cls._config is None:
  105.             cls.load() # Load existing config if not already loaded
  106.         # Default values are ensured by load_config, so direct get is fine here
  107.         return cls._config.get(key, default)
  108.  
  109.     @classmethod
  110.     def set(cls, key, value):
  111.         """Sets a configuration value and saves the config."""
  112.         if cls._config is None:
  113.             cls.load() # Load existing config before modifying
  114.         cls._config[key] = value
  115.         print(f"ConfigManager: Setting key '{key}' to '{value}' in memory.") # Debug print
  116.         save_config(cls._config)
  117.         print("ConfigManager: Config saved after set.") # Debug print
  118.  
  119.  
  120. # --- Whitelist Utility Function ---
  121. def add_domain_to_whitelist(domain_to_add):
  122.     """
  123.    Reads the existing whitelist, adds a new domain if not present,
  124.    sorts the list, and writes it back to the file. Returns True on success.
  125.    Handles case-insensitivity and basic sanitization.
  126.    """
  127.     print(f"Attempting to add '{domain_to_add}' to whitelist...") # Debug print
  128.     if not domain_to_add:
  129.         print("Warning: Attempted to add an empty domain to whitelist.") # Debug print
  130.         return False
  131.  
  132.     # Basic sanitization - remove leading/trailing dots, lowercase for storage/comparison
  133.     domain_to_add_lower = domain_to_add.strip().strip('.').lower()
  134.     if not domain_to_add_lower: # Check again after stripping
  135.          print("Warning: Domain is empty after sanitization.") # Debug print
  136.          return False
  137.  
  138.     existing = set()
  139.     try:
  140.         if os.path.exists(OUTPUT_FILE_PATH):
  141.             # Ensure file is read with UTF-8 encoding
  142.             print(f"Reading existing whitelist from '{OUTPUT_FILE_PATH}'.") # Debug print
  143.             with open(OUTPUT_FILE_PATH, 'r', encoding='utf-8') as f:
  144.                 # Store existing domains in lowercase for case-insensitive check
  145.                 existing.update(line.strip().lower() for line in f if line.strip())
  146.             print(f"Read {len(existing)} existing domains.") # Debug print
  147.     except Exception as e:
  148.         print(f"Error reading whitelist file '{OUTPUT_FILE_PATH}': {e}") # Debug print
  149.         # Optionally show a more user-facing error using QMessageBox if needed in GUI context
  150.         return False
  151.  
  152.     if domain_to_add_lower in existing:
  153.         print(f"Domain '{domain_to_add}' (as '{domain_to_add_lower}') is already in the whitelist.") # Debug print
  154.         return True # Already exists, considered success
  155.  
  156.     print(f"Domain '{domain_to_add_lower}' is new. Adding to set.") # Debug print
  157.     existing.add(domain_to_add_lower)
  158.     # Sort case-insensitively (already lowercase in set)
  159.     all_domains = sorted(list(existing))
  160.  
  161.     try:
  162.         # Ensure the directory exists before saving
  163.         output_dir = os.path.dirname(OUTPUT_FILE_PATH)
  164.         if output_dir and not os.path.exists(output_dir):
  165.             os.makedirs(output_dir)
  166.             print(f"Created output directory: {output_dir}") # Debug print
  167.  
  168.  
  169.         # Ensure file is written with UTF-8 encoding
  170.         print(f"Writing updated whitelist to '{OUTPUT_FILE_PATH}'. Total {len(all_domains)} domains.") # Debug print
  171.         with open(OUTPUT_FILE_PATH, 'w', encoding='utf-8') as f:
  172.             for domain in all_domains:
  173.                 f.write(domain + '\n')
  174.         print(f"Successfully added '{domain_to_add}' (as '{domain_to_add_lower}') to whitelist '{OUTPUT_FILE_PATH}'.") # Debug print
  175.         return True
  176.     except Exception as e:
  177.         print(f"Error writing whitelist file '{OUTPUT_FILE_PATH}': {e}") # Debug print
  178.         # Optionally show a more user-facing error
  179.         return False
  180.  
  181. # --- File Watcher Class (using QThread) ---
  182. class FileWatcher(QObject):
  183.     """Watches the blocked-names.log file for new entries."""
  184.     # Signal emitted with the domain string when a new domain is detected
  185.     new_domain_signal = pyqtSignal(str)
  186.  
  187.     def __init__(self, filepath):
  188.         super().__init__()
  189.         self.filepath = filepath
  190.         self._stop = False
  191.         # REMOVED: self.seen_domains = set() # Seen domains are now tracked by TrayIcon
  192.         self._last_position = 0
  193.         self._last_inode = None # Track file identity (inode on Unix, similar concept on Windows)
  194.         self._last_mtime = None # Track modification time
  195.  
  196.         # Create and configure the QThread
  197.         self._thread = QThread()
  198.         # Move the worker object (self) to the new thread
  199.         self.moveToThread(self._thread)
  200.         # Connect the thread's started signal to the worker's main task method
  201.         self._thread.started.connect(self.watch_file)
  202.         # Clean up when the thread finishes
  203.         self._thread.finished.connect(self._thread.deleteLater)
  204.         print("FileWatcher initialized and moved to QThread.") # Debug print
  205.  
  206.  
  207.     def start_watching(self):
  208.          """Starts the file watching QThread."""
  209.          if not self._thread.isRunning():
  210.               print("FileWatcher QThread starting...") # Debug print
  211.               self._stop = False # Ensure stop flag is false before starting
  212.               self._thread.start()
  213.          else:
  214.               print("FileWatcher QThread is already running.") # Debug print
  215.  
  216.  
  217.     def stop_watching(self):
  218.         """Signals the watcher thread to stop and waits for it."""
  219.         if self._thread.isRunning():
  220.              print("Requesting FileWatcher QThread stop.") # Debug print
  221.              self._stop = True # Signal the loop to stop
  222.              # Wait for the thread's event loop (which is just sleep in this case) to finish
  223.              self._thread.quit()
  224.              # Optionally wait for the thread to finish execution - can block GUI if not careful
  225.              # self._thread.wait(2000) # Wait up to 2 seconds
  226.              # if self._thread.isRunning():
  227.              #      print("Warning: FileWatcher thread did not quit within timeout.") # Debug print
  228.              print("FileWatcher QThread stop requested.") # Debug print
  229.         else:
  230.              print("FileWatcher QThread is not running.") # Debug print
  231.  
  232.  
  233.     def watch_file(self):
  234.         """QThread target function to monitor the log file."""
  235.         print(f"FileWatcher thread running. Monitoring: {self.filepath}") # <-- Debug print
  236.  
  237.         # Initial file check and positioning
  238.         try:
  239.             if not os.path.exists(self.filepath):
  240.                 print(f"Warning: Monitored file not found at startup: {self.filepath}. Waiting for it...") # <-- Debug print
  241.                 # Wait for the file to appear
  242.                 while not os.path.exists(self.filepath) and not self._stop:
  243.                     time.sleep(1) # Wait 1 second before checking again
  244.                     # QApplication.processEvents() # Process GUI events if needed, but be careful in worker thread
  245.                 if self._stop:
  246.                     print("Watcher stopping during initial wait for file.") # <-- Debug print
  247.                     return # Exit thread if stop is requested while waiting
  248.                 print(f"File found: {self.filepath}. Attempting initial access.") # <-- Debug print
  249.  
  250.  
  251.             # Initial seek to the end
  252.             with open(self.filepath, 'r', encoding='utf-8', errors='ignore') as f:
  253.                  f.seek(0, os.SEEK_END)
  254.                  self._last_position = f.tell()
  255.                  print(f"Initial seek successful. Starting position: {self._last_position}") # <-- Debug print
  256.  
  257.             # Get initial file stats
  258.             stat_info = os.stat(self.filepath)
  259.             self._last_inode = getattr(stat_info, 'st_ino', None) # Get inode if available (Unix-like)
  260.             self._last_mtime = stat_info.st_mtime # Get modification time
  261.  
  262.             print(f"Initial file stats: size={stat_info.st_size}, mtime={self._last_mtime}, inode={self._last_inode}") # <-- Debug print
  263.  
  264.  
  265.         except Exception as e:
  266.              print(f"ERROR: Initial file access or stat failed for {self.filepath}: {e}. Watcher thread is stopping.") # <-- Debug print
  267.              return # Watcher stops on initial error
  268.  
  269.  
  270.         # Main monitoring loop
  271.         print("Entering file monitoring loop...") # <-- Debug print
  272.  
  273.         while not self._stop:
  274.             # print("Watcher loop iteration.") # <-- Optional: Add for every loop check
  275.             try:
  276.                 # Check if file still exists and get its stats
  277.                 if not os.path.exists(self.filepath):
  278.                      # If file disappears *after* the initial check, handle it
  279.                      print(f"Warning: Monitored file {self.filepath} disappeared during watching (in loop).") # <-- Debug print
  280.                      raise FileNotFoundError(f"{self.filepath} disappeared") # Jump to FileNotFoundError handler
  281.  
  282.                 stat_info = os.stat(self.filepath)
  283.                 current_size = stat_info.st_size
  284.                 current_inode = getattr(stat_info, 'st_ino', None)
  285.                 current_mtime = stat_info.st_mtime
  286.  
  287.                 # Check for file replacement (inode change or mtime regression)
  288.                 file_replaced = False
  289.                 if self._last_inode is not None and current_inode is not None and current_inode != self._last_inode:
  290.                      print(f"Warning: File inode changed for {self.filepath}. File replaced? Resetting position.") # <-- Debug print
  291.                      file_replaced = True
  292.                 elif self._last_mtime is not None and current_mtime is not None and self._last_mtime is not None and current_mtime < self._last_mtime:
  293.                       # mtime went backward - likely file replacement/system clock issue
  294.                       print(f"Warning: File modification time went backward for {self.filepath}. File replaced? Resetting position.") # <-- Debug print
  295.                       file_replaced = True
  296.  
  297.                 if file_replaced:
  298.                      self._last_position = 0
  299.                      # REMOVED: self.seen_domains.clear() # Seen domains are cleared by TrayIcon when the file is cleared
  300.                      self._last_inode = current_inode # Update stats
  301.                      self._last_mtime = current_mtime
  302.                      # Continue the loop, the size check below will handle reading from 0 if size > 0
  303.                      print(f"Stats after file replacement detection: size={current_size}, mtime={self._last_mtime}, inode={self._last_inode}") # <-- Debug print
  304.  
  305.  
  306.                 # Check for truncation (size decreased) - handles cases like file clear
  307.                 if current_size < self._last_position:
  308.                     print(f"Log file truncation detected. Size {current_size} < LastPos {self._last_position}. Resetting position.") # <-- Debug print
  309.                     self._last_position = 0
  310.                     # REMOVED: self.seen_domains.clear() # Seen domains are cleared by TrayIcon when the file is cleared
  311.                     # Update stats as file has effectively become a new file at the same path
  312.                     try:
  313.                          # Ensure file exists before getting stats after truncation
  314.                          if os.path.exists(self.filepath):
  315.                               stat_info_after_truncate_check = os.stat(self.filepath) # Get fresh stats
  316.                               self._last_inode = getattr(stat_info_after_truncate_check, 'st_ino', None)
  317.                               self._last_mtime = stat_info_after_truncate_check.st_mtime
  318.                               print(f"Stats after truncation reset: size={stat_info_after_truncate_check.st_size}, mtime={self._last_mtime}, inode={self._last_inode}") # <-- Debug print
  319.                          else:
  320.                              print(f"File {self.filepath} disappeared right after truncation detection. Resetting stats.") # Debug print
  321.                              self._last_inode = None
  322.                              self._last_mtime = None
  323.  
  324.                     except Exception as e:
  325.                          print(f"Warning: Could not get stats after truncation processing for {self.filepath}: {e}") # <-- Debug print
  326.                          self._last_inode = None # Reset if stat fails
  327.                          self._last_mtime = None
  328.  
  329.  
  330.                 # Check for new data (size increased)
  331.                 if current_size > self._last_position:
  332.                     print(f"New data detected. Current size: {current_size}, Last position: {self._last_position}") # <-- Debug print
  333.                     try:
  334.                         with open(self.filepath, 'r', encoding='utf-8', errors='ignore') as f:
  335.                             f.seek(self._last_position)
  336.                             new_lines = f.readlines()
  337.                             new_position = f.tell() # Get the position after reading
  338.                             print(f"Read {len(new_lines)} new line(s). Updating position from {self._last_position} to {new_position}.") # <-- Debug print
  339.                             self._last_position = new_position # Update position for the next read
  340.  
  341.                             for line in new_lines:
  342.                                  print(f"Processing line: {line.strip()}") # <-- Uncommented debug print
  343.                                  if not line.strip():
  344.                                     continue
  345.                                  domains_in_line = self.parse_line(line)
  346.                                  print(f"Parsed domains from line: {domains_in_line}") # <-- Uncommented debug print
  347.                                  for domain in domains_in_line:
  348.                                      # The watcher now emits the signal for every domain found in new lines.
  349.                                      # Uniqueness check for notifications is done in TrayIcon.handle_new_domain.
  350.                                      if domain.strip(): # Ensure domain is not empty after stripping
  351.                                          print(f"Watcher found domain: {domain}. Emitting signal.") # <-- Debug print
  352.                                          # Emit the original case domain string via the signal
  353.                                          self.new_domain_signal.emit(domain.strip()) # Emit stripped original case
  354.  
  355.                     except Exception as e:
  356.                          print(f"ERROR reading new data from {self.filepath}: {e}") # <-- Debug print
  357.                          # If read fails, might need to reset position or wait longer
  358.                          # For now, just print error and continue loop
  359.  
  360.  
  361.                 # Update last known stats if no errors occurred and file exists
  362.                 # (This happens implicitly if no exception is raised in the main try block)
  363.                 # We already updated stats for replacement/truncation, but update here for normal growth
  364.                 if os.path.exists(self.filepath): # Double check exists after potential read errors
  365.                      try:
  366.                           stat_info_after_process = os.stat(self.filepath)
  367.                           self._last_inode = getattr(stat_info_after_process, 'st_ino', None)
  368.                           self._last_mtime = stat_info_after_process.st_mtime
  369.                      except Exception as e:
  370.                           print(f"Warning: Could not get stats after processing lines for {self.filepath}: {e}") # <-- Debug print
  371.                           self._last_inode = None # Reset if stat fails
  372.                           self._last_mtime = None
  373.  
  374.  
  375.                 # Prevent busy-waiting
  376.                 time.sleep(0.5) # Keep a small sleep interval
  377.  
  378.             except FileNotFoundError:
  379.                  print(f"Warning: Monitored file {self.filepath} is not found (in loop). Resetting state. Waiting 5s...") # <-- Debug print
  380.                  self._last_position = 0 # Reset position as file is gone
  381.                  # REMOVED: self.seen_domains.clear() # Seen domains handled by TrayIcon
  382.                  self._last_inode = None # Reset stats
  383.                  self._last_mtime = None
  384.                  time.sleep(5)
  385.                  # After waiting, check if it exists again. If it reappeared, the next loop iteration
  386.                  # will find it and read from the beginning (position 0) due to last_position being 0.
  387.                  if os.path.exists(self.filepath):
  388.                       print(f"File {self.filepath} reappeared after wait.") # <-- Debug print
  389.                       # Attempt to get initial stats for the reappeared file immediately
  390.                       try:
  391.                           stat_info_reappeared = os.stat(self.filepath)
  392.                           self._last_inode = getattr(stat_info_reappeared, 'st_ino', None)
  393.                           self._last_mtime = stat_info_reappeared.st_mtime
  394.                           print(f"Got stats for reappeared file: size={stat_info_reappeared.st_size}, mtime={self._last_mtime}, inode={self._last_inode}") # <-- Debug print
  395.                       except Exception as e:
  396.                            print(f"Error getting stats for reappeared file {self.filepath}: {e}") # <-- Debug print
  397.                            self._last_inode = None # Stats failed again
  398.                            self._last_mtime = None
  399.  
  400.                  else:
  401.                       print(f"File {self.filepath} still missing after wait.") # <-- Debug print
  402.  
  403.  
  404.             except Exception as e:
  405.                  print(f"FATAL ERROR: Unhandled exception during file watching loop for {self.filepath}: {e}") # <-- Debug print
  406.                  # This is a more serious error, maybe wait longer or consider stopping?
  407.                  # For now, wait and continue the loop.
  408.                  time.sleep(5) # Wait longer after an error
  409.  
  410.  
  411.         print(f"FileWatcher thread stopping gracefully for: {self.filepath}") # <-- Debug print
  412.  
  413.  
  414.     def parse_line(self, line):
  415.         """Parses a log line to extract domain names."""
  416.         # print(f"Parsing line: {line.strip()}") # Optional: Debug print for parser input
  417.         domains = set()
  418.         # Regex to find domains inside "(alias for [domain.name])"
  419.         alias_matches = re.findall(r'\s\(alias for\s+\[([^\]]+)\]\)', line)
  420.         if alias_matches:
  421.             # Handle multiple aliases if syntax allows, though usually one
  422.             for alias in alias_matches:
  423.                 domains.add(alias.strip()) # Keep original case from log for signal
  424.         else:
  425.             # Assume tab-separated: timestamp, client_ip, domain, filter_list_name, ...
  426.             parts = line.strip().split('\t')
  427.             if len(parts) >= 3:
  428.                 # Domain is usually the 3rd part (index 2)
  429.                 potential_domain = parts[2].strip()
  430.                 # Basic validation: check for at least one dot and no spaces
  431.                 if '.' in potential_domain and ' ' not in potential_domain:
  432.                     domains.add(potential_domain) # Keep original case from log for signal
  433.         # print(f"Parsed domains: {domains}") # Optional: Debug print for parser output
  434.         return domains
  435.  
  436.     def filter_domain(self, domain):
  437.         """Optional filtering logic for domains."""
  438.         # Optional filtering logic (e.g., ignore certain patterns)
  439.         # For now, accept all valid-looking domains found
  440.         # Example: pattern = ConfigManager.get("domain_filter_regex")
  441.         # if pattern: try: return re.fullmatch(pattern, domain) is not None except re.error: pass
  442.         return True # Accept all parsed domains by default
  443.  
  444.  
  445. # --- Main Dialog Class ---
  446. class DomainListDialog(QDialog):
  447.     """Dialog to display blocked domains and manage whitelisting."""
  448.     def __init__(self, parent=None):
  449.         # FIX: QDialog parent must be a QWidget or None. TrayIcon is not a QWidget.
  450.         super().__init__(parent) # <-- Parent will now be None when created from TrayIcon
  451.  
  452.         self.setWindowTitle("DNSCrypt-Proxy Blocked Domains")
  453.         # REVERTING LAYOUT: Revert initial size to 700, 550
  454.         self.resize(700, 550) # <--- Reverted initial size
  455.  
  456.         # REVERTING LAYOUT: Create main layout with default stretch
  457.         self.layout = QVBoxLayout(self) # <--- Reverted layout creation
  458.  
  459.         self.table_widget = QTableWidget(self)
  460.         # Hide the default header labels, we use tooltips and imply meaning by column content
  461.         self.table_widget.verticalHeader().setVisible(False)
  462.         self.table_widget.horizontalHeader().setVisible(False)
  463.         # Updated column count and headers for two checkboxes and two domain columns
  464.         self.table_widget.setColumnCount(4)
  465.         # self.table_widget.setHorizontalHeaderLabels(["Whitelist Full", "Full Domain", "Whitelist Base", "Base Domain"]) # Headers now hidden
  466.  
  467.         # Adjust resize modes for the new columns
  468.         self.table_widget.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents) # Whitelist Full Checkbox
  469.         self.table_widget.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) # Stretch Full Domain
  470.         self.table_widget.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) # Whitelist Base Checkbox
  471.         self.table_widget.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) # Stretch Base Domain (adjust stretch if needed)
  472.  
  473.         # Set default row height to potentially fix checkbox rendering and give space
  474.         self.table_widget.verticalHeader().setDefaultSectionSize(32) # Increased row height
  475.  
  476.         self.table_widget.setSortingEnabled(True) # Allow sorting
  477.         self.table_widget.setSelectionBehavior(QAbstractItemView.SelectRows) # Select full rows
  478.         self.table_widget.setSelectionMode(QAbstractItemView.NoSelection) # Disable selection to avoid conflict with checkboxes
  479.  
  480.         # REVERTING LAYOUT: Remove setMinimumHeight for the table
  481.         # self.table_widget.setMinimumHeight(300) # <--- REMOVED this line
  482.  
  483.  
  484.         # REVERTING LAYOUT: Add the table widget with default stretch (0)
  485.         self.layout.addWidget(self.table_widget) # <--- Reverted line, removed explicit stretch
  486.  
  487.  
  488.         # --- Settings Section ---
  489.         settings_layout = QVBoxLayout()
  490.         settings_layout.setContentsMargins(0, 0, 0, 0) # Remove margins
  491.  
  492.         self.checkbox_notify = QCheckBox("Enable domain blocking notifications/prompts", self)
  493.         self.checkbox_notify.setChecked(ConfigManager.get("notifications_enabled", True))
  494.         settings_layout.addWidget(self.checkbox_notify)
  495.  
  496.         # Add spin box for timeout setting
  497.         self.timeout_spinbox = QSpinBox(self)
  498.         self.timeout_spinbox.setRange(5, 60) # Allow timeout between 5 and 60 seconds
  499.         self.timeout_spinbox.setValue(ConfigManager.get("messagebox_timeout_seconds", DEFAULT_TIMEOUT_SECONDS))
  500.         self.timeout_spinbox.setSuffix(" seconds")
  501.         self.timeout_label = QLabel("Prompt timeout:", self)
  502.         self.timeout_label.setTextFormat(Qt.RichText) # <-- Fix: Enable rich text for label
  503.  
  504.  
  505.         timeout_layout = QHBoxLayout()
  506.         timeout_layout.addWidget(self.timeout_label)
  507.         timeout_layout.addWidget(self.timeout_spinbox)
  508.         timeout_layout.addStretch() # Push widgets to the left
  509.         settings_layout.addLayout(timeout_layout) # Add timeout setting layout
  510.  
  511.  
  512.         # Add dropdown for default action setting
  513.         self.default_action_combo = QComboBox(self)
  514.         # Ensure these match the strings used in show_domain_notification and ConfigManager
  515.         self.default_action_combo.addItems(["Ignore", "Whitelist Full", "Whitelist Base"])
  516.         # Set the current index based on the saved config value
  517.         default_action = ConfigManager.get("default_prompt_action", DEFAULT_PROMPT_ACTION)
  518.         index = self.default_action_combo.findText(default_action, Qt.MatchExactly)
  519.         if index != -1:
  520.             self.default_action_combo.setCurrentIndex(index)
  521.         else:
  522.             # If saved value is invalid, reset to default and save
  523.             print(f"Warning: Invalid saved default prompt action '{default_action}'. Resetting to default.") # Debug print
  524.             self.default_action_combo.setCurrentText(DEFAULT_PROMPT_ACTION)
  525.             ConfigManager.set("default_prompt_action", DEFAULT_PROMPT_ACTION) # Save the corrected value
  526.  
  527.         self.default_action_label = QLabel("Default prompt action on timeout:", self)
  528.         self.default_action_label.setTextFormat(Qt.RichText) # <-- Fix: Enable rich text for label
  529.  
  530.         default_action_layout = QHBoxLayout()
  531.         default_action_layout.addWidget(self.default_action_label)
  532.         default_action_layout.addWidget(self.default_action_combo)
  533.         default_action_layout.addStretch() # Push widgets to the left
  534.         settings_layout.addLayout(default_action_layout) # Add default action setting layout
  535.  
  536.         self.checkbox_startup = QCheckBox("Run this application on system startup", self)
  537.         self.checkbox_startup.setChecked(ConfigManager.get("run_on_startup", False))
  538.         settings_layout.addWidget(self.checkbox_startup)
  539.  
  540.         self.checkbox_restart_on_clear = QCheckBox("Auto-restart dnscrypt-proxy after clearing log", self)
  541.         self.checkbox_restart_on_clear.setChecked(ConfigManager.get("restart_on_clear", False))
  542.         settings_layout.addWidget(self.checkbox_restart_on_clear)
  543.  
  544.  
  545.         # REVERTING LAYOUT: Add the settings layout with default stretch (0)
  546.         self.layout.addLayout(settings_layout) # <--- Reverted line, removed explicit stretch
  547.  
  548.  
  549.         # --- Button Section ---
  550.         button_layout = QGridLayout()
  551.         button_layout.setContentsMargins(0, 10, 0, 0) # Add some top margin
  552.  
  553.         self.button_save = QPushButton("Whitelist selected domain(s)", self)
  554.         self.button_clear_input = QPushButton("Clear blocked-names.log", self)
  555.         self.button_restart_service = QPushButton("Restart dnscrypt-proxy Service", self)
  556.         self.button_refresh_list = QPushButton("Refresh List from Log File", self)
  557.         self.button_close = QPushButton("Close Window", self)
  558.  
  559.         button_layout.addWidget(self.button_save, 0, 0)
  560.         button_layout.addWidget(self.button_clear_input, 0, 1)
  561.         button_layout.addWidget(self.button_restart_service, 1, 0)
  562.         button_layout.addWidget(self.button_refresh_list, 1, 1)
  563.         button_layout.addWidget(self.button_close, 2, 0, 1, 2) # Span close across bottom
  564.         # REVERTING LAYOUT: Add the button layout with default stretch (0)
  565.         self.layout.addLayout(button_layout) # <--- Reverted line, removed explicit stretch
  566.  
  567.  
  568.         # REVERTING LAYOUT: Remove the final addStretch. This will remove the explicit void at the bottom.
  569.         # self.layout.addStretch(1) # <--- REMOVED THIS LINE (was not in original problematic code)
  570.  
  571.  
  572.         # Internal state
  573.         # Store domains in lowercase in the set for case-insensitive tracking
  574.         # This set now tracks what's currently displayed in the table.
  575.         self.domains_set = set() # Tracks domains currently *displayed* in the table (lowercase)
  576.         self.populate_list() # Initial population
  577.  
  578.  
  579.         # --- Connections ---
  580.         self.button_save.clicked.connect(self.save_selected_domains_from_table)
  581.         self.button_clear_input.clicked.connect(self.clear_input_file)
  582.         self.button_restart_service.clicked.connect(self.restart_dnscrypt_proxy)
  583.         self.button_refresh_list.clicked.connect(self.populate_list)
  584.         self.button_close.clicked.connect(self.hide) # Hide instead of close
  585.  
  586.         self.checkbox_notify.stateChanged.connect(self.save_settings)
  587.         self.timeout_spinbox.valueChanged.connect(self.save_timeout_setting) # Connect spinbox value changed
  588.         self.default_action_combo.currentIndexChanged.connect(self.save_default_action_setting) # Connect combobox index changed
  589.         self.checkbox_startup.stateChanged.connect(self.toggle_startup)
  590.         self.checkbox_restart_on_clear.stateChanged.connect(self.save_settings)
  591.  
  592.  
  593.     def closeEvent(self, event):
  594.         """Override close event to hide instead of exit."""
  595.         print("DomainListDialog close event received. Hiding window.") # Debug print
  596.         event.ignore() # Ignore the close event
  597.         self.hide() # Hide the window instead
  598.  
  599.  
  600.     def populate_list(self):
  601.         """Reads the log file (last N lines) and populates the table."""
  602.         print(f"Populating list from {TEXT_FILE_PATH}...") # Debug print
  603.         if not os.path.exists(TEXT_FILE_PATH):
  604.             QMessageBox.warning(self, "File Not Found", f"{os.path.basename(TEXT_FILE_PATH)} not found.")
  605.             print(f"Log file not found: {TEXT_FILE_PATH}. Clearing table.") # Debug print
  606.             self.table_widget.setRowCount(0) # Clear table if file is gone
  607.             self.domains_set.clear() # Clear dialog's internal set
  608.             return
  609.  
  610.         # Read last ~500 lines for context (increased from 200 for more history)
  611.         lines = []
  612.         try:
  613.             print("Reading lines from log file...") # Debug print
  614.             with open(TEXT_FILE_PATH, 'r', encoding='utf-8', errors='ignore') as file:
  615.                # Read all lines efficiently, then take the tail
  616.                all_lines = file.readlines()
  617.                lines = all_lines[-500:] # Adjust number of lines as needed
  618.             print(f"Finished reading {len(all_lines)} total lines from log, processing last {len(lines)}.") # Debug print
  619.         except Exception as e:
  620.            QMessageBox.critical(self, "Error Reading Log", f"Could not read {os.path.basename(TEXT_FILE_PATH)}:\n{e}")
  621.            print(f"Error reading log file {TEXT_FILE_PATH}: {e}. Clearing table.") # Debug print
  622.            self.table_widget.setRowCount(0) # Clear table on read error
  623.            self.domains_set.clear() # Clear dialog's internal set
  624.            return
  625.  
  626.         # Use FileWatcher's parser logic
  627.         # Create a temporary watcher instance just to use its parsing method
  628.         # Pass the path, but its internal state won't affect parse_line
  629.         temp_watcher = FileWatcher(TEXT_FILE_PATH)
  630.         current_domains_in_log = set() # Use a set to store unique domains (lowercase) found in recent log entries
  631.         domain_display_map = {} # Map lowercase to first encountered original case for display
  632.  
  633.         for line in lines:
  634.             parsed_domains = temp_watcher.parse_line(line)
  635.             for domain in parsed_domains:
  636.                  domain_lower = domain.strip().lower()
  637.                  if domain_lower and domain_lower not in current_domains_in_log:
  638.                       current_domains_in_log.add(domain_lower)
  639.                       # Store the first encountered case for display
  640.                       domain_display_map[domain_lower] = domain.strip()
  641.  
  642.  
  643.         print(f"Parsed {len(current_domains_in_log)} unique domains (lowercase) from recent log entries.") # Debug print
  644.  
  645.         # Update table - Clear and repopulate
  646.         self.table_widget.setSortingEnabled(False) # Disable sorting during update
  647.         self.table_widget.setRowCount(0) # Clear table
  648.         self.domains_set.clear()          # Clear dialog's internal tracking set (lowercase)
  649.  
  650.  
  651.         # Sort unique lowercase domains for consistent table order
  652.         sorted_domains_lower = sorted(list(current_domains_in_log))
  653.  
  654.         # Add rows for each unique domain
  655.         for domain_lower in sorted_domains_lower:
  656.              # Use the stored original/first encountered case for adding to the table
  657.              display_domain = domain_display_map.get(domain_lower, domain_lower) # Fallback to lowercase if map somehow failed
  658.              self.add_domain_row(display_domain) # add_domain_row handles uniqueness within the dialog's set
  659.  
  660.         print(f"Table populated with {self.table_widget.rowCount()} rows.") # Debug print
  661.         self.table_widget.setSortingEnabled(True) # Re-enable sorting
  662.  
  663.  
  664.     def add_domain_row(self, full_domain):
  665.         """Adds a domain to the table with exclusive checkboxes for full and base domain whitelisting."""
  666.         # Use lowercase for internal set check
  667.         domain_lower = full_domain.strip().strip('.').lower() # Ensure consistency
  668.         # Check against the dialog's internal set (self.domains_set)
  669.         if not domain_lower or domain_lower in self.domains_set:
  670.             # print(f"Skipping adding domain row: '{full_domain}' (empty or duplicate in set).") # Optional debug print
  671.             return # Ignore empty or duplicate domains (case-insensitive check)
  672.  
  673.         self.domains_set.add(domain_lower) # Add lowercase to the dialog's set
  674.  
  675.         # Attempt to extract base domain using tldextract
  676.         base_domain = full_domain # Default to full domain if extraction fails or result isn't valid
  677.         try:
  678.             base = tldextract.extract(full_domain)
  679.             # Ensure both domain and suffix exist for a valid base domain
  680.             # Use the result from tldextract if valid, otherwise fallback to full_domain
  681.             if base.domain and base.suffix:
  682.                  base_domain = f"{base.domain}.{base.suffix}"
  683.             else:
  684.                  # If tldextract didn't find domain/suffix, check if the full domain itself is a potential base
  685.                  # (e.g., "localhost", although typically not blocked like this)
  686.                  # For now, just use full domain if tldextract failed to find parts
  687.                  pass # base_domain is already set to full_domain
  688.         except Exception as e:
  689.             print(f"Warning: tldextract failed for '{full_domain}': {e}. Using full domain as base.") # Debug print
  690.             base_domain = full_domain # Fallback to full domain
  691.  
  692.  
  693.         row = self.table_widget.rowCount()
  694.         self.table_widget.insertRow(row)
  695.  
  696.         # Column 0: Whitelist Full Domain (Checkbox)
  697.         checkbox_full = QCheckBox()
  698.         # Center the checkbox and remove spacing
  699.         checkbox_full_layout = QHBoxLayout()
  700.         checkbox_full_layout.setContentsMargins(0, 0, 0, 0) # Remove margins
  701.         checkbox_full_layout.setSpacing(0) # Remove spacing
  702.         checkbox_full_layout.addStretch()
  703.         checkbox_full_layout.addWidget(checkbox_full)
  704.         checkbox_full_layout.addStretch()
  705.         checkbox_full_widget = QWidget()
  706.         checkbox_full_widget.setLayout(checkbox_full_layout)
  707.         self.table_widget.setCellWidget(row, 0, checkbox_full_widget)
  708.         checkbox_full_widget.setToolTip(f"Check to whitelist the full domain: {full_domain}")
  709.  
  710.         # Column 1: Full Domain (Text)
  711.         full_item = QTableWidgetItem(full_domain) # Display original case
  712.         full_item.setFlags(full_item.flags() & ~Qt.ItemIsUserCheckable & ~Qt.ItemIsEditable) # Read-only
  713.         # REMOVED: full_item.setTextFormat(Qt.RichText) # <-- This line caused the AttributeError, QTableWidgetItem doesn't have this method
  714.         full_item.setToolTip(f"Full blocked domain: {full_domain}")
  715.         self.table_widget.setItem(row, 1, full_item) # Set the item
  716.  
  717.  
  718.         # Column 2: Whitelist Base Domain (Checkbox)
  719.         checkbox_base = QCheckBox()
  720.         # Center the checkbox and remove spacing
  721.         checkbox_base_layout = QHBoxLayout()
  722.         checkbox_base_layout.setContentsMargins(0, 0, 0, 0) # Remove margins
  723.         checkbox_base_layout.setSpacing(0) # Remove spacing
  724.         checkbox_base_layout.addStretch()
  725.         checkbox_base_layout.addWidget(checkbox_base)
  726.         checkbox_base_layout.addStretch()
  727.         checkbox_base_widget = QWidget()
  728.         checkbox_base_widget.setLayout(checkbox_base_layout)
  729.         self.table_widget.setCellWidget(row, 2, checkbox_base_widget)
  730.         checkbox_base_widget.setToolTip(f"Check to whitelist the base domain: {base_domain}")
  731.  
  732.  
  733.         # Column 3: Base Domain (Text)
  734.         base_item = QTableWidgetItem(base_domain) # Display original/tldextract case
  735.         base_item.setFlags(base_item.flags() & ~Qt.ItemIsUserCheckable & ~Qt.ItemIsEditable) # Read-only
  736.         # REMOVED: base_item.setTextFormat(Qt.RichText) # <-- This line caused the AttributeError, QTableWidgetItem doesn't have this method
  737.         base_item.setToolTip(f"Base domain: {base_domain}")
  738.         self.table_widget.setItem(row, 3, base_item) # Set the item
  739.  
  740.  
  741.         # Connect signals for exclusive selection using lambda with captured variables
  742.         checkbox_full.stateChanged.connect(lambda state, cb_base=checkbox_base: self.handle_checkbox_state_change(checkbox_full, cb_base, state))
  743.         checkbox_base.stateChanged.connect(lambda state, cb_full=checkbox_full: self.handle_checkbox_state_change(checkbox_base, cb_full, state))
  744.  
  745.  
  746.         # Set size policy for the checkbox widget to help with layout
  747.         checkbox_full_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
  748.         checkbox_base_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
  749.  
  750.  
  751.     # Modified handle_checkbox_state_change to accept checkbox objects directly
  752.     def handle_checkbox_state_change(self, changed_checkbox, other_checkbox, state):
  753.         """Handles state changes for checkboxes to ensure exclusive selection per row."""
  754.         # Only deselect the other checkbox if the current one is being checked
  755.         if state == Qt.Checked:
  756.             # Ensure the other checkbox exists before trying to access it
  757.             if other_checkbox and other_checkbox.isChecked():
  758.                  other_checkbox.setChecked(Qt.Unchecked)
  759.  
  760.  
  761.     def save_selected_domains_from_table(self):
  762.         """Saves domains corresponding to checked checkboxes in the table."""
  763.         print("Saving selected domains from table...") # Debug print
  764.         domains_to_whitelist = set() # Use a set to store domains to whitelist (lowercase for uniqueness)
  765.         full_domains_for_feedback = set() # Keep track of original full domains for confirmation
  766.  
  767.         for row in range(self.table_widget.rowCount()):
  768.             # Get the checkboxes from the cell widgets
  769.             checkbox_full_widget = self.table_widget.cellWidget(row, 0)
  770.             checkbox_base_widget = self.table_widget.cellWidget(row, 2)
  771.  
  772.             domain_added_in_row = False # Flag to ensure only one domain is added per row from this row's checkboxes
  773.  
  774.             # Check the 'Whitelist Full' checkbox first
  775.             if checkbox_full_widget:
  776.                 checkbox_full = checkbox_full_widget.findChild(QCheckBox)
  777.                 if checkbox_full and checkbox_full.isChecked():
  778.                     full_domain_item = self.table_widget.item(row, 1) # Full domain is in column 1
  779.                     if full_domain_item:
  780.                        domain_text = full_domain_item.text().strip()
  781.                        if domain_text:
  782.                            domains_to_whitelist.add(domain_text.lower()) # Add lowercase to set for whitelisting
  783.                            full_domains_for_feedback.add(domain_text) # Add original case for feedback/confirmation
  784.                            domain_added_in_row = True # A domain was selected in this row
  785.                            print(f"Selected full domain for whitelisting: {domain_text}") # Debug print
  786.  
  787.  
  788.             # Only check the base checkbox if no domain was selected via the full checkbox in this row
  789.             if not domain_added_in_row and checkbox_base_widget:
  790.                 checkbox_base = checkbox_base_widget.findChild(QCheckBox)
  791.                 if checkbox_base and checkbox_base.isChecked():
  792.                     base_domain_item = self.table_widget.item(row, 3) # Base domain is in column 3
  793.                     if base_domain_item:
  794.                        domain_text = base_domain_item.text().strip()
  795.                        if domain_text:
  796.                             domains_to_whitelist.add(domain_text.lower()) # Add lowercase to set for whitelisting
  797.                             # Add the corresponding full domain for feedback (get from column 1)
  798.                             full_domains_for_feedback.add(self.table_widget.item(row, 1).text().strip()) # Use original full domain
  799.                             print(f"Selected base domain for whitelisting: {domain_text}") # Debug print
  800.  
  801.  
  802.         if not domains_to_whitelist:
  803.             print("No domains selected for whitelisting.") # Debug print
  804.             QMessageBox.information(self, "No Selection", "No domains were checked for whitelisting.")
  805.             return
  806.  
  807.         print(f"Attempting to whitelist {len(domains_to_whitelist)} unique domains (lowercase).") # Debug print
  808.         # Confirmation dialog
  809.         confirm_msg = f"Add the following {len(domains_to_whitelist)} domain(s) to '{os.path.basename(OUTPUT_FILE_PATH)}'?\n\n"
  810.         confirm_msg += "\n".join(sorted(list(domains_to_whitelist))) # Display lowercase sorted list of what will be added
  811.         # Optionally list the original full domains that led to these selections
  812.         # confirm_msg += "\n\n(Selected from blocked domains like: " + ", ".join(sorted(list(full_domains_for_feedback))[:5]) + "...)"
  813.  
  814.         reply = QMessageBox.question(self, 'Confirm Whitelist', confirm_msg,
  815.                                      QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
  816.  
  817.         if reply == QMessageBox.Yes:
  818.             print("User confirmed whitelisting. Adding domains...") # Debug print
  819.             added_count = 0
  820.             failed_count = 0
  821.             # Iterate through the lowercase set for unique domains to add
  822.             for domain_lower in sorted(list(domains_to_whitelist)):
  823.                  # Use the utility function for adding each domain
  824.                  # The utility function handles case-insensitivity and existence checks
  825.                  if add_domain_to_whitelist(domain_lower):
  826.                      added_count +=1
  827.                  else:
  828.                      failed_count += 1
  829.  
  830.             # Provide feedback
  831.             feedback_msg = f"Finished adding domains:\n- Added/Already Present: {added_count}\n- Failed: {failed_count}"
  832.             print(feedback_msg) # Debug print
  833.             QMessageBox.information(self, "Whitelist Update Complete", feedback_msg)
  834.  
  835.             # Uncheck rows after successful save that correspond to added domains
  836.             # Re-iterate through the table to find checked boxes for domains that were successfully processed
  837.             domains_processed_lower = domains_to_whitelist # Assuming all selected were attempted
  838.             for row in range(self.table_widget.rowCount()):
  839.                  checkbox_full_widget = self.table_widget.cellWidget(row, 0)
  840.                  checkbox_base_widget = self.table_widget.cellWidget(row, 2)
  841.  
  842.                  if checkbox_full_widget:
  843.                       checkbox_full = checkbox_full_widget.findChild(QCheckBox)
  844.                       if checkbox_full and checkbox_full.isChecked():
  845.                            full_domain_item = self.table_widget.item(row, 1)
  846.                            if full_domain_item and full_domain_item.text().strip().lower() in domains_processed_lower:
  847.                                 checkbox_full.setChecked(False) # Uncheck the box
  848.  
  849.                  if checkbox_base_widget:
  850.                       checkbox_base = checkbox_base_widget.findChild(QCheckBox)
  851.                       if checkbox_base and checkbox_base.isChecked():
  852.                            base_domain_item = self.table_widget.item(row, 3)
  853.                            if base_domain_item and base_domain_item.text().strip().lower() in domains_processed_lower:
  854.                                 checkbox_base.setChecked(False) # Uncheck the box
  855.  
  856.  
  857.             # Ask if user wants to restart dnscrypt-proxy now
  858.             if added_count > 0: # Only prompt restart if something was actually added/wasn't already there
  859.                 print("Domains added/present, prompting for service restart.") # Debug print
  860.                 restart_reply = QMessageBox.question(self, 'Restart Service?',
  861.                                                      "Domains added to whitelist.\nRestart dnscrypt-proxy service now for changes to take effect?",
  862.                                                      QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
  863.                 if restart_reply == QMessageBox.Yes:
  864.                     print("User chose to restart service.") # Debug print
  865.                     self.restart_dnscrypt_proxy()
  866.                 else:
  867.                     print("User chose NOT to restart service.") # Debug print
  868.             else:
  869.                  print("No domains were actually added/new. No restart prompt.") # Debug print
  870.  
  871.  
  872.     def clear_input_file(self):
  873.         """Clears the blocked-names.log file."""
  874.         print(f"Attempting to clear log file: {TEXT_FILE_PATH}") # Debug print
  875.         confirm = QMessageBox.question(self, "Confirm Clear",
  876.                                        f"Are you sure you want to permanently clear the log file:\n{os.path.basename(TEXT_FILE_PATH)}?",
  877.                                        QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
  878.         if confirm == QMessageBox.Yes:
  879.             print("User confirmed clearing log file.") # Debug print
  880.             try:
  881.                 # Stop the watcher thread gracefully before modifying the file it monitors
  882.                 # Access the file_watcher via the parent TrayIcon object
  883.                 tray_icon_parent = self.parent() # This should be the TrayIcon instance
  884.                 if isinstance(tray_icon_parent, TrayIcon) and tray_icon_parent.file_watcher:
  885.                      print("Stopping file watcher before clearing log.") # Debug print
  886.                      tray_icon_parent.file_watcher.stop_watching()
  887.                      # Give the thread a moment to ideally finish its current loop and check _stop
  888.                      # The wait() call can block the GUI, so relying on the sleep interval is safer.
  889.                      # time.sleep(0.2) # Optional small delay
  890.  
  891.  
  892.                 # Open in write mode and immediately close to truncate the file
  893.                 with open(TEXT_FILE_PATH, 'w', encoding='utf-8') as f:
  894.                      f.write("") # Explicitly write empty string
  895.                 print(f"{TEXT_FILE_PATH} has been cleared (truncated).") # Debug print
  896.  
  897.                 self.table_widget.setRowCount(0) # Clear table UI
  898.                 self.domains_set.clear()         # Clear dialog's internal set
  899.                 print("Table view and internal domain set cleared.") # Debug print
  900.  
  901.                 # Also clear the TrayIcon's seen_domains set when the log is cleared,
  902.                 # so future entries will trigger notifications again.
  903.                 if isinstance(tray_icon_parent, TrayIcon):
  904.                     print("Clearing TrayIcon's seen_domains set.") # Debug print
  905.                     tray_icon_parent.seen_domains.clear()
  906.                 else:
  907.                     print("Warning: Could not access TrayIcon to clear seen_domains.") # Debug print
  908.  
  909.  
  910.                 QMessageBox.information(self, "Cleared", f"{os.path.basename(TEXT_FILE_PATH)} has been cleared.\nTable view reset.")
  911.  
  912.                 # Restart the watcher thread after clearing so it monitors the (now empty) file
  913.                 # Access the file_watcher via the parent TrayIcon object again after clearing
  914.                 if isinstance(tray_icon_parent, TrayIcon) and tray_icon_parent.file_watcher:
  915.                      print("Restarting file watcher after clearing log.") # Debug print
  916.                      # The watcher should start again, detect the file exists (though empty),
  917.                      # and set its position to 0.
  918.                      tray_icon_parent.file_watcher.start_watching()
  919.                 else:
  920.                     print("Warning: Could not access file watcher to restart after clear.") # Debug print
  921.  
  922.  
  923.                 # Optionally restart service if configured
  924.                 # This call needs to happen *after* the watcher has potentially restarted,
  925.                 # and it should only happen if the setting is enabled.
  926.                 if self.checkbox_restart_on_clear.isChecked():
  927.                     print("Auto-restart on clear is enabled. Attempting service restart.") # Debug print
  928.                     # Call the restart method on self (the dialog instance) or its parent (TrayIcon)
  929.                     # Calling on self is fine as it's a dialog method
  930.                     self.restart_dnscrypt_proxy()
  931.                 else:
  932.                     print("Auto-restart on clear is disabled.") # Debug print
  933.  
  934.             except Exception as e:
  935.                print(f"ERROR clearing file '{TEXT_FILE_PATH}': {e}") # Debug print
  936.                QMessageBox.critical(self, "Error", f"Could not clear file '{os.path.basename(TEXT_FILE_PATH)}':\n{e}")
  937.         else:
  938.             print("User cancelled log file clear.") # Debug print
  939.  
  940.  
  941.     def restart_dnscrypt_proxy(self):
  942.         """Attempts to restart the dnscrypt-proxy service using helper script."""
  943.         print(f"Attempting to restart service using helper script: {HELPER_SCRIPT_PATH}") # Debug print
  944.  
  945.         if not os.path.exists(HELPER_SCRIPT_PATH):
  946.             print(f"ERROR: Helper script missing: {HELPER_SCRIPT_PATH}") # Debug print
  947.             QMessageBox.critical(self, "Helper Script Missing",
  948.                                  f"Cannot restart service: The required helper script '{os.path.basename(HELPER_SCRIPT_PATH)}' was not found in the application directory:\n{BASE_DIR}")
  949.             return
  950.  
  951.         # Command to run python helper script elevated via PowerShell
  952.         # Use the current executable path, or find pythonw if available and not frozen
  953.         python_executable = sys.executable
  954.         # In a frozen app, sys.executable is the exe. In a script, it's python/pythonw.
  955.         # Stick to sys.executable for simplicity, let the helper script be executed by it.
  956.  
  957.         # Add quotes around paths if they contain spaces
  958.         quoted_python_executable = f'"{python_executable}"' if ' ' in python_executable else python_executable
  959.  
  960.         # FIX: Ensure the conditional assignment for quoted_helper_path is correct
  961.         # It should be 'else HELPER_SCRIPT_PATH' instead of 'else quoted_helper_path'
  962.         # This line IS correct and should not cause a NameError if running this code.
  963.         quoted_helper_path = f'"{HELPER_SCRIPT_PATH}"' if ' ' in HELPER_SCRIPT_PATH else HELPER_SCRIPT_PATH
  964.  
  965.  
  966.         # Use -WindowStyle Hidden to try and hide the PowerShell window
  967.         # Use -Verb runAs to request administrator privileges
  968.         command = [
  969.            "powershell",
  970.            "-WindowStyle", "Hidden",
  971.            "-Command",
  972.            f"Start-Process {quoted_python_executable} -ArgumentList '{quoted_helper_path}' -Verb runAs"
  973.         ]
  974.  
  975.         try:
  976.            print(f"Issuing restart command: {' '.join(command)}") # Debug print
  977.            # Use shell=True with caution, necessary for Start-Process in this context
  978.            # subprocess.Popen allows the command to run in the background
  979.            subprocess.Popen(command, shell=True)
  980.  
  981.            print("Restart command issued via Start-Process.") # Debug print
  982.            QMessageBox.information(self, "Service Restart", "Attempting to restart dnscrypt-proxy service with administrator privileges (check for UAC prompt).\n\nNote: Success depends on the helper script and service configuration.")
  983.         except Exception as e:
  984.            print(f"ERROR issuing restart command: {e}") # Debug print
  985.            QMessageBox.critical(self, "Error", f"Failed to issue restart command:\n\n{e}")
  986.  
  987.  
  988.     def save_settings(self):
  989.         """Saves general checkbox states to config file."""
  990.         print("Saving general settings...") # Debug print
  991.         ConfigManager.set("notifications_enabled", self.checkbox_notify.isChecked())
  992.         ConfigManager.set("run_on_startup", self.checkbox_startup.isChecked())
  993.         ConfigManager.set("restart_on_clear", self.checkbox_restart_on_clear.isChecked())
  994.         print("General settings saved.") # Debug print
  995.  
  996.  
  997.     def save_timeout_setting(self):
  998.         """Saves the messagebox timeout setting to config file."""
  999.         ConfigManager.set("messagebox_timeout_seconds", self.timeout_spinbox.value())
  1000.         print(f"Timeout setting saved: {self.timeout_spinbox.value()} seconds.") # Debug print
  1001.  
  1002.  
  1003.     def save_default_action_setting(self):
  1004.         """Saves the default prompt action setting to config file."""
  1005.         ConfigManager.set("default_prompt_action", self.default_action_combo.currentText())
  1006.         print(f"Default action setting saved: {self.default_action_combo.currentText()}.") # Debug print
  1007.  
  1008.  
  1009.     def toggle_startup(self):
  1010.         """Handles the 'Run on startup' checkbox and registry modification."""
  1011.         print(f"Toggling startup setting to: {self.checkbox_startup.isChecked()}") # Debug print
  1012.         self.save_settings() # Save the state first
  1013.         run_at_startup = self.checkbox_startup.isChecked()
  1014.         app_name_reg = APP_NAME # Use APP_NAME for the registry key name
  1015.  
  1016.         # Get the path to the executable or script
  1017.         # Use BASE_DIR which is already determined based on frozen status
  1018.         app_path = os.path.join(BASE_DIR, os.path.basename(sys.executable if getattr(sys, 'frozen', False) else __file__))
  1019.  
  1020.  
  1021.         try:
  1022.             # Access the Run key in the current user's registry hive
  1023.             key = winreg.OpenKey(winreg.HKEY_CURRENT_USER,
  1024.                                  r"Software\Microsoft\Windows\CurrentVersion\Run",
  1025.                                  0, winreg.KEY_ALL_ACCESS)
  1026.  
  1027.             if run_at_startup:
  1028.                 # Set the registry value
  1029.                 # Ensure the path is quoted in case it contains spaces
  1030.                 quoted_app_path = f'"{app_path}"'
  1031.                 winreg.SetValueEx(key, app_name_reg, 0, winreg.REG_SZ, quoted_app_path)
  1032.                 # FIX: Escape backslashes in print statement
  1033.                 print(f"Set registry key HKCU\\...\\Run\\{app_name_reg} to {quoted_app_path}") # Debug print
  1034.                 QMessageBox.information(self, "Startup Setting", "Application will run on system startup.")
  1035.             else:
  1036.                 # Delete the registry value if it exists
  1037.                 try:
  1038.                     winreg.DeleteValue(key, app_name_reg)
  1039.                     # FIX: Escape backslashes in print statement
  1040.                     print(f"Deleted registry key HKCU\\...\\Run\\{app_name_reg}") # Debug print
  1041.                     QMessageBox.information(self, "Startup Setting", "Application will no longer run on system startup.")
  1042.                 except FileNotFoundError:
  1043.                     # Key or value might not exist, which is fine
  1044.                     # FIX: Escape backslashes in print statement
  1045.                     print(f"Registry key HKCU\\...\\Run\\{app_name_reg} not found, no action needed.") # Debug print
  1046.                     QMessageBox.information(self, "Startup Setting", "Application was not set to run on startup.")
  1047.  
  1048.             winreg.CloseKey(key)
  1049.  
  1050.         except Exception as e:
  1051.             print(f"ERROR modifying Windows registry for startup: {e}") # Debug print
  1052.             QMessageBox.critical(self, "Startup Setting Error",
  1053.                                  f"Could not modify Windows registry for startup setting:\n{e}\n\nTry running the application with administrator privileges if the issue persists.")
  1054.             # Revert checkbox state in UI if registry modification failed
  1055.             self.checkbox_startup.setChecked(not run_at_startup)
  1056.             # Note: The config value remains the *intended* state based on the checkbox click.
  1057.  
  1058.  
  1059. # --- System Tray Icon ---
  1060. class TrayIcon(QSystemTrayIcon):
  1061.     def __init__(self, icon, parent=None):
  1062.         # parent here is the QApplication instance passed during creation
  1063.         # The QSystemTrayIcon constructor accepts a QObject parent, so passing 'parent' (QApplication) here is fine.
  1064.         super().__init__(icon, parent)
  1065.         self.app = QApplication.instance() # Get the QApplication instance
  1066.         self.dialog = None # Reference to the main dialog
  1067.  
  1068.         # FIX: Initialize _current_msg_box here! This was the cause of the AttributeError.
  1069.         self._current_msg_box = None # <--- ADDED THIS LINE BACK IN
  1070.  
  1071.         # ADDED: Set to track domains seen for notifications during this session (lowercase)
  1072.         self.seen_domains = set()
  1073.  
  1074.         # --- File Watcher ---
  1075.         self.file_watcher = FileWatcher(TEXT_FILE_PATH)
  1076.         # Connect the signal from the worker object (watcher) to the slot in the main thread object (self)
  1077.         self.file_watcher.new_domain_signal.connect(self.handle_new_domain)
  1078.         self.file_watcher.start_watching() # Start the watcher QThread
  1079.  
  1080.  
  1081.         # --- Menu ---
  1082.         # FIX: The parent of a QMenu should be a QWidget or None. QApplication is not a QWidget.
  1083.         # Set parent to None for a top-level context menu associated with the tray icon.
  1084.         self.menu = QMenu(None) # <--- CORRECTED LINE HERE
  1085.  
  1086.         self.show_dialog_action = QAction("Show Blocked Domains", self)
  1087.         # We connect triggered to a method in THIS class (TrayIcon)
  1088.         self.show_dialog_action.triggered.connect(self.show_dialog)
  1089.         self.menu.addAction(self.show_dialog_action)
  1090.  
  1091.         self.exit_action = QAction("Exit", self)
  1092.         self.exit_action.triggered.connect(self.exit_app)
  1093.         self.menu.addAction(self.exit_action)
  1094.  
  1095.         self.setContextMenu(self.menu)
  1096.         self.setToolTip(APP_NAME)
  1097.  
  1098.         # FIX: Connect the activated signal to a custom handler
  1099.         self.activated.connect(self._handle_tray_activation) # <--- Add this line
  1100.  
  1101.  
  1102.         # --- Message Box Timers ---
  1103.         self._msg_box_close_timer = QTimer(self) # Timer for closing the box
  1104.         self._msg_box_close_timer.setSingleShot(True) # It should fire only once
  1105.         self._msg_box_close_timer.timeout.connect(self._handle_msg_box_timeout)
  1106.  
  1107.         self._msg_box_countdown_timer = QTimer(self) # Timer for updating countdown label
  1108.         self._msg_box_countdown_timer.setSingleShot(False) # It should fire repeatedly
  1109.         self._msg_box_countdown_timer.timeout.connect(self._update_msg_box_timer_label)
  1110.  
  1111.         # Add attributes to store the timer label and countdown value
  1112.         self._msg_box_timer_label = None
  1113.         self._countdown_value = 0 # Keep countdown value
  1114.  
  1115.  
  1116.     def show_dialog(self):
  1117.         """Creates or shows the main dialog window."""
  1118.         print("Tray icon clicked: Showing dialog.") # Debug print
  1119.         if self.dialog is None:
  1120.             print("Dialog is None, creating new dialog instance.") # Debug print
  1121.             # FIX: QDialog parent must be a QWidget or None. TrayIcon is not a QWidget.
  1122.             # Pass None as the parent.
  1123.             self.dialog = DomainListDialog(None) # <--- CORRECTED LINE HERE
  1124.         else:
  1125.             print("Dialog exists, showing existing instance.") # Debug print
  1126.             self.dialog.populate_list() # Refresh the list when showing
  1127.         self.dialog.show()
  1128.         self.dialog.activateWindow() # Bring dialog to front
  1129.         self.dialog.raise_()
  1130.  
  1131.  
  1132.     # ADDED: Handler for tray icon activation
  1133.     def _handle_tray_activation(self, reason):
  1134.         """Handles activation signals from the tray icon."""
  1135.         # print(f"Tray icon activated. Reason: {reason}") # Debug print
  1136.  
  1137.         # QSystemTrayIcon.Trigger corresponds to the primary click (usually left click)
  1138.         # QSystemTrayIcon.Context corresponds to the secondary click (usually right click)
  1139.         # QSystemTrayIcon.DoubleClick corresponds to a double click
  1140.         if reason == QSystemTrayIcon.ActivationReason.Trigger:
  1141.             print("Tray icon activated by Trigger (likely left click). Showing dialog.") # Debug print
  1142.             self.show_dialog()
  1143.         # You could optionally add handling for DoubleClick here if you want different behavior
  1144.         # elif reason == QSystemTrayIcon.ActivationReason.DoubleClick:
  1145.         #     print("Tray icon double-clicked.") # Debug print
  1146.         #     self.show_dialog() # Example: double-click also shows dialog
  1147.  
  1148.  
  1149.     def exit_app(self):
  1150.         """Cleans up and exits the application."""
  1151.         print("Exit action triggered. Stopping watcher and quitting app.") # Debug print
  1152.         # Stop the file watcher thread gracefully
  1153.         if self.file_watcher:
  1154.             self.file_watcher.stop_watching()
  1155.             # Give the thread a moment to finish its current task/sleep
  1156.             # It's generally okay to not wait() here if the main thread is exiting anyway,
  1157.             # but explicitly stopping is good practice.
  1158.             # self.file_watcher._thread.wait(2000) # Avoid blocking main thread excessively
  1159.  
  1160.         self.app.quit()
  1161.  
  1162.  
  1163.     def handle_new_domain(self, domain):
  1164.         """Receives new domain signals from the file watcher (in the main thread)."""
  1165.         print(f"handle_new_domain received signal for: {domain} (Main Thread)") # <-- Debug print
  1166.         # This slot is connected via Qt.QueuedConnection automatically as it's cross-thread
  1167.  
  1168.         # Ensure notifications are enabled via config
  1169.         if not ConfigManager.get("notifications_enabled", True):
  1170.             print("Notifications disabled in config. Skipping notification.") # <-- Debug print
  1171.             return # Exit early if notifications are off
  1172.  
  1173.         # Check if the domain (lowercase) is already seen during this session for notifications
  1174.         domain_lower = domain.strip().lower() # Ensure lowercase and no extra whitespace
  1175.  
  1176.         # Only proceed if the domain is not empty and is new to our session tracking
  1177.         if domain_lower and domain_lower not in self.seen_domains:
  1178.             print(f"Domain '{domain}' (as '{domain_lower}') is new to the session ({len(self.seen_domains)} items).") # <-- Debug print
  1179.  
  1180.             # Add the domain (lowercase) to the session-wide seen_domains set immediately
  1181.             self.seen_domains.add(domain_lower)
  1182.  
  1183.             # If the dialog is currently open and visible, add the domain to its table
  1184.             if self.dialog is not None and self.dialog.isVisible():
  1185.                 print(f"Dialog is open. Attempting to add '{domain}' to dialog table.") # Debug print
  1186.                 # Pass the original case domain to the dialog method
  1187.                 self.dialog.add_domain_row(domain.strip()) # The dialog's add_domain_row checks its own set
  1188.  
  1189.             # Show the message box notification (independent of dialog visibility)
  1190.             self.show_domain_notification(domain.strip()) # Pass the stripped original case domain for display
  1191.  
  1192.         # No print needed here for already seen domains, as it's normal.
  1193.  
  1194.  
  1195.     def show_domain_notification(self, full_domain):
  1196.         """Shows a QMessagebox prompting the user to whitelist a domain."""
  1197.         print(f"Attempting to show notification message box for: {full_domain}") # <-- Debug print
  1198.  
  1199.         # Prevent multiple message boxes stacking up
  1200.         # Check if the current message box exists AND is visible
  1201.         # This line caused the AttributeError because _current_msg_box wasn't initialized
  1202.         if self._current_msg_box is not None and self._current_msg_box.isVisible():
  1203.             print("Notification box already visible. Skipping new notification.") # Debug print
  1204.             # Optionally update the existing one or queue? Skipping for now.
  1205.             return
  1206.  
  1207.         # Determine parent for the message box. Using None is safest if dialog might be closed.
  1208.         parent_widget = None # Use None as parent for the message box
  1209.  
  1210.  
  1211.         self._current_msg_box = QMessageBox(parent_widget)
  1212.         self._current_msg_box.setWindowTitle(f"{APP_NAME} - New Domain Blocked") # Use APP_NAME
  1213.         self._current_msg_box.setIcon(QMessageBox.Question)
  1214.  
  1215.         # Set the message text with bolding and HTML line breaks for blank lines
  1216.         # REPLACED \n with <br> for blank lines when using Qt.RichText
  1217.         self._current_msg_box.setText(f"Blocked domain detected:<br><br><br>  <b>{full_domain}</b><br><br><br>Whitelist this domain?") # <--- Replaced \n\n\n with <br><br><br>
  1218.         self._current_msg_box.setTextFormat(Qt.RichText) # <-- Keep RichText for bolding
  1219.  
  1220.         # Define buttons and their roles
  1221.         btn_whitelist_full = self._current_msg_box.addButton("Whitelist Full", QMessageBox.AcceptRole)
  1222.         btn_whitelist_base = self._current_msg_box.addButton("Whitelist Base", QMessageBox.AcceptRole)
  1223.         btn_ignore = self._current_msg_box.addButton("Ignore", QMessageBox.RejectRole)
  1224.         # Set Ignore as the default button if the user presses Enter
  1225.         self._current_msg_box.setDefaultButton(btn_ignore)
  1226.  
  1227.         # Map buttons to actions (Use the same strings as the button text and default action config)
  1228.         button_map = {
  1229.             btn_whitelist_full: "Whitelist Full",
  1230.             btn_whitelist_base: "Whitelist Base",
  1231.             btn_ignore: "Ignore"
  1232.         }
  1233.  
  1234.         # Get timeout from config
  1235.         timeout_seconds = ConfigManager.get("messagebox_timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
  1236.         print(f"Message box timeout set to {timeout_seconds} seconds.") # Debug print
  1237.  
  1238.         # Initialize countdown value and timer label
  1239.         self._countdown_value = timeout_seconds # <--- Initialize countdown value
  1240.         # Create timer label, parented to the message box
  1241.         self._msg_box_timer_label = QLabel(f"Closing in {self._countdown_value} seconds...", self._current_msg_box) # <--- Create timer label
  1242.         self._msg_box_timer_label.setAlignment(Qt.AlignCenter)
  1243.         self._msg_box_timer_label.setTextFormat(Qt.RichText) # Enable rich text for label
  1244.  
  1245.         # Get the layout of the message box and add the label
  1246.         # QMessageBox uses a QGridLayout internally. Buttons are added first, then the standard text/icon area.
  1247.         # The layout structure can vary slightly by style/version, but adding to the last row should work.
  1248.         layout = self._current_msg_box.layout()
  1249.         # Find the row containing the buttons. Add label below buttons.
  1250.         # A safer way might be to add the label into the standard QMessageBox content layout area if possible,
  1251.         # but adding it to a new row at the bottom of the grid layout is more direct with the current structure.
  1252.         current_row = layout.rowCount() # Get the row index *after* buttons and main content have been added
  1253.         layout.addWidget(self._msg_box_timer_label, current_row, 0, 1, layout.columnCount(), Qt.AlignCenter) # <--- Add the label to the layout
  1254.  
  1255.         # Start the timers
  1256.         # Stop any previous timers just in case
  1257.         if self._msg_box_close_timer.isActive():
  1258.             self._msg_box_close_timer.stop()
  1259.         if self._msg_box_countdown_timer.isActive():
  1260.             self._msg_box_countdown_timer.stop()
  1261.  
  1262.         # Start the single-shot timer for closing
  1263.         self._msg_box_close_timer.start(timeout_seconds * 1000) # Start close timer with full duration
  1264.  
  1265.         # Start the repeating timer for the countdown label
  1266.         self._msg_box_countdown_timer.start(1000) # Start countdown timer (ticks every 1s)
  1267.  
  1268.         print(f"Message box timers started. Close in {timeout_seconds}s, countdown ticks every 1s.") # Debug print
  1269.  
  1270.  
  1271.         print(f"Showing message box for {full_domain}.") # Debug print
  1272.  
  1273.         # Connect finished signal to handle button clicks and timeout
  1274.         # The finished signal is emitted when the box is closed, either by button click or implicitly (e.g., timeout causing accept/reject)
  1275.         self._current_msg_box.finished.connect(lambda result: self._handle_msg_box_finished(result, full_domain, button_map))
  1276.  
  1277.         # Show the message box modally - blocks the main thread until the user responds or it times out.
  1278.         # Be aware that blocking the main thread prevents other GUI updates temporarily.
  1279.         # For a notification, modal is usually desired.
  1280.         self._current_msg_box.exec_()
  1281.  
  1282.         # The finished signal handler has already run when exec_() returns.
  1283.         # Explicitly stop timers again just in case (should be stopped in finished handler too)
  1284.         if self._msg_box_close_timer.isActive():
  1285.              self._msg_box_close_timer.stop()
  1286.         if self._msg_box_countdown_timer.isActive():
  1287.              self._msg_box_countdown_timer.stop()
  1288.         # print("Message box timers stopped after exec_ returned.") # Optional debug print
  1289.  
  1290.  
  1291.         # Clear the reference and temporary attributes after it's closed
  1292.         self._current_msg_box = None
  1293.         self._msg_box_timer_label = None
  1294.         self._countdown_value = 0 # Reset countdown value
  1295.  
  1296.  
  1297.     # ADDED: Slot to update the countdown timer label
  1298.     def _update_msg_box_timer_label(self):
  1299.         """Updates the countdown timer label in the message box."""
  1300.         # print(f"Countdown timer tick. Value before decrement: {self._countdown_value}") # Optional verbose debug
  1301.  
  1302.         # Decrement the countdown value
  1303.         self._countdown_value -= 1
  1304.  
  1305.         # Update the label text if the message box is still open and label exists
  1306.         if self._current_msg_box and self._msg_box_timer_label:
  1307.              if self._countdown_value > 0:
  1308.                   self._msg_box_timer_label.setText(f"Closing in {self._countdown_value} seconds...")
  1309.              elif self._countdown_value == 0:
  1310.                   self._msg_box_timer_label.setText("Closing now...")
  1311.              # No need for else for < 0, label stays at "Closing now..."
  1312.  
  1313.         # print(f"Countdown timer tick. Value after decrement: {self._countdown_value}") # Optional verbose debug
  1314.  
  1315.  
  1316.     def _handle_msg_box_timeout(self):
  1317.         """Handles the message box timeout (triggered by single-shot timer)."""
  1318.         print("Message box CLOSE timer timeout signal received.") # Debug print
  1319.         if self._current_msg_box and self._current_msg_box.isVisible():
  1320.             print("Closing message box due to timeout.") # Debug print
  1321.             # Stop the countdown timer when the close timer fires
  1322.             if self._msg_box_countdown_timer.isActive():
  1323.                  self._msg_box_countdown_timer.stop()
  1324.                  print("Stopped countdown timer in close timeout handler.") # Debug print
  1325.  
  1326.             # Closing the box will emit the finished signal with Rejected (0) as the result,
  1327.             # which our _handle_msg_box_finished slot is connected to.
  1328.             # It will then correctly apply the default action.
  1329.             self._current_msg_box.done(QMessageBox.Rejected)
  1330.         else:
  1331.              print("CLOSE timer timed out but message box was not visible/valid.") # Debug print
  1332.  
  1333.  
  1334.     def _handle_msg_box_finished(self, result, full_domain, button_map):
  1335.         """Handles the result when the message box is closed (by button or timeout).
  1336.           This slot is called after exec_() returns."""
  1337.         print(f"_handle_msg_box_finished handler called for {full_domain}. Result: {result}") # Debug print
  1338.  
  1339.         # Ensure BOTH timers are stopped when the box finishes, regardless of how it finished
  1340.         if self._msg_box_close_timer.isActive():
  1341.             self._msg_box_close_timer.stop()
  1342.             print("Close timer stopped in finished handler.") # Debug print
  1343.         if self._msg_box_countdown_timer.isActive():
  1344.             self._msg_box_countdown_timer.stop()
  1345.             print("Countdown timer stopped in finished handler.") # Debug print
  1346.  
  1347.  
  1348.         # Determine which button was clicked or if it timed out
  1349.         clicked_button = self._current_msg_box.clickedButton() # Get the button object that was clicked
  1350.  
  1351.         action = "Ignore" # Default action if button isn't in map or timeout action isn't determined
  1352.         if clicked_button in button_map:
  1353.              action = button_map[clicked_button]
  1354.              print(f"User clicked button: {action}") # Debug print
  1355.         else:
  1356.              # If clicked_button is None, it implies timeout or other non-button close.
  1357.              # Check the result. If it's Rejected (0), it was likely due to the timeout slot closing it.
  1358.              if result == QMessageBox.Rejected:
  1359.                  action = ConfigManager.get("default_prompt_action", DEFAULT_PROMPT_ACTION)
  1360.                  print(f"Message box finished without button click (result Rejected). Applying default action: {action}") # Debug print
  1361.              else:
  1362.                  # Fallback for other unexpected closes (e.g., Esc key if not handled by default button)
  1363.                  print(f"Message box finished unexpectedly with result {result}, clicked_button is None. Cannot determine action precisely, defaulting to Ignore.") # Debug print
  1364.                  action = "Ignore" # Default if determination fails
  1365.  
  1366.  
  1367.         print(f"Handling message box action '{action}' for domain '{full_domain}'.") # Debug print
  1368.  
  1369.         if action == "Whitelist Full":
  1370.             print(f"Whitelisting full domain: {full_domain}") # Debug print
  1371.             # The utility function add_domain_to_whitelist expects lowercase for storing/checking
  1372.             add_domain_to_whitelist(full_domain.strip()) # Pass stripped original case, utility converts to lower
  1373.             # Optionally restart service here or prompt
  1374.  
  1375.         elif action == "Whitelist Base":
  1376.             print(f"Whitelisting base domain for: {full_domain}") # Debug print
  1377.             base_domain_to_add = full_domain.strip() # Default to full domain (stripped)
  1378.             try:
  1379.                 base = tldextract.extract(full_domain.strip())
  1380.                 # Ensure both domain and suffix exist for a valid base domain
  1381.                 if base.domain and base.suffix:
  1382.                      base_domain_to_add = f"{base.domain}.{base.suffix}"
  1383.                 print(f"Extracted base domain: {base_domain_to_add}") # Debug print
  1384.             except Exception as e:
  1385.                 print(f"ERROR extracting base domain for '{full_domain}': {e}. Using full domain.") # Debug print
  1386.                 # QMessageBox.warning(self.dialog, "Error Whitelisting Base", f"Could not extract base domain for '{full_domain}':\n{e}") # Avoid message boxes from this handler
  1387.                 base_domain_to_add = full_domain.strip() # Fallback to full domain (stripped) if extraction failed
  1388.  
  1389.             # The utility function add_domain_to_whitelist expects lowercase for storing/checking
  1390.             add_domain_to_whitelist(base_domain_to_add) # Pass determined base domain, utility converts to lower
  1391.             # Optionally restart service here or prompt
  1392.  
  1393.  
  1394.         elif action == "Ignore":
  1395.             print(f"Ignoring domain: {full_domain}") # Debug print
  1396.             pass # Do nothing
  1397.  
  1398.         else:
  1399.             print(f"Unknown action '{action}'. Ignoring domain: {full_domain}") # Debug print
  1400.             pass # Unknown action, treat as ignore
  1401.  
  1402.         # The finished signal is emitted when exec_() returns, so the box is already closed.
  1403.  
  1404.  
  1405. # --- Main Application Entry Point ---
  1406. if __name__ == '__main__':
  1407.     print("Application starting.") # Debug print
  1408.     ConfigManager.load() # Load configuration at startup
  1409.  
  1410.     app = QApplication(sys.argv)
  1411.     app.setQuitOnLastWindowClosed(False) # Don't exit when the dialog is closed
  1412.  
  1413.     # Ensure the necessary directories exist
  1414.     for d in [BASE_DIR, os.path.dirname(TEXT_FILE_PATH), os.path.dirname(OUTPUT_FILE_PATH), os.path.dirname(CONFIG_FILE_PATH)]:
  1415.          # Check if d is not empty string before trying to make dir (e.g. if path is just "file.log")
  1416.          if d and not os.path.exists(d):
  1417.               try:
  1418.                    os.makedirs(d, exist_ok=True) # exist_ok=True prevents error if dir already exists
  1419.                    print(f"Ensured directory exists: {d}") # Debug print
  1420.               except Exception as e:
  1421.                    print(f"Warning: Could not create directory {d}: {e}") # Debug print
  1422.  
  1423.  
  1424.     # Load the icon (make sure 'icon.png' is in the BASE_DIR)
  1425.     icon_path = os.path.join(BASE_DIR, "icon.png")
  1426.     if os.path.exists(icon_path):
  1427.          app_icon = QIcon(icon_path)
  1428.          print(f"Loaded icon from: {icon_path}") # Debug print
  1429.     else:
  1430.          app_icon = QIcon() # Use a default blank icon if not found
  1431.          print(f"Warning: Icon file not found at: {icon_path}. Using default icon.") # Debug print
  1432.  
  1433.  
  1434.     # Create and show the tray icon
  1435.     # Pass the app instance as the parent (QSystemTrayIcon accepts QObject parent)
  1436.     tray_icon = TrayIcon(app_icon, app)
  1437.     tray_icon.show()
  1438.     print("Tray icon created and shown.") # Debug print
  1439.  
  1440.     # Set the application icon (optional, might show in taskbar/alt-tab)
  1441.     app.setWindowIcon(app_icon)
  1442.  
  1443.  
  1444.     # Start the Qt event loop
  1445.     print("Starting Qt event loop.") # Debug print
  1446.     # app.exec() is the old name, exec_() is the modern name to avoid keyword conflict in Python 3
  1447.     exit_code = app.exec_()
  1448.     print(f"Qt event loop finished with exit code: {exit_code}") # Debug print
  1449.  
  1450.     # This code is typically not reached after sys.exit()
  1451.     print("Application finished.") # Debug print
  1452.     sys.exit(exit_code) # Ensure proper exit code propagates
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement