feat(src/services): add config persistence, reload handling and notification pruning on config change

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`
This commit is contained in:
sankalp committed 2026-05-03 22:06:23 +05:45
1 parent 791400f437
commit 244a913d96
2 files changed
+87 -14

No files matched your search

+35 -10
View File
@@ -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()
+52 -4
View File
@@ -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