From 7afafe90286ab030f66a71c618ef863282a73e36 Mon Sep 17 00:00:00 2001 From: tr1x_em Date: Mon, 11 Aug 2025 15:37:22 +0530 Subject: [PATCH] Better notification caching system --- modules/notification/notification.py | 357 ++++++++++++++++---- modules/notification/notification_center.py | 97 +++++- styles/controlcenter.css | 2 +- 3 files changed, 375 insertions(+), 81 deletions(-) diff --git a/modules/notification/notification.py b/modules/notification/notification.py index c09c50e..243b8ba 100644 --- a/modules/notification/notification.py +++ b/modules/notification/notification.py @@ -124,57 +124,60 @@ def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None): def cache_notification_icon(source, size=(48, 48)): - """Cache a notification icon from local sources (file, pixbuf) - PERMANENT cache""" + """Optimized notification icon caching with immediate pixbuf generation and caching""" try: ensure_notification_cache_dirs() - # Handle different source types + # Handle different source types with optimized caching if isinstance(source, str): cache_key = get_cache_key(source, size) - # Check if already cached + # Check cache first for immediate return cached_pixbuf = get_cached_pixbuf( cache_key, size, NOTIFICATION_ICON_CACHE_DIR ) if cached_pixbuf: + logger.debug(f"Cache hit for icon: {cache_key}") return cached_pixbuf - # Load and cache the icon (local only) + # Load, cache, and return icon in one optimized flow if source.startswith("file://"): - # Local file URL + # Local file URL - process and cache immediately file_path = source[7:] pixbuf = load_and_cache_local_icon(file_path, cache_key, size) elif os.path.exists(source): - # Direct file path + # Direct file path - process and cache immediately pixbuf = load_and_cache_local_icon(source, cache_key, size) else: - # Icon name - try to resolve from theme + # Icon name - resolve from theme and cache immediately pixbuf = load_and_cache_theme_icon(source, cache_key, size) return pixbuf elif hasattr(source, "scale_simple"): - # Already a pixbuf + # Already a pixbuf - cache it directly with optimized flow cache_key = get_cache_key(source, size) - # Check if already cached + # Check cache first cached_pixbuf = get_cached_pixbuf( cache_key, size, NOTIFICATION_ICON_CACHE_DIR ) if cached_pixbuf: + logger.debug(f"Cache hit for pixbuf: {cache_key}") return cached_pixbuf - # Scale and cache + # Scale once and cache immediately scaled_pixbuf = source.scale_simple( size[0], size[1], GdkPixbuf.InterpType.BILINEAR ) save_pixbuf_to_cache(scaled_pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR) + logger.debug(f"Cached new pixbuf icon: {cache_key}") return scaled_pixbuf except Exception as e: logger.warning(f"Failed to cache notification icon: {e}") - # Return fallback + # Return fallback with caching return get_fallback_notification_icon(size) @@ -233,39 +236,117 @@ def get_fallback_notification_icon(size=(48, 48)): return None -def get_notification_image_cache_key(notification_id, image_pixbuf): - """Generate a cache key for notification images""" +def find_existing_cached_image(notification_id): + """Find existing cached image for a notification ID, regardless of hash - prioritizes restart scenarios""" try: - # Use notification ID and image hash for unique key + if not os.path.exists(NOTIFICATION_IMAGE_CACHE_DIR): + logger.debug(f"Notification image cache directory does not exist: {NOTIFICATION_IMAGE_CACHE_DIR}") + return None + + # Look for any cached image that starts with this notification ID + matching_files = [] + for filename in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR): + if filename.startswith(f"notif_{notification_id}_") and filename.endswith(".png"): + matching_files.append(filename) + + if not matching_files: + logger.debug(f"No cached images found for notification ID: {notification_id}") + return None + + # Sort by modification time to get the most recent cache file + matching_files.sort(key=lambda f: os.path.getmtime(os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f)), reverse=True) + + # Try to load the most recent cached image + for filename in matching_files: + cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, filename) + try: + cached_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) + if cached_pixbuf: + cache_key = filename[:-4] # Remove .png extension + logger.debug(f"Found existing cached image for notification {notification_id}: {cache_key}") + return cached_pixbuf + except Exception as e: + logger.debug(f"Failed to load cached image {filename}: {e}") + continue + + logger.debug(f"Found {len(matching_files)} cache files for notification {notification_id} but none could be loaded") + return None + except Exception as e: + logger.debug(f"Failed to find existing cached image for notification {notification_id}: {e}") + return None + + +def get_notification_image_cache_key(notification_id, image_pixbuf): + """Generate a robust cache key for notification images, handling corrupted pixbufs""" + try: + # Primary approach: Use notification ID and image hash for unique key if hasattr(image_pixbuf, "get_pixels"): - pixel_data = image_pixbuf.get_pixels() - image_hash = hashlib.md5(pixel_data).hexdigest()[:8] + try: + pixel_data = image_pixbuf.get_pixels() + image_hash = hashlib.md5(pixel_data).hexdigest()[:8] + return f"notif_{notification_id}_{image_hash}" + except Exception as pixel_error: + logger.debug(f"Failed to get pixel data for cache key (temp file likely gone): {pixel_error}") + # Fallback to notification ID + dimensions if pixel data fails + try: + width = image_pixbuf.get_width() + height = image_pixbuf.get_height() + dimension_hash = hashlib.md5(f"{width}x{height}".encode()).hexdigest()[:8] + return f"notif_{notification_id}_{dimension_hash}" + except Exception: + # Ultimate fallback using just notification ID and timestamp + return f"notif_{notification_id}_{int(time.time())}" else: - image_hash = str(int(time.time())) - return f"notif_{notification_id}_{image_hash}" + # Not a valid pixbuf, use notification ID + timestamp + return f"notif_{notification_id}_{int(time.time())}" except Exception: + # Absolute fallback return f"notif_{notification_id}_{int(time.time())}" def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)): - """Cache a notification image pixbuf - PERMANENT cache until manual clear""" + """Optimized notification image caching with robust error handling for persistent notifications""" try: ensure_notification_cache_dirs() cache_key = get_notification_image_cache_key(notification_id, image_pixbuf) cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{cache_key}.png") - # Don't overwrite existing cache + # Check if already cached to avoid redundant work if os.path.exists(cache_path): + logger.debug(f"Image cache hit: {cache_key}") return cache_path, cache_key - # Scale and save the image - scaled_pixbuf = image_pixbuf.scale_simple( - size[0], size[1], GdkPixbuf.InterpType.BILINEAR - ) - scaled_pixbuf.savev(cache_path, "png", [], []) - logger.debug(f"Cached notification image: {cache_key}") - return cache_path, cache_key + # Try to scale and save the image with robust error handling + try: + scaled_pixbuf = image_pixbuf.scale_simple( + size[0], size[1], GdkPixbuf.InterpType.BILINEAR + ) + scaled_pixbuf.savev(cache_path, "png", [], []) + logger.debug(f"Generated and cached notification image: {cache_key}") + return cache_path, cache_key + except Exception as scale_error: + # Handle cases where pixbuf is corrupted or references invalid files + logger.debug(f"Failed to scale/save pixbuf (likely temp file gone): {scale_error}") + + # Try to get pixel data directly to create a valid cache key even if file is gone + try: + # If we can't scale but pixbuf exists, create a fallback cache entry + fallback_cache_key = f"fallback_{notification_id}_{int(time.time())}" + fallback_cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, f"{fallback_cache_key}.png") + + # Create a fallback image based on notification ID + fallback_pixbuf = get_fallback_notification_icon(size) + if fallback_pixbuf: + fallback_pixbuf.savev(fallback_cache_path, "png", [], []) + logger.debug(f"Created fallback cached image: {fallback_cache_key}") + return fallback_cache_path, fallback_cache_key + + except Exception as fallback_error: + logger.debug(f"Fallback cache creation failed: {fallback_error}") + + return None, None + except Exception as e: logger.warning(f"Failed to cache notification image: {e}") return None, None @@ -365,9 +446,119 @@ def cleanup_all_notification_caches(): logger.warning(f"Failed to cleanup all notification caches: {e}") -# Initialize cache on module load +def verify_cache_persistence(): + """Verify that cached icons persist and can be loaded after restart""" + try: + icon_cache_files = [] + image_cache_files = [] + + if os.path.exists(NOTIFICATION_ICON_CACHE_DIR): + icon_cache_files = [f for f in os.listdir(NOTIFICATION_ICON_CACHE_DIR) if f.endswith('.png')] + + if os.path.exists(NOTIFICATION_IMAGE_CACHE_DIR): + image_cache_files = [f for f in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR) if f.endswith('.png')] + + logger.info(f"Cache persistence check: {len(icon_cache_files)} icons, {len(image_cache_files)} images cached") + + # Test loading a few cached items to verify they work + for cache_file in icon_cache_files[:2]: # Test first 2 icon files + try: + cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file) + test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) + if test_pixbuf: + logger.debug(f"Successfully verified cached icon: {cache_file}") + except Exception as e: + logger.warning(f"Failed to load cached icon {cache_file}: {e}") + + for cache_file in image_cache_files[:2]: # Test first 2 image files + try: + cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file) + test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path) + if test_pixbuf: + logger.debug(f"Successfully verified cached image: {cache_file}") + except Exception as e: + logger.warning(f"Failed to load cached image {cache_file}: {e}") + + return len(icon_cache_files) + len(image_cache_files) > 0 + + except Exception as e: + logger.error(f"Failed to verify cache persistence: {e}") + return False + + +def migrate_persistent_notifications(): + """Migrate persistent notifications to use cached assets when temp files are gone""" + try: + from services.modus import notification_service + + migrated_count = 0 + for cached_notification in notification_service.cached_notifications: + notification = cached_notification._notification + + # Check if notification has image_pixbuf but temp file might be gone + if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: + try: + # Try to access pixel data to test if temp file still exists + notification.image_pixbuf.get_pixels() + except Exception: + # Temp file is gone, ensure app icon is cached as fallback + try: + if hasattr(notification, "app_icon") and notification.app_icon: + cache_notification_icon(notification.app_icon, (35, 35)) + migrated_count += 1 + logger.debug(f"Migrated notification for {notification.app_name} to use cached app icon") + except Exception as cache_error: + logger.debug(f"Failed to cache app icon for {notification.app_name}: {cache_error}") + + if migrated_count > 0: + logger.info(f"Migrated {migrated_count} persistent notifications to use cached assets") + + except Exception as e: + logger.warning(f"Failed to migrate persistent notifications: {e}") + + +# Initialize cache and verify persistence on module load ensure_notification_cache_dirs() cleanup_old_cache_files() +verify_cache_persistence() + +# Run migration for persistent notifications on startup +try: + migrate_persistent_notifications() +except Exception as e: + logger.debug(f"Migration skipped (service not ready): {e}") + + +def preload_notification_assets(notification): + """Preload and cache notification assets with robust error handling for persistent notifications""" + try: + # Preload app icon at common sizes used in notifications + if hasattr(notification, "app_icon") and notification.app_icon: + try: + # Cache header size (24x24) and content size (35x35) simultaneously + cache_notification_icon(notification.app_icon, (24, 24)) + cache_notification_icon(notification.app_icon, (35, 35)) + logger.debug(f"Preloaded app icon for: {notification.app_name}") + except Exception as icon_error: + logger.debug(f"Failed to preload app icon for {notification.app_name}: {icon_error}") + + # Preload notification image if present (with resilient handling) + if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: + try: + notification_id = getattr(notification, "id", int(time.time())) + + # Try to cache the image, but don't fail if temp files are gone + cache_path, cache_key = cache_notification_image(notification_id, notification.image_pixbuf, (35, 35)) + if cache_path and cache_key: + logger.debug(f"Preloaded notification image for: {notification.app_name}") + else: + logger.debug(f"Skipped preloading notification image for {notification.app_name} (temp file likely gone)") + + except Exception as image_error: + logger.debug(f"Failed to preload notification image for {notification.app_name}: {image_error}") + + except Exception as e: + logger.warning(f"Failed to preload notification assets: {e}") def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 280): @@ -448,19 +639,30 @@ class NotificationWidget(Box): self.start_timeout() def create_header(self, notification): - app_icon = ( - Image( + """Create notification header with optimized cached app icon""" + try: + # Use optimized caching for header app icon (24x24 for header display) + cached_app_icon_pixbuf = cache_notification_icon(notification.app_icon, (24, 24)) + + if cached_app_icon_pixbuf: + # Create image from cached pixbuf for consistent display + app_icon = CustomImage(pixbuf=cached_app_icon_pixbuf) + app_icon.set_name("notification-icon") + else: + # Fallback to theme icon if caching fails completely + app_icon = Image( + name="notification-icon", + icon_name="notifications", + icon_size=24, + ) + except Exception as e: + logger.warning(f"Failed to load cached header icon: {e}") + # Ultimate fallback + app_icon = Image( name="notification-icon", - image_file=notification.app_icon[7:], - size=24, - ) - if "file://" in notification.app_icon - else Image( - name="notification-icon", - icon_name="notifications" or notification.app_icon, + icon_name="notifications", icon_size=24, ) - ) return CenterBox( name="notification-title", @@ -589,41 +791,65 @@ class NotificationWidget(Box): return get_fallback_notification_icon((width, height)) def _get_notification_pixbuf(self, notification): - """Safely get notification pixbuf with caching for image notifications""" + """Optimized notification pixbuf with cached-first approach for restart scenarios""" + notification_id = getattr(notification, "id", int(time.time())) + + # Priority 1: ALWAYS check for existing cached image first (especially important for restarts) + if notification_id: + existing_cached_image = find_existing_cached_image(notification_id) + if existing_cached_image: + logger.debug(f"Using existing cached image for notification: {notification_id}") + return existing_cached_image + try: + # Priority 2: Try to process current pixbuf only if we have no cached version if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: - # Try to get/cache the notification image - notification_id = getattr(notification, "id", int(time.time())) - cache_path, cache_key = cache_notification_image( - notification_id, notification.image_pixbuf, (64, 64) - ) + try: + cache_key = get_notification_image_cache_key(notification_id, notification.image_pixbuf) + cached_image = get_cached_notification_image(cache_key) + if cached_image: + logger.debug(f"Using cached notification image: {cache_key}") + return cached_image + + # Try to cache the image now + cache_path, cache_key = cache_notification_image( + notification_id, notification.image_pixbuf, (35, 35) + ) - if cache_path and cache_key: - # Store cache key for reference (but don't auto-delete) - self.notification_image_cache_key = cache_key - # Load the cached image - cached_pixbuf = get_cached_notification_image(cache_key) - if cached_pixbuf: - return cached_pixbuf.scale_simple( - 35, 35, GdkPixbuf.InterpType.BILINEAR - ) + if cache_path and cache_key: + # Load and return the newly cached image + cached_pixbuf = get_cached_notification_image(cache_key) + if cached_pixbuf: + logger.debug(f"Cached and using notification image: {cache_key}") + return cached_pixbuf - # Fallback to direct scaling if caching fails - return notification.image_pixbuf.scale_simple( - 35, 35, GdkPixbuf.InterpType.BILINEAR - ) + # Direct fallback if caching succeeded but loading failed + logger.debug("Using direct scaling for notification image") + return notification.image_pixbuf.scale_simple( + 35, 35, GdkPixbuf.InterpType.BILINEAR + ) + except Exception as image_error: + logger.debug(f"Notification image processing failed (temp file likely gone): {image_error}") + # Continue to app icon fallback + except Exception as e: - logger.warning(f"Failed to get notification image: {e}") + logger.debug(f"Failed to process notification image: {e}") - # Fallback to cached app icon + # Priority 3: Use cached app icon with optimized cache-first approach try: - cached_app_icon = cache_notification_icon(notification.app_icon, (35, 35)) - if cached_app_icon: - return cached_app_icon + # Generate optimized cache key for app icon + app_icon_source = getattr(notification, "app_icon", None) + if app_icon_source: + # Cache icon immediately and return cached version + cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) + if cached_app_icon: + logger.debug(f"Using cached app icon for: {app_icon_source}") + return cached_app_icon except Exception as e: - logger.warning(f"Failed to get cached app icon: {e}") + logger.debug(f"Failed to get cached app icon: {e}") - # Ultimate fallback + # Priority 4: Ultimate fallback with caching + logger.debug("Using fallback notification icon") return get_fallback_notification_icon((35, 35)) def create_action_buttons(self, notification): @@ -948,6 +1174,9 @@ class ModusNoti(Window): # Notification is already cached by the service, just don't show popup return + # Preload assets immediately for optimal caching and display performance + preload_notification_assets(notification) + # Implement smart queue management for smooth transitions current_time = GLib.get_monotonic_time() / 1000 diff --git a/modules/notification/notification_center.py b/modules/notification/notification_center.py index 4107ab5..e5d5fce 100644 --- a/modules/notification/notification_center.py +++ b/modules/notification/notification_center.py @@ -1,4 +1,5 @@ from collections import defaultdict +import time from fabric.widgets.box import Box from fabric.widgets.button import Button @@ -13,6 +14,11 @@ from loguru import logger from modules.notification.notification import ( NotificationWidget, cache_notification_icon, + cache_notification_image, + get_cached_notification_image, + get_notification_image_cache_key, + find_existing_cached_image, + preload_notification_assets, cleanup_all_notification_caches, cleanup_notification_specific_caches, get_fallback_notification_icon, @@ -302,24 +308,61 @@ class ExpandableNotificationGroup(Box): self.expanded_container.set_visible(False) def _get_notification_pixbuf(self, notification): - # Use the same logic as NotificationWidget + """Use cached-first approach prioritizing existing cache over pixbuf processing""" + notification_id = getattr(notification, "id", None) + + # Priority 1: ALWAYS check for existing cached image first (critical for restart scenarios) + if notification_id: + existing_cached_image = find_existing_cached_image(notification_id) + if existing_cached_image: + logger.debug(f"Using existing cached image in center for notification: {notification_id}") + return existing_cached_image + try: - if hasattr(notification, "image_pixbuf") and notification.image_pixbuf: - return notification.image_pixbuf.scale_simple( - 35, 35, 2 - ) # GdkPixbuf.InterpType.BILINEAR = 2 - except Exception: - pass + # Priority 2: Try to process current pixbuf only if we have no cached version + if hasattr(notification, "image_pixbuf") and notification.image_pixbuf and notification_id: + try: + cache_key = get_notification_image_cache_key(notification_id, notification.image_pixbuf) + cached_image = get_cached_notification_image(cache_key) + if cached_image: + logger.debug(f"Using cached notification image in center: {cache_key}") + return cached_image + + # Try to cache the image now + cache_path, cache_key = cache_notification_image( + notification_id, notification.image_pixbuf, (35, 35) + ) + if cache_path and cache_key: + cached_image = get_cached_notification_image(cache_key) + if cached_image: + logger.debug(f"Cached and using notification image in center: {cache_key}") + return cached_image - # Fallback to app icon + # Direct scaling fallback if caching works but loading fails + logger.debug("Using direct scaling for notification image in center") + return notification.image_pixbuf.scale_simple(35, 35, 2) # GdkPixbuf.InterpType.BILINEAR = 2 + + except Exception as image_error: + logger.debug(f"Notification image processing failed in center (temp file likely gone): {image_error}") + # Continue to app icon fallback + + except Exception as e: + logger.debug(f"Failed to process notification image in center: {e}") + + # Priority 3: Use cached app icon with optimized cache-first approach try: - cached_app_icon = cache_notification_icon(notification.app_icon, (35, 35)) - if cached_app_icon: - return cached_app_icon - except Exception: - pass + # Use optimized caching for app icon + app_icon_source = getattr(notification, "app_icon", None) + if app_icon_source: + cached_app_icon = cache_notification_icon(app_icon_source, (35, 35)) + if cached_app_icon: + logger.debug(f"Using cached app icon in center for: {app_icon_source}") + return cached_app_icon + except Exception as e: + logger.debug(f"Failed to get cached app icon in center: {e}") - # Ultimate fallback + # Priority 4: Ultimate fallback with caching + logger.debug("Using fallback notification icon in center") return get_fallback_notification_icon((35, 35)) def on_clicked(self, widget, event): @@ -635,7 +678,7 @@ class NotificationCenter(Window): self.connect("destroy", self._on_destroy) def _rebuild_notification_groups(self): - """Rebuild notification groups from scratch""" + """Rebuild notification groups from scratch with enhanced asset preloading and debugging""" # Clear existing groups self.notification_groups.clear() self.group_widgets.clear() @@ -644,10 +687,29 @@ class NotificationCenter(Window): for child in self.notifications_box.get_children(): child.destroy() - # Group notifications by app name + # Group notifications by app name and preload assets with debugging + rebuild_count = 0 for cached_notification in notification_service.cached_notifications: app_name = cached_notification._notification.app_name + notification_id = getattr(cached_notification._notification, "id", None) + + logger.debug(f"Rebuilding notification for {app_name} with ID: {notification_id}") + + # Preload assets for each cached notification to ensure display consistency + preload_notification_assets(cached_notification._notification) + + # Check if we can find cached images for this notification + if notification_id: + existing_image = find_existing_cached_image(notification_id) + if existing_image: + logger.debug(f"✓ Found cached image for notification {notification_id} during rebuild") + else: + logger.debug(f"✗ No cached image found for notification {notification_id} during rebuild") + self.notification_groups[app_name].append(cached_notification) + rebuild_count += 1 + + logger.info(f"Rebuilt {rebuild_count} notifications into {len(self.notification_groups)} groups") # Create group widgets for app_name, notifications in self.notification_groups.items(): @@ -662,6 +724,9 @@ class NotificationCenter(Window): try: app_name = cached_notification._notification.app_name + # Preload assets for notification center display (ensure caching consistency) + preload_notification_assets(cached_notification._notification) + # Add to groups self.notification_groups[app_name].insert( 0, cached_notification diff --git a/styles/controlcenter.css b/styles/controlcenter.css index 683b5a2..96d8843 100644 --- a/styles/controlcenter.css +++ b/styles/controlcenter.css @@ -844,7 +844,7 @@ /* WiFi network slots - macOS style */ #wifi-network-slot button { border-radius: 8px; - padding: 4px 12px; + padding: 4px 4px; /* min-width: 280px; */ margin: 2px 0; }