From 244a913d962b64edaa28fe96873df0847c6086c2 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sun, 3 May 2026 22:06:23 +0545 Subject: [PATCH] feat(src/services): add config persistence, reload handling and notification pruning on config change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ability to persist current configuration to disk and trigger reloads, expose set() and save() methods, monitor the config directory for changes, and adjust notification history based on config modifications. - New Features - Implement `save()` method to write config to file and trigger reload - Add `set()` method for in‑memory config updates - Extend file monitoring to include parent directory - Rename `get_all_config` to `get_config_all` - Enhancements - Refine reload logic to correctly capture previous state via `_last_notified_config` - Trigger reloads on additional file‑monitor events (CREATED, DELETED) - Prune cached notifications according to config changes (`notification_ignored_apps`, `notification_limited_apps_history`) - Bug Fixes - Resolve race condition where reload‑pending flag could be left set - Ensure callbacks are notified on every reload request - Fix stale comparison of config values in `has_changed` --- src/services/config.py | 45 +++++++++++++++++------ src/services/custom_notification.py | 56 ++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/services/config.py b/src/services/config.py index 653b189..74f8d90 100644 --- a/src/services/config.py +++ b/src/services/config.py @@ -40,6 +40,7 @@ class ConfigService: self.RELOAD_DELAY_MS: int = 100 self._load_config() + self._last_notified_config = self._config.copy() self._setup_monitors() def get(self, key: str, default: Any = None) -> Any: @@ -51,6 +52,24 @@ class ConfigService: def has_changed(self, key: str, old_config: Dict[str, Any]) -> bool: return self._config.get(key) != old_config.get(key) + def set(self, key: str, value: Any) -> None: + """Set a config value (local state only).""" + self._config[key] = value + + def save(self) -> bool: + """Persist the current config state to disk and trigger reloads.""" + try: + os.makedirs(os.path.dirname(self._config_file), exist_ok=True) + with open(self._config_file, "w") as f: + json.dump(self._config, f, indent=4) + + # Manually trigger a reload to notify listeners immediately + GLib.idle_add(self._reload_config) + return True + except Exception as e: + logger.error(f"[ConfigService] Failed to save config: {e}") + return False + def register_reload_callback( self, callback: Callable[[Dict[str, Any], Dict[str, Any]], None] ) -> None: @@ -97,7 +116,7 @@ class ConfigService: self._config = {} def _setup_monitors(self) -> None: - files_to_watch = [self._config_file] + files_to_watch = [self._config_file, os.path.dirname(self._config_file)] for file_path in files_to_watch: if not os.path.exists(file_path): continue @@ -110,26 +129,32 @@ class ConfigService: logger.error(f"[ConfigService] Failed to monitor {file_path}: {e}") def _on_file_changed(self, monitor, file, other_file, event_type, file_path: str): - if ( - event_type == Gio.FileMonitorEvent.CHANGES_DONE_HINT - and not self._reload_pending - ): + # Trigger on various change events to be more robust across editors/OSs + valid_events = [ + Gio.FileMonitorEvent.CHANGES_DONE_HINT, + Gio.FileMonitorEvent.CHANGED, + Gio.FileMonitorEvent.CREATED, + Gio.FileMonitorEvent.DELETED, + ] + if event_type in valid_events and not self._reload_pending: self._reload_pending = True GLib.timeout_add(self.RELOAD_DELAY_MS, self._reload_config) def _reload_config(self) -> bool: try: - old_config = self._config.copy() + self._reload_pending = False + old_config = self._last_notified_config.copy() self._load_config() + # Always notify listeners on reload request to be safe for callback in list(self._reload_callbacks): try: callback(self._config, old_config) except Exception as e: logger.error(f"[ConfigService] Callback failed: {e}") - self._reload_pending = False - return True + self._last_notified_config = self._config.copy() + return False except Exception as e: logger.error(f"[ConfigService] Failed to reload configuration: {e}") self._reload_pending = False @@ -164,8 +189,8 @@ def get_config(key: str, default: Any = None) -> Any: return start_config_service().get(key, default) -def get_all_config() -> Dict[str, Any]: - """Get all config values from the singleton service.""" +def get_config_all() -> Dict[str, Any]: + """Get the entire current configuration state.""" return start_config_service().get_all() diff --git a/src/services/custom_notification.py b/src/services/custom_notification.py index c75e89c..c7cf3c3 100644 --- a/src/services/custom_notification.py +++ b/src/services/custom_notification.py @@ -11,6 +11,7 @@ from fabric.notifications import ( from fabric.utils import GdkPixbuf, logger, os, time import shared.data as data +from services.config import config, on_config_change NOTIFICATION_CACHE_FILE = f"{data.CACHE_DIR}/notification_history.json" @@ -222,11 +223,43 @@ class CachedNotifications(Notifications): self.load_cached_notifications() - # Connect to the notification_added signal to cache new notifications - # Note: self here refers to the CachedNotifications service, which inherits from Notifications - # So we connect to our own notification_added signal super().notification_added.connect(self.on_notification_added) + # Listen for config changes to prune history if needed + on_config_change(self._on_config_changed) + + def _on_config_changed(self, new_config, old_config): + # If ignored apps changed, remove them from history + if config().has_changed("notification_ignored_apps", old_config): + ignored_apps = new_config.get("notification_ignored_apps", []) + ids_to_remove = [ + cid + for cid, cnotif in self._cached_notifications.items() + if cnotif.app_name in ignored_apps + ] + for cid in ids_to_remove: + self.remove_cached_notification(cid) + if ids_to_remove: + logger.debug( + f"Hot-reload: Removed {len(ids_to_remove)} notifications from ignored apps" + ) + + # If limited apps changed, prune duplicates + if config().has_changed("notification_limited_apps_history", old_config): + limited_apps = new_config.get("notification_limited_apps_history", []) + for app in limited_apps: + app_notifs = [ + (cid, cnotif.timestamp) + for cid, cnotif in self._cached_notifications.items() + if cnotif.app_name == app + ] + if len(app_notifs) > 1: + # Sort by timestamp descending and keep only the first (latest) + app_notifs.sort(key=lambda x: x[1], reverse=True) + for cid, _ in app_notifs[1:]: + self.remove_cached_notification(cid) + logger.debug(f"Hot-reload: Pruned history for limited app {app}") + def load_cached_notifications(self) -> dict[int, CachedNotification]: """Load cached notifications from a JSON file (deserialization).""" try: @@ -309,7 +342,8 @@ class CachedNotifications(Notifications): ) # Check if this app should be ignored for history (don't cache) - if notification.app_name in data.NOTIFICATION_IGNORED_APPS_HISTORY: + ignored_apps = config().get("notification_ignored_apps", []) + if notification.app_name in ignored_apps: # Don't cache notifications from ignored apps, but still allow popup display logger.debug( f"Ignoring notification from {notification.app_name} (in ignore list)" @@ -347,6 +381,20 @@ class CachedNotifications(Notifications): f"Caching new notification: ID={notification.id}, App={notification.app_name}, Summary={notification.summary[:50]}..." ) + # Handle limited history apps - remove previous notifications from same app + limited_apps = config().get("notification_limited_apps_history", []) + if notification.app_name in limited_apps: + ids_to_remove = [ + cid + for cid, cnotif in self._cached_notifications.items() + if cnotif.app_name == notification.app_name + ] + for cid in ids_to_remove: + self.remove_cached_notification(cid) + logger.debug( + f"Pruned {len(ids_to_remove)} old notifications for limited app {notification.app_name}" + ) + # GUARANTEED STORAGE: Always create and store notification to history first cache_id = self._next_cache_id self._next_cache_id += 1