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:
2 files changed
+87
-14
No files matched your search
+35
-10
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user