From d265607d40d5bc512ddb8bdab2dc1ed7d5e2255d Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Mon, 23 Jun 2025 17:44:47 -0500 Subject: [PATCH] feat: Add comprehensive logging system with UI for debugging text injection issues (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add comprehensive logging system with UI for debugging 🔍 New Logging Features: - Add LoggingManager for centralized log capture and management - Add LoggingDialog with real-time log viewing, filtering, and export - Add 'View Logs' menu item to system tray for easy access - Support log filtering by level (DEBUG, INFO, WARNING, ERROR, CRITICAL) - Support log filtering by module name - Export logs to text files with applied filters - Auto-scroll functionality for real-time log monitoring - Log statistics and management (clear logs, view counts) 🐛 Enhanced Text Injection Debugging: - Add detailed logging for text injection process - Log target window information (name, class, PID, process) - Log injection chunks and retry attempts - Add timeout handling with detailed error messages - Log environment detection and tool selection - Enhanced error reporting with stack traces 🎯 UI Improvements: - Integrated logging dialog with GTK interface - Color-coded log levels for easy identification - Real-time log updates in the UI - Export functionality with file chooser dialog - Status bar showing log statistics - Non-modal dialog for continuous monitoring 📁 New Files: - src/vocalinux/ui/logging_manager.py - Core logging management - src/vocalinux/ui/logging_dialog.py - GTK logging viewer dialog This will help debug text injection issues in applications like TextEdit and terminal where voice typing may not work as expected. * fix: Improve logging dialog UI layout and add clipboard functionality 🎨 UI Layout Improvements: - Increase dialog default size from 900x600 to 900x700 - Set minimum content height for scrolled window to 400px - Enable vertical and horizontal expansion for text view and scrolled window - Ensure log text area takes up most of the available dialog space 📋 Clipboard Functionality: - Add 'Copy All' button to dialog for easy log sharing - Implement clipboard copy with formatted headers and timestamps - Add user feedback messages for successful/failed copy operations - Include line count information in success messages - Handle empty log cases gracefully 🔧 Technical Improvements: - Proper GTK clipboard integration using Gdk.SELECTION_CLIPBOARD - Error handling for clipboard operations - Formatted clipboard content with metadata headers - Thread-safe clipboard operations This fixes the issue where the log text area was too short and users could barely see 2 lines despite having plenty of vertical space available. The new layout ensures the text view expands to fill the available space, and the clipboard functionality makes it easy to share logs for debugging. --- activate-vocalinux.sh | 32 +- src/vocalinux/main.py | 5 + src/vocalinux/text_injection/text_injector.py | 119 ++++- src/vocalinux/ui/__init__.py | 2 +- src/vocalinux/ui/logging_dialog.py | 472 ++++++++++++++++++ src/vocalinux/ui/logging_manager.py | 295 +++++++++++ src/vocalinux/ui/tray_indicator.py | 12 + 7 files changed, 905 insertions(+), 32 deletions(-) create mode 100644 src/vocalinux/ui/logging_dialog.py create mode 100644 src/vocalinux/ui/logging_manager.py diff --git a/activate-vocalinux.sh b/activate-vocalinux.sh index 51430cf..23b86b9 100755 --- a/activate-vocalinux.sh +++ b/activate-vocalinux.sh @@ -1,29 +1,5 @@ #!/bin/bash -# Vocalinux Virtual Environment Activation Script -# This script activates the virtual environment and sets up the Python path - -# Get the directory where this script is located -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Check if virtual environment exists -if [ ! -d "$SCRIPT_DIR/venv" ]; then - echo "Error: Virtual environment not found at $SCRIPT_DIR/venv" - echo "Please run the installer script first: ./install.sh" - return 1 2>/dev/null || exit 1 -fi - -# Activate the virtual environment -source "$SCRIPT_DIR/venv/bin/activate" - -# Set up Python path for development -export PYTHONPATH="$SCRIPT_DIR/src:$PYTHONPATH" - -echo "Vocalinux development environment activated!" -echo "Virtual environment: $SCRIPT_DIR/venv" -echo "Python path includes: $SCRIPT_DIR/src" -echo "" -echo "You can now:" -echo " - Run tests: pytest tests/" -echo " - Run Vocalinux: python -m vocalinux.main" -echo " - Install in development mode: pip install -e ." -echo "" +# This script activates the Vocalinux virtual environment +source "$(dirname "$(realpath "$0")")/venv/bin/activate" +echo "Vocalinux virtual environment activated." +echo "To start the application, run: vocalinux" diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index 9a3e07f..213b6d9 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -86,6 +86,11 @@ def main(): logging.getLogger().setLevel(logging.DEBUG) logger.debug("Debug logging enabled") + # Initialize logging manager early + from .ui.logging_manager import initialize_logging + initialize_logging() + logger.info("Logging system initialized") + # Check dependencies first if not check_dependencies(): logger.error("Cannot start Vocalinux due to missing dependencies") diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index 3858379..d385221 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -196,10 +196,15 @@ class TextInjector: logger.debug("Empty text provided, skipping injection") return True - logger.debug(f"Injecting text: {text}") + logger.info(f"Starting text injection: '{text}' (length: {len(text)})") + logger.debug(f"Environment: {self.environment}") + + # Get information about the current window/application + self._log_current_window_info() # Escape special characters for shell escaped_text = self._escape_text(text) + logger.debug(f"Escaped text: '{escaped_text}'") try: if ( @@ -222,9 +227,10 @@ class TextInjector: self._inject_with_xdotool(escaped_text) else: raise + logger.info(f"Text injection completed successfully") return True except Exception as e: - logger.error(f"Failed to inject text: {e}") + logger.error(f"Failed to inject text: {e}", exc_info=True) try: from ..ui.audio_feedback import play_error_sound play_error_sound() # Play error sound when text injection fails @@ -310,17 +316,25 @@ class TextInjector: # Inject text using xdotool try: max_retries = 2 + logger.debug(f"Starting xdotool injection with {max_retries} max retries") + for retry in range(max_retries + 1): try: # Inject in smaller chunks to avoid issues with very long text chunk_size = 20 # Reduced chunk size for better reliability + total_chunks = (len(text) + chunk_size - 1) // chunk_size + logger.debug(f"Splitting text into {total_chunks} chunks of max {chunk_size} chars") + for i in range(0, len(text), chunk_size): chunk = text[i : i + chunk_size] + chunk_num = (i // chunk_size) + 1 # First try with clearmodifiers cmd = ["xdotool", "type", "--clearmodifiers", chunk] + logger.debug(f"Injecting chunk {chunk_num}/{total_chunks}: '{chunk}'") + result = subprocess.run( - cmd, env=env, check=True, stderr=subprocess.PIPE, text=True + cmd, env=env, check=True, stderr=subprocess.PIPE, text=True, timeout=5 ) # Add a larger delay between chunks @@ -338,7 +352,15 @@ class TextInjector: ) time.sleep(0.5) # Wait before retry else: + logger.error(f"Final attempt failed: {chunk_error.stderr}") raise # Re-raise on final attempt + except subprocess.TimeoutExpired: + if retry < max_retries: + logger.warning(f"Text injection timeout, retrying (attempt {retry+1}/{max_retries})") + time.sleep(0.5) + else: + logger.error("Text injection timed out on final attempt") + raise # Try to reset any stuck modifiers try: @@ -450,3 +472,94 @@ class TextInjector: else: logger.warning(f"Keyboard shortcuts not supported with {self.wayland_tool}") return False + + def _log_current_window_info(self): + """Log information about the current window/application for debugging.""" + try: + if ( + self.environment == DesktopEnvironment.X11 + or self.environment == DesktopEnvironment.WAYLAND_XDOTOOL + ): + self._log_x11_window_info() + else: + logger.debug("Window info logging not available for pure Wayland") + except Exception as e: + logger.debug(f"Could not get window info: {e}") + + def _log_x11_window_info(self): + """Log X11 window information.""" + env = os.environ.copy() + + if self.environment == DesktopEnvironment.WAYLAND_XDOTOOL: + env["GDK_BACKEND"] = "x11" + env["QT_QPA_PLATFORM"] = "xcb" + if "DISPLAY" not in env or not env["DISPLAY"]: + env["DISPLAY"] = ":0" + + try: + # Get active window ID + result = subprocess.run( + ["xdotool", "getactivewindow"], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + timeout=2 + ) + window_id = result.stdout.strip() + logger.debug(f"Active window ID: {window_id}") + + # Get window name + result = subprocess.run( + ["xdotool", "getwindowname", window_id], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + timeout=2 + ) + window_name = result.stdout.strip() + logger.info(f"Target window: '{window_name}' (ID: {window_id})") + + # Get window class + result = subprocess.run( + ["xdotool", "getwindowclassname", window_id], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + timeout=2 + ) + window_class = result.stdout.strip() + logger.debug(f"Window class: {window_class}") + + # Get window PID + result = subprocess.run( + ["xdotool", "getwindowpid", window_id], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + timeout=2 + ) + window_pid = result.stdout.strip() + logger.debug(f"Window PID: {window_pid}") + + # Try to get process name + try: + with open(f"/proc/{window_pid}/comm", "r") as f: + process_name = f.read().strip() + logger.info(f"Target process: {process_name} (PID: {window_pid})") + except: + pass + + except subprocess.TimeoutExpired: + logger.warning("Timeout getting window information") + except subprocess.CalledProcessError as e: + logger.debug(f"xdotool command failed: {e.stderr}") + except Exception as e: + logger.debug(f"Error getting window info: {e}") diff --git a/src/vocalinux/ui/__init__.py b/src/vocalinux/ui/__init__.py index 1f1c6c4..7f496b2 100644 --- a/src/vocalinux/ui/__init__.py +++ b/src/vocalinux/ui/__init__.py @@ -2,4 +2,4 @@ User interface module for Vocalinux """ -from . import audio_feedback, config_manager, keyboard_shortcuts, tray_indicator +from . import audio_feedback, config_manager, keyboard_shortcuts, logging_manager, tray_indicator diff --git a/src/vocalinux/ui/logging_dialog.py b/src/vocalinux/ui/logging_dialog.py new file mode 100644 index 0000000..3fed20d --- /dev/null +++ b/src/vocalinux/ui/logging_dialog.py @@ -0,0 +1,472 @@ +""" +Logging viewer dialog for VocaLinux. + +This module provides a GTK dialog for viewing, filtering, and exporting +application logs for debugging purposes. +""" + +import logging +import os +import threading +from datetime import datetime +from typing import Optional + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import GLib, GObject, Gtk + +from .logging_manager import LogRecord, get_logging_manager + +logger = logging.getLogger(__name__) + + +class LoggingDialog(Gtk.Dialog): + """GTK Dialog for viewing and managing application logs.""" + + def __init__(self, parent: Optional[Gtk.Window] = None): + super().__init__( + title="VocaLinux Logs", + transient_for=parent, + flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, + modal=False + ) + + self.logging_manager = get_logging_manager() + self.auto_scroll = True + self.filter_level = None + self.filter_module = None + + # Set dialog properties + self.set_default_size(900, 700) + self.set_border_width(10) + + # Add buttons + self.add_button("Close", Gtk.ResponseType.CLOSE) + self.add_button("Copy All", Gtk.ResponseType.HELP) + self.add_button("Export", Gtk.ResponseType.APPLY) + self.add_button("Clear", Gtk.ResponseType.REJECT) + + # Create UI + self._create_ui() + + # Register for new log records + self.logging_manager.register_callback(self._on_new_log_record) + + # Load existing logs + self._refresh_logs() + + # Connect signals + self.connect("response", self._on_response) + self.connect("destroy", self._on_destroy) + + def _create_ui(self): + """Create the user interface.""" + content_area = self.get_content_area() + + # Create main container + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + content_area.add(main_box) + + # Create toolbar + toolbar_box = self._create_toolbar() + main_box.pack_start(toolbar_box, False, False, 0) + + # Create log view (give it most of the space) + log_view_box = self._create_log_view() + main_box.pack_start(log_view_box, True, True, 0) + + # Create status bar + status_box = self._create_status_bar() + main_box.pack_start(status_box, False, False, 0) + + # Show all widgets + main_box.show_all() + + def _create_toolbar(self): + """Create the toolbar with filters and controls.""" + toolbar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + + # Level filter + level_label = Gtk.Label(label="Level:") + toolbar_box.pack_start(level_label, False, False, 0) + + self.level_combo = Gtk.ComboBoxText() + self.level_combo.append("ALL", "All Levels") + self.level_combo.append("DEBUG", "Debug") + self.level_combo.append("INFO", "Info") + self.level_combo.append("WARNING", "Warning") + self.level_combo.append("ERROR", "Error") + self.level_combo.append("CRITICAL", "Critical") + self.level_combo.set_active(0) + self.level_combo.connect("changed", self._on_filter_changed) + toolbar_box.pack_start(self.level_combo, False, False, 0) + + # Module filter + module_label = Gtk.Label(label="Module:") + toolbar_box.pack_start(module_label, False, False, 0) + + self.module_entry = Gtk.Entry() + self.module_entry.set_placeholder_text("Filter by module name...") + self.module_entry.connect("changed", self._on_filter_changed) + toolbar_box.pack_start(self.module_entry, True, True, 0) + + # Auto-scroll toggle + self.auto_scroll_check = Gtk.CheckButton(label="Auto-scroll") + self.auto_scroll_check.set_active(True) + self.auto_scroll_check.connect("toggled", self._on_auto_scroll_toggled) + toolbar_box.pack_start(self.auto_scroll_check, False, False, 0) + + # Refresh button + refresh_button = Gtk.Button(label="Refresh") + refresh_button.connect("clicked", self._on_refresh_clicked) + toolbar_box.pack_start(refresh_button, False, False, 0) + + return toolbar_box + + def _create_log_view(self): + """Create the main log viewing area.""" + # Create scrolled window + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.set_shadow_type(Gtk.ShadowType.IN) + # Set minimum height to ensure the text view takes up most of the dialog space + scrolled.set_min_content_height(400) + scrolled.set_vexpand(True) + scrolled.set_hexpand(True) + + # Create text view + self.text_view = Gtk.TextView() + self.text_view.set_editable(False) + self.text_view.set_cursor_visible(False) + self.text_view.set_wrap_mode(Gtk.WrapMode.WORD) + # Ensure the text view expands to fill available space + self.text_view.set_vexpand(True) + self.text_view.set_hexpand(True) + + # Set monospace font + font_desc = self.text_view.get_pango_context().get_font_description() + font_desc.set_family("monospace") + font_desc.set_size(9 * 1024) # 9pt + self.text_view.override_font(font_desc) + + # Get text buffer + self.text_buffer = self.text_view.get_buffer() + + # Create text tags for different log levels + self._create_text_tags() + + scrolled.add(self.text_view) + + # Store reference to scrolled window for auto-scrolling + self.scrolled_window = scrolled + + return scrolled + + def _create_text_tags(self): + """Create text tags for different log levels.""" + # Debug - gray + debug_tag = self.text_buffer.create_tag("DEBUG") + debug_tag.set_property("foreground", "#666666") + + # Info - default color + info_tag = self.text_buffer.create_tag("INFO") + + # Warning - orange + warning_tag = self.text_buffer.create_tag("WARNING") + warning_tag.set_property("foreground", "#FF8C00") + warning_tag.set_property("weight", 600) + + # Error - red + error_tag = self.text_buffer.create_tag("ERROR") + error_tag.set_property("foreground", "#DC143C") + error_tag.set_property("weight", 700) + + # Critical - red background + critical_tag = self.text_buffer.create_tag("CRITICAL") + critical_tag.set_property("foreground", "#FFFFFF") + critical_tag.set_property("background", "#DC143C") + critical_tag.set_property("weight", 700) + + def _create_status_bar(self): + """Create the status bar.""" + status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + + self.status_label = Gtk.Label() + self.status_label.set_halign(Gtk.Align.START) + status_box.pack_start(self.status_label, True, True, 0) + + # Update status + self._update_status() + + return status_box + + def _update_status(self): + """Update the status bar with current log statistics.""" + stats = self.logging_manager.get_log_stats() + + if stats['total'] == 0: + status_text = "No log records" + else: + level_counts = [] + for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: + count = stats['by_level'].get(level, 0) + if count > 0: + level_counts.append(f"{level}: {count}") + + status_text = f"Total: {stats['total']} | " + " | ".join(level_counts) + + self.status_label.set_text(status_text) + + def _on_filter_changed(self, widget): + """Handle filter changes.""" + # Get current filter values + level_id = self.level_combo.get_active_id() + self.filter_level = None if level_id == "ALL" else level_id + + module_text = self.module_entry.get_text().strip() + self.filter_module = module_text if module_text else None + + # Refresh logs with new filters + self._refresh_logs() + + def _on_auto_scroll_toggled(self, widget): + """Handle auto-scroll toggle.""" + self.auto_scroll = widget.get_active() + + def _on_refresh_clicked(self, widget): + """Handle refresh button click.""" + self._refresh_logs() + + def _refresh_logs(self): + """Refresh the log display with current filters.""" + # Get filtered logs + records = self.logging_manager.get_logs( + level_filter=self.filter_level, + module_filter=self.filter_module + ) + + # Clear text buffer + self.text_buffer.set_text("") + + # Add log records + for record in records: + self._append_log_record(record) + + # Update status + self._update_status() + + # Auto-scroll to bottom + if self.auto_scroll: + self._scroll_to_bottom() + + def _append_log_record(self, record: LogRecord): + """ + Append a log record to the text view. + + Args: + record: The log record to append + """ + # Format the log line + log_line = str(record) + "\n" + + # Get end iterator + end_iter = self.text_buffer.get_end_iter() + + # Insert text with appropriate tag + tag_name = record.level + if tag_name in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: + self.text_buffer.insert_with_tags_by_name(end_iter, log_line, tag_name) + else: + self.text_buffer.insert(end_iter, log_line) + + def _scroll_to_bottom(self): + """Scroll the text view to the bottom.""" + mark = self.text_buffer.get_insert() + end_iter = self.text_buffer.get_end_iter() + self.text_buffer.place_cursor(end_iter) + self.text_view.scroll_to_mark(mark, 0.0, False, 0.0, 0.0) + + def _on_new_log_record(self, record: LogRecord): + """ + Handle new log record from the logging manager. + + Args: + record: The new log record + """ + # Check if record matches current filters + if self.filter_level and record.level != self.filter_level: + return + + if self.filter_module and self.filter_module.lower() not in record.logger_name.lower(): + return + + # Add to UI in main thread + GLib.idle_add(self._append_log_record_safe, record) + + def _append_log_record_safe(self, record: LogRecord): + """ + Safely append a log record in the main thread. + + Args: + record: The log record to append + """ + try: + self._append_log_record(record) + + # Auto-scroll if enabled + if self.auto_scroll: + self._scroll_to_bottom() + + # Update status + self._update_status() + + except Exception as e: + print(f"Error appending log record: {e}") + + return False # Remove from idle queue + + def _on_response(self, dialog, response_id): + """Handle dialog responses.""" + if response_id == Gtk.ResponseType.APPLY: + self._export_logs() + elif response_id == Gtk.ResponseType.REJECT: + self._clear_logs() + elif response_id == Gtk.ResponseType.HELP: + self._copy_logs_to_clipboard() + elif response_id == Gtk.ResponseType.CLOSE: + self.destroy() + + def _export_logs(self): + """Export logs to a file.""" + # Create file chooser dialog + file_dialog = Gtk.FileChooserDialog( + title="Export Logs", + parent=self, + action=Gtk.FileChooserAction.SAVE + ) + + file_dialog.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK + ) + + # Set default filename + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + default_filename = f"vocalinux_logs_{timestamp}.txt" + file_dialog.set_current_name(default_filename) + + # Add file filter + file_filter = Gtk.FileFilter() + file_filter.set_name("Text files") + file_filter.add_pattern("*.txt") + file_dialog.add_filter(file_filter) + + response = file_dialog.run() + + if response == Gtk.ResponseType.OK: + filepath = file_dialog.get_filename() + success = self.logging_manager.export_logs( + filepath, + level_filter=self.filter_level, + module_filter=self.filter_module + ) + + if success: + self._show_message("Export successful", f"Logs exported to:\n{filepath}") + else: + self._show_message("Export failed", "Failed to export logs. Check the logs for details.", Gtk.MessageType.ERROR) + + file_dialog.destroy() + + def _copy_logs_to_clipboard(self): + """Copy all visible logs to clipboard.""" + try: + # Get all text from the text buffer + start_iter = self.text_buffer.get_start_iter() + end_iter = self.text_buffer.get_end_iter() + text_content = self.text_buffer.get_text(start_iter, end_iter, False) + + if not text_content.strip(): + self._show_message("No logs to copy", "There are no logs to copy to clipboard.") + return + + # Get the clipboard + from gi.repository import Gdk + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + + # Create header for clipboard content + from datetime import datetime + header = f"VocaLinux Logs - Copied at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + header += "=" * 80 + "\n\n" + + # Combine header and content + clipboard_content = header + text_content + + # Set clipboard content + clipboard.set_text(clipboard_content, -1) + clipboard.store() + + # Count lines for user feedback + line_count = len(text_content.strip().split('\n')) + self._show_message( + "Logs copied to clipboard", + f"Successfully copied {line_count} log lines to clipboard.\n\n" + "You can now paste them into any text editor or document." + ) + + except Exception as e: + logger.error(f"Failed to copy logs to clipboard: {e}") + self._show_message( + "Copy failed", + f"Failed to copy logs to clipboard: {e}", + Gtk.MessageType.ERROR + ) + + def _clear_logs(self): + """Clear all logs after confirmation.""" + # Show confirmation dialog + confirm_dialog = Gtk.MessageDialog( + transient_for=self, + flags=0, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text="Clear all logs?" + ) + confirm_dialog.format_secondary_text( + "This will permanently remove all log records from memory. " + "This action cannot be undone." + ) + + response = confirm_dialog.run() + confirm_dialog.destroy() + + if response == Gtk.ResponseType.YES: + self.logging_manager.clear_logs() + self._refresh_logs() + self._show_message("Logs cleared", "All log records have been cleared.") + + def _show_message(self, title: str, message: str, message_type: Gtk.MessageType = Gtk.MessageType.INFO): + """ + Show a message dialog. + + Args: + title: Dialog title + message: Message text + message_type: Type of message + """ + dialog = Gtk.MessageDialog( + transient_for=self, + flags=0, + message_type=message_type, + buttons=Gtk.ButtonsType.OK, + text=title + ) + dialog.format_secondary_text(message) + dialog.run() + dialog.destroy() + + def _on_destroy(self, widget): + """Handle dialog destruction.""" + # Unregister callback + self.logging_manager.unregister_callback(self._on_new_log_record) \ No newline at end of file diff --git a/src/vocalinux/ui/logging_manager.py b/src/vocalinux/ui/logging_manager.py new file mode 100644 index 0000000..61bad91 --- /dev/null +++ b/src/vocalinux/ui/logging_manager.py @@ -0,0 +1,295 @@ +""" +Logging manager for VocaLinux. + +This module provides centralized logging functionality with UI integration, +allowing users to view, filter, and export application logs for debugging. +""" + +import logging +import os +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Callable, List, Optional + +logger = logging.getLogger(__name__) + + +class LogRecord: + """Represents a single log record with additional metadata.""" + + def __init__(self, timestamp: datetime, level: str, logger_name: str, message: str, module: str = ""): + self.timestamp = timestamp + self.level = level + self.logger_name = logger_name + self.message = message + self.module = module + + def to_dict(self): + """Convert to dictionary for easy serialization.""" + return { + 'timestamp': self.timestamp.isoformat(), + 'level': self.level, + 'logger_name': self.logger_name, + 'message': self.message, + 'module': self.module + } + + def __str__(self): + """String representation for display.""" + time_str = self.timestamp.strftime("%H:%M:%S.%f")[:-3] # Include milliseconds + return f"[{time_str}] {self.level:8} {self.logger_name:20} | {self.message}" + + +class LoggingManager: + """ + Centralized logging manager for VocaLinux. + + This class captures all application logs, stores them in memory, + and provides functionality for viewing, filtering, and exporting logs. + """ + + def __init__(self, max_records: int = 1000): + """ + Initialize the logging manager. + + Args: + max_records: Maximum number of log records to keep in memory + """ + self.max_records = max_records + self.log_records: List[LogRecord] = [] + self.log_callbacks: List[Callable[[LogRecord], None]] = [] + self.lock = threading.Lock() + + # Create logs directory + self.logs_dir = Path.home() / ".local" / "share" / "vocalinux" / "logs" + self.logs_dir.mkdir(parents=True, exist_ok=True) + + # Set up custom log handler + self.handler = LoggingHandler(self) + self.handler.setLevel(logging.DEBUG) + + # Create formatter + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + self.handler.setFormatter(formatter) + + # Add handler to root logger + root_logger = logging.getLogger() + root_logger.addHandler(self.handler) + + logger.info("Logging manager initialized") + + def add_log_record(self, record: LogRecord): + """ + Add a new log record. + + Args: + record: The log record to add + """ + with self.lock: + self.log_records.append(record) + + # Trim old records if we exceed max_records + if len(self.log_records) > self.max_records: + self.log_records = self.log_records[-self.max_records:] + + # Notify callbacks + for callback in self.log_callbacks: + try: + callback(record) + except Exception as e: + # Don't let callback errors break logging + print(f"Error in log callback: {e}") + + def register_callback(self, callback: Callable[[LogRecord], None]): + """ + Register a callback to be called when new log records are added. + + Args: + callback: Function to call with new log records + """ + with self.lock: + self.log_callbacks.append(callback) + + def unregister_callback(self, callback: Callable[[LogRecord], None]): + """ + Unregister a log callback. + + Args: + callback: The callback to remove + """ + with self.lock: + try: + self.log_callbacks.remove(callback) + except ValueError: + pass + + def get_logs(self, level_filter: Optional[str] = None, + module_filter: Optional[str] = None, + last_n: Optional[int] = None) -> List[LogRecord]: + """ + Get log records with optional filtering. + + Args: + level_filter: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + module_filter: Filter by module name (partial match) + last_n: Return only the last N records + + Returns: + List of filtered log records + """ + with self.lock: + records = self.log_records.copy() + + # Apply filters + if level_filter: + records = [r for r in records if r.level == level_filter] + + if module_filter: + records = [r for r in records if module_filter.lower() in r.logger_name.lower()] + + # Return last N records + if last_n: + records = records[-last_n:] + + return records + + def export_logs(self, filepath: str, level_filter: Optional[str] = None, + module_filter: Optional[str] = None) -> bool: + """ + Export logs to a file. + + Args: + filepath: Path to save the log file + level_filter: Filter by log level + module_filter: Filter by module name + + Returns: + True if export was successful, False otherwise + """ + try: + records = self.get_logs(level_filter, module_filter) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(f"VocaLinux Log Export\n") + f.write(f"Generated: {datetime.now().isoformat()}\n") + f.write(f"Total Records: {len(records)}\n") + if level_filter: + f.write(f"Level Filter: {level_filter}\n") + if module_filter: + f.write(f"Module Filter: {module_filter}\n") + f.write("=" * 80 + "\n\n") + + for record in records: + f.write(str(record) + "\n") + + logger.info(f"Exported {len(records)} log records to {filepath}") + return True + + except Exception as e: + logger.error(f"Failed to export logs: {e}") + return False + + def clear_logs(self): + """Clear all stored log records.""" + with self.lock: + self.log_records.clear() + logger.info("Log records cleared") + + def get_log_stats(self) -> dict: + """ + Get statistics about the current logs. + + Returns: + Dictionary with log statistics + """ + with self.lock: + records = self.log_records.copy() + + if not records: + return { + 'total': 0, + 'by_level': {}, + 'by_module': {}, + 'oldest': None, + 'newest': None + } + + # Count by level + by_level = {} + for record in records: + by_level[record.level] = by_level.get(record.level, 0) + 1 + + # Count by module + by_module = {} + for record in records: + module = record.logger_name.split('.')[0] if '.' in record.logger_name else record.logger_name + by_module[module] = by_module.get(module, 0) + 1 + + return { + 'total': len(records), + 'by_level': by_level, + 'by_module': by_module, + 'oldest': records[0].timestamp if records else None, + 'newest': records[-1].timestamp if records else None + } + + +class LoggingHandler(logging.Handler): + """Custom logging handler that feeds records to the LoggingManager.""" + + def __init__(self, logging_manager: LoggingManager): + super().__init__() + self.logging_manager = logging_manager + + def emit(self, record: logging.LogRecord): + """ + Emit a log record to the logging manager. + + Args: + record: The logging record to emit + """ + try: + # Create our custom log record + log_record = LogRecord( + timestamp=datetime.fromtimestamp(record.created), + level=record.levelname, + logger_name=record.name, + message=record.getMessage(), + module=getattr(record, 'module', record.name.split('.')[0]) + ) + + # Add to logging manager + self.logging_manager.add_log_record(log_record) + + except Exception: + # Don't let logging errors break the application + self.handleError(record) + + +# Global logging manager instance +_logging_manager: Optional[LoggingManager] = None + + +def get_logging_manager() -> LoggingManager: + """ + Get the global logging manager instance. + + Returns: + The global LoggingManager instance + """ + global _logging_manager + if _logging_manager is None: + _logging_manager = LoggingManager() + return _logging_manager + + +def initialize_logging(): + """Initialize the global logging manager.""" + global _logging_manager + if _logging_manager is None: + _logging_manager = LoggingManager() + logger.info("Global logging manager initialized") \ No newline at end of file diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index ab891c3..81dada1 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -165,6 +165,7 @@ class TrayIndicator: self._add_menu_item("Stop Voice Typing", self._on_stop_clicked) self._add_menu_separator() self._add_menu_item("Settings", self._on_settings_clicked) + self._add_menu_item("View Logs", self._on_logs_clicked) self._add_menu_separator() self._add_menu_item("About", self._on_about_clicked) self._add_menu_item("Quit", self._on_quit_clicked) @@ -283,6 +284,17 @@ class TrayIndicator: # Show the dialog (non-modal) dialog.show() + def _on_logs_clicked(self, widget): + """Handle click on the View Logs menu item.""" + logger.debug("View Logs clicked") + + # Import here to avoid circular imports + from .logging_dialog import LoggingDialog + + # Create and show the logging dialog + dialog = LoggingDialog(parent=None) + dialog.show() + def _on_settings_dialog_response(self, dialog, response): """Handle responses from the settings dialog.""" if response == Gtk.ResponseType.APPLY: