From 986793632eb7ed4660581cc5a8d69f236a595656 Mon Sep 17 00:00:00 2001 From: claudi Date: Wed, 25 Feb 2026 13:26:46 +0100 Subject: [PATCH] Refactor SettingsDialog for improved readability and maintainability - Cleaned up whitespace and formatting throughout the settings_dialog.py file. - Enhanced type hints for better clarity and type checking. - Consolidated URL mapping handling in get_config_data method. - Improved error handling and logging for configuration operations. - Added comments for better understanding of the code structure and functionality. --- src/webdrop_bridge/core/config_manager.py | 46 +- src/webdrop_bridge/core/updater.py | 3 - src/webdrop_bridge/ui/main_window.py | 623 +++++++++++----------- src/webdrop_bridge/ui/settings_dialog.py | 276 +++++----- 4 files changed, 468 insertions(+), 480 deletions(-) diff --git a/src/webdrop_bridge/core/config_manager.py b/src/webdrop_bridge/core/config_manager.py index 3b0f313..aeedf81 100644 --- a/src/webdrop_bridge/core/config_manager.py +++ b/src/webdrop_bridge/core/config_manager.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) class ConfigValidator: """Validates configuration values against schema. - + Provides detailed error messages for invalid configurations. """ @@ -33,10 +33,10 @@ class ConfigValidator: @staticmethod def validate(config_dict: Dict[str, Any]) -> List[str]: """Validate configuration dictionary. - + Args: config_dict: Configuration dictionary to validate - + Returns: List of validation error messages (empty if valid) """ @@ -53,7 +53,9 @@ class ConfigValidator: # Check type expected_type = rules.get("type") if expected_type and not isinstance(value, expected_type): - errors.append(f"{field}: expected {expected_type.__name__}, got {type(value).__name__}") + errors.append( + f"{field}: expected {expected_type.__name__}, got {type(value).__name__}" + ) continue # Check allowed values @@ -84,10 +86,10 @@ class ConfigValidator: @staticmethod def validate_or_raise(config_dict: Dict[str, Any]) -> None: """Validate configuration and raise error if invalid. - + Args: config_dict: Configuration dictionary to validate - + Raises: ConfigurationError: If configuration is invalid """ @@ -98,26 +100,26 @@ class ConfigValidator: class ConfigProfile: """Manages named configuration profiles. - + Profiles are stored in ~/.webdrop-bridge/profiles/ directory as JSON files. """ PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles" - def __init__(self): + def __init__(self) -> None: """Initialize profile manager.""" self.PROFILES_DIR.mkdir(parents=True, exist_ok=True) def save_profile(self, profile_name: str, config: Config) -> Path: """Save configuration as a named profile. - + Args: profile_name: Name of the profile (e.g., "work", "personal") config: Config object to save - + Returns: Path to the saved profile file - + Raises: ConfigurationError: If profile name is invalid """ @@ -148,13 +150,13 @@ class ConfigProfile: def load_profile(self, profile_name: str) -> Dict[str, Any]: """Load configuration from a named profile. - + Args: profile_name: Name of the profile to load - + Returns: Configuration dictionary - + Raises: ConfigurationError: If profile not found or invalid """ @@ -173,7 +175,7 @@ class ConfigProfile: def list_profiles(self) -> List[str]: """List all available profiles. - + Returns: List of profile names (without .json extension) """ @@ -184,10 +186,10 @@ class ConfigProfile: def delete_profile(self, profile_name: str) -> None: """Delete a profile. - + Args: profile_name: Name of the profile to delete - + Raises: ConfigurationError: If profile not found """ @@ -209,11 +211,11 @@ class ConfigExporter: @staticmethod def export_to_json(config: Config, output_path: Path) -> None: """Export configuration to JSON file. - + Args: config: Config object to export output_path: Path to write JSON file - + Raises: ConfigurationError: If export fails """ @@ -240,13 +242,13 @@ class ConfigExporter: @staticmethod def import_from_json(input_path: Path) -> Dict[str, Any]: """Import configuration from JSON file. - + Args: input_path: Path to JSON file to import - + Returns: Configuration dictionary - + Raises: ConfigurationError: If import fails or validation fails """ diff --git a/src/webdrop_bridge/core/updater.py b/src/webdrop_bridge/core/updater.py index 3de4b9f..8a50766 100644 --- a/src/webdrop_bridge/core/updater.py +++ b/src/webdrop_bridge/core/updater.py @@ -231,9 +231,6 @@ class UpdateManager: except socket.timeout as e: logger.error(f"Socket timeout (5s) connecting to {self.api_endpoint}") return None - except TimeoutError as e: - logger.error(f"Timeout error: {e}") - return None except Exception as e: logger.error(f"Failed to fetch release: {type(e).__name__}: {e}") import traceback diff --git a/src/webdrop_bridge/ui/main_window.py b/src/webdrop_bridge/ui/main_window.py index 0a12eca..ad6d12e 100644 --- a/src/webdrop_bridge/ui/main_window.py +++ b/src/webdrop_bridge/ui/main_window.py @@ -28,6 +28,7 @@ from PySide6.QtWebEngineCore import QWebEngineDownloadRequest, QWebEngineScript from PySide6.QtWidgets import ( QLabel, QMainWindow, + QMessageBox, QSizePolicy, QSpacerItem, QStatusBar, @@ -201,41 +202,41 @@ DEFAULT_WELCOME_PAGE = """ class _DragBridge(QObject): """JavaScript bridge for drag operations via QWebChannel. - + Exposed to JavaScript as 'bridge' object. """ - - def __init__(self, window: 'MainWindow', parent: Optional[QObject] = None): + + def __init__(self, window: "MainWindow", parent: Optional[QObject] = None): """Initialize the drag bridge. - + Args: window: MainWindow instance parent: Parent QObject """ super().__init__(parent) self.window = window - + @Slot(str) def start_file_drag(self, path_text: str) -> None: """Start a native file drag for the given path or Azure URL. - + Called from JavaScript when user drags an item. Accepts either local file paths or Azure Blob Storage URLs. Defers execution to avoid Qt drag manager state issues. - + Args: path_text: File path string or Azure URL to drag """ logger.debug(f"Bridge: start_file_drag called for {path_text}") - + # Defer to avoid drag manager state issues # handle_drag() handles URL conversion and validation internally QTimer.singleShot(0, lambda: self.window.drag_interceptor.handle_drag(path_text)) - + @Slot(str) def debug_log(self, message: str) -> None: """Log debug message from JavaScript. - + Args: message: Debug message from JavaScript """ @@ -279,16 +280,18 @@ class MainWindow(QMainWindow): config.window_width, config.window_height, ) - + # Set window icon # Support both development mode and PyInstaller bundle - if hasattr(sys, '_MEIPASS'): + if hasattr(sys, "_MEIPASS"): # Running as PyInstaller bundle - icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore + icon_path = Path(sys._MEIPASS) / "resources" / "icons" / "app.ico" # type: ignore else: # Running in development mode - icon_path = Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico" - + icon_path = ( + Path(__file__).parent.parent.parent.parent / "resources" / "icons" / "app.ico" + ) + if icon_path.exists(): self.setWindowIcon(QIcon(str(icon_path))) logger.debug(f"Window icon set from {icon_path}") @@ -297,14 +300,14 @@ class MainWindow(QMainWindow): # Create web engine view self.web_view = RestrictedWebEngineView(config.allowed_urls) - + # Enable the main window and web view to receive drag events self.setAcceptDrops(True) self.web_view.setAcceptDrops(True) - + # Track ongoing drags from web view self._current_drag_url = None - + # Redirect JavaScript console messages to Python logger self.web_view.page().javaScriptConsoleMessage = self._on_js_console_message @@ -326,20 +329,20 @@ class MainWindow(QMainWindow): web_channel = QWebChannel(self) web_channel.registerObject("bridge", self._drag_bridge) self.web_view.page().setWebChannel(web_channel) - + # Install the drag bridge script self._install_bridge_script() - + # Connect to loadFinished to verify script injection self.web_view.loadFinished.connect(self._on_page_loaded) # Set up download handler profile = self.web_view.page().profile() logger.debug(f"Connecting download handler to profile: {profile}") - + # CRITICAL: Connect download handler BEFORE any page loads profile.downloadRequested.connect(self._on_download_requested) - + # Enable downloads by setting download path downloads_path = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.DownloadLocation @@ -347,7 +350,7 @@ class MainWindow(QMainWindow): if downloads_path: profile.setDownloadPath(downloads_path) logger.debug(f"Download path set to: {downloads_path}") - + logger.debug("Download handler connected successfully") # Set up central widget with layout @@ -381,14 +384,14 @@ class MainWindow(QMainWindow): # Local file path try: file_path = Path(webapp_url).resolve() - + # If path doesn't exist, try relative to application root # This handles both development and bundled (PyInstaller) modes if not file_path.exists(): # Try relative to application package root app_root = Path(__file__).parent.parent.parent.parent relative_path = app_root / webapp_url.lstrip("file:///").lstrip("./") - + if relative_path.exists(): file_path = relative_path else: @@ -396,7 +399,7 @@ class MainWindow(QMainWindow): alt_path = Path(webapp_url.lstrip("file:///").lstrip("./")).resolve() if alt_path.exists(): file_path = alt_path - + if not file_path.exists(): # Show welcome page with instructions welcome_html = DEFAULT_WELCOME_PAGE.format(version=self.config.app_version) @@ -404,11 +407,11 @@ class MainWindow(QMainWindow): return # Load local file - html_content = file_path.read_text(encoding='utf-8') - + html_content = file_path.read_text(encoding="utf-8") + # Inject WebChannel bridge JavaScript injected_html = self._inject_drag_bridge(html_content) - + # Load the modified HTML self.web_view.setHtml(injected_html, QUrl.fromLocalFile(file_path.parent)) @@ -419,34 +422,34 @@ class MainWindow(QMainWindow): def _install_bridge_script(self) -> None: """Install the drag bridge JavaScript via QWebEngineScript. - + Uses DocumentCreation injection point to ensure script runs as early as possible, before any page scripts that might interfere with drag events. - + Embeds qwebchannel.js inline to avoid CSP issues with qrc:// URLs. Injects configuration that bridge script uses for dynamic URL pattern matching. """ from PySide6.QtCore import QFile, QIODevice - + script = QWebEngineScript() script.setName("webdrop-bridge") script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentCreation) script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld) script.setRunsOnSubFrames(False) - + # Load qwebchannel.js from Qt resources (avoids CSP blocking qrc:// URLs) qwebchannel_code = "" qwebchannel_file = QFile(":/qtwebchannel/qwebchannel.js") if qwebchannel_file.open(QIODevice.OpenModeFlag.ReadOnly | QIODevice.OpenModeFlag.Text): - qwebchannel_code = bytes(qwebchannel_file.readAll()).decode('utf-8') # type: ignore + qwebchannel_code = bytes(qwebchannel_file.readAll()).decode("utf-8") # type: ignore qwebchannel_file.close() logger.debug("Loaded qwebchannel.js inline to avoid CSP issues") else: logger.warning("Failed to load qwebchannel.js from resources") - + # Generate configuration injection script config_code = self._generate_config_injection_script() - + # Load bridge script from file # Try multiple paths to support dev mode, PyInstaller bundle, and MSI installation # 1. Development mode: __file__.parent / script.js @@ -454,28 +457,28 @@ class MainWindow(QMainWindow): # 3. MSI installation: Same directory as executable script_path = None download_interceptor_path = None - + # List of paths to try in order of preference search_paths = [] - + # 1. Development mode search_paths.append(Path(__file__).parent / "bridge_script_intercept.js") - + # 2. PyInstaller bundle (via sys._MEIPASS) - if hasattr(sys, '_MEIPASS'): - search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "bridge_script_intercept.js") # type: ignore - + if hasattr(sys, "_MEIPASS"): + search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "bridge_script_intercept.js") # type: ignore + # 3. Installed executable's directory (handles MSI installation where all files are packaged together) exe_dir = Path(sys.executable).parent search_paths.append(exe_dir / "webdrop_bridge" / "ui" / "bridge_script_intercept.js") - + # Find the bridge script for path in search_paths: if path.exists(): script_path = path logger.debug(f"Found bridge script at: {script_path}") break - + if script_path is None: # Log all attempted paths for debugging logger.error("Bridge script NOT found at any expected location:") @@ -484,52 +487,58 @@ class MainWindow(QMainWindow): logger.error(f"sys._MEIPASS: {getattr(sys, '_MEIPASS', 'NOT SET')}") logger.error(f"sys.executable: {sys.executable}") logger.error(f"__file__: {__file__}") - + try: if script_path is None: - raise FileNotFoundError("bridge_script_intercept.js not found in any expected location") - - with open(script_path, 'r', encoding='utf-8') as f: + raise FileNotFoundError( + "bridge_script_intercept.js not found in any expected location" + ) + + with open(script_path, "r", encoding="utf-8") as f: bridge_code = f.read() - + # Load download interceptor using similar search path logic download_search_paths = [] download_search_paths.append(Path(__file__).parent / "download_interceptor.js") - if hasattr(sys, '_MEIPASS'): - download_search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "download_interceptor.js") # type: ignore - download_search_paths.append(exe_dir / "webdrop_bridge" / "ui" / "download_interceptor.js") - + if hasattr(sys, "_MEIPASS"): + download_search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "download_interceptor.js") # type: ignore + download_search_paths.append( + exe_dir / "webdrop_bridge" / "ui" / "download_interceptor.js" + ) + download_interceptor_code = "" for path in download_search_paths: if path.exists(): download_interceptor_path = path break - + if download_interceptor_path: try: - with open(download_interceptor_path, 'r', encoding='utf-8') as f: + with open(download_interceptor_path, "r", encoding="utf-8") as f: download_interceptor_code = f.read() logger.debug(f"Loaded download interceptor from {download_interceptor_path}") except (OSError, IOError) as e: logger.warning(f"Download interceptor exists but failed to load: {e}") else: logger.debug("Download interceptor not found (optional)") - + # Combine: qwebchannel.js + config + bridge script + download interceptor combined_code = qwebchannel_code + "\n\n" + config_code + "\n\n" + bridge_code - + if download_interceptor_code: combined_code += "\n\n" + download_interceptor_code - - logger.debug(f"Combined script size: {len(combined_code)} chars " - f"(qwebchannel: {len(qwebchannel_code)}, " - f"config: {len(config_code)}, " - f"bridge: {len(bridge_code)}, " - f"interceptor: {len(download_interceptor_code)})") + + logger.debug( + f"Combined script size: {len(combined_code)} chars " + f"(qwebchannel: {len(qwebchannel_code)}, " + f"config: {len(config_code)}, " + f"bridge: {len(bridge_code)}, " + f"interceptor: {len(download_interceptor_code)})" + ) logger.debug(f"URL mappings in config: {len(self.config.url_mappings)}") for i, mapping in enumerate(self.config.url_mappings): logger.debug(f" Mapping {i+1}: {mapping.url_prefix} β†’ {mapping.local_path}") - + script.setSourceCode(combined_code) self.web_view.page().scripts().insert(script) logger.debug(f"βœ… Successfully installed bridge script") @@ -539,35 +548,32 @@ class MainWindow(QMainWindow): logger.error(f"❌ Failed to load bridge script: {e}") logger.error(f" This will break drag-and-drop functionality!") # Don't re-raise - allow app to start (will show error in logs) - + def _generate_config_injection_script(self) -> str: """Generate JavaScript code that injects configuration. - + Creates a script that sets window.webdropConfig with the current URL mappings, allowing the bridge script to dynamically check against configured patterns instead of hardcoded values. - + Returns: JavaScript code as string """ # Convert URL mappings to format expected by bridge script mappings = [] for mapping in self.config.url_mappings: - mappings.append({ - "url_prefix": mapping.url_prefix, - "local_path": mapping.local_path - }) - + mappings.append({"url_prefix": mapping.url_prefix, "local_path": mapping.local_path}) + logger.debug(f"Generating config injection with {len(mappings)} URL mappings") for i, m in enumerate(mappings): logger.debug(f" [{i+1}] {m['url_prefix']} -> {m['local_path']}") - + # Generate config object as JSON config_obj = {"urlMappings": mappings} config_json = json.dumps(config_obj) - + logger.debug(f"Config JSON size: {len(config_json)} bytes") - + # Generate JavaScript code - Safe injection with error handling config_js = f""" (function() {{ @@ -593,16 +599,16 @@ class MainWindow(QMainWindow): }})(); """ return config_js - + def _inject_drag_bridge(self, html_content: str) -> str: """Return HTML content unmodified. - + The drag bridge script is now injected via QWebEngineScript in _install_bridge_script(). This method is kept for compatibility but does nothing. - + Args: html_content: Original HTML content - + Returns: HTML unchanged """ @@ -610,8 +616,9 @@ class MainWindow(QMainWindow): def _apply_stylesheet(self) -> None: """Apply application stylesheet if available.""" - stylesheet_path = Path(__file__).parent.parent.parent.parent / \ - "resources" / "stylesheets" / "default.qss" + stylesheet_path = ( + Path(__file__).parent.parent.parent.parent / "resources" / "stylesheets" / "default.qss" + ) if stylesheet_path.exists(): try: @@ -630,16 +637,16 @@ class MainWindow(QMainWindow): local_path: Local file path that is being dragged """ logger.info(f"Drag started: {source} -> {local_path}") - + # Ask user if they want to check out the asset - if source.startswith('http'): + if source.startswith("http"): self._prompt_checkout(source, local_path) - + def _prompt_checkout(self, azure_url: str, local_path: str) -> None: """Check checkout status and prompt user if needed. - + First checks if the asset is already checked out. Only shows dialog if not checked out. - + Args: azure_url: Azure Blob Storage URL local_path: Local file path @@ -648,18 +655,18 @@ class MainWindow(QMainWindow): # Extract filename for display filename = Path(local_path).name - + # Extract asset ID - match = re.search(r'/([^/]+)/[^/]+$', azure_url) + match = re.search(r"/([^/]+)/[^/]+$", azure_url) if not match: logger.warning(f"Could not extract asset ID from URL: {azure_url}") return - + asset_id = match.group(1) - + # Store callback ID for this check callback_id = f"checkout_check_{id(self)}" - + # Check checkout status - use callback approach since Qt doesn't handle Promise returns well js_code = f""" (async () => {{ @@ -702,22 +709,30 @@ class MainWindow(QMainWindow): }} }})(); """ - + # Execute the async fetch self.web_view.page().runJavaScript(js_code) - + # After a short delay, read the result from window variable def check_result(): read_code = f"window['{callback_id}']" - self.web_view.page().runJavaScript(read_code, lambda result: self._handle_checkout_status(result, azure_url, filename, callback_id)) - + self.web_view.page().runJavaScript( + read_code, + lambda result: self._handle_checkout_status( + result, azure_url, filename, callback_id + ), + ) + # Wait 500ms for async fetch to complete from PySide6.QtCore import QTimer + QTimer.singleShot(500, check_result) - - def _handle_checkout_status(self, result, azure_url: str, filename: str, callback_id: str) -> None: + + def _handle_checkout_status( + self, result, azure_url: str, filename: str, callback_id: str + ) -> None: """Handle the result of checkout status check. - + Args: result: Result from JavaScript (JSON string) azure_url: Azure URL @@ -727,56 +742,59 @@ class MainWindow(QMainWindow): # Clean up window variable cleanup_code = f"delete window['{callback_id}']" self.web_view.page().runJavaScript(cleanup_code) - + logger.debug(f"Checkout status result type: {type(result)}, value: {result}") - + if not result or not isinstance(result, str): logger.warning(f"Checkout status check returned invalid result: {result}") self._show_checkout_dialog(azure_url, filename) return - + # Parse JSON string try: import json + parsed_result = json.loads(result) except (json.JSONDecodeError, ValueError) as e: logger.warning(f"Failed to parse checkout status result: {e}") self._show_checkout_dialog(azure_url, filename) return - - if parsed_result.get('error'): + + if parsed_result.get("error"): logger.warning(f"Could not check checkout status: {parsed_result}") self._show_checkout_dialog(azure_url, filename) return - + # Check if already checked out - has_checkout = parsed_result.get('hasCheckout', False) + has_checkout = parsed_result.get("hasCheckout", False) if has_checkout: - checkout_info = parsed_result.get('checkout', {}) - logger.info(f"Asset {filename} is already checked out: {checkout_info}, skipping dialog") + checkout_info = parsed_result.get("checkout", {}) + logger.info( + f"Asset {filename} is already checked out: {checkout_info}, skipping dialog" + ) return - + # Not checked out, show confirmation dialog logger.debug(f"Asset {filename} is not checked out, showing dialog") self._show_checkout_dialog(azure_url, filename) - + def _show_checkout_dialog(self, azure_url: str, filename: str) -> None: """Show the checkout confirmation dialog. - + Args: azure_url: Azure Blob Storage URL filename: Asset filename """ from PySide6.QtWidgets import QMessageBox - + reply = QMessageBox.question( self, "Checkout Asset", f"Do you want to check out this asset?\n\n{filename}", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.Yes + QMessageBox.StandardButton.Yes, ) - + if reply == QMessageBox.StandardButton.Yes: logger.info(f"User confirmed checkout for {filename}") self._trigger_checkout_api(azure_url) @@ -796,14 +814,14 @@ class MainWindow(QMainWindow): try: # Extract asset ID from URL (middle segment between domain and filename) # Format: https://domain/container/ASSET_ID/filename - match = re.search(r'/([^/]+)/[^/]+$', azure_url) + match = re.search(r"/([^/]+)/[^/]+$", azure_url) if not match: logger.warning(f"Could not extract asset ID from URL: {azure_url}") return - + asset_id = match.group(1) logger.info(f"Extracted asset ID: {asset_id}") - + # Call API from JavaScript with Authorization header js_code = f""" (async function() {{ @@ -846,22 +864,22 @@ class MainWindow(QMainWindow): }} }})(); """ - + def on_result(result): """Callback when JavaScript completes.""" if result and isinstance(result, dict): - if result.get('success'): + if result.get("success"): logger.info(f"βœ… Checkout successful for asset {asset_id}") else: - status = result.get('status', 'unknown') - error = result.get('error', 'unknown error') + status = result.get("status", "unknown") + error = result.get("error", "unknown error") logger.warning(f"Checkout API returned status {status}: {error}") else: logger.debug(f"Checkout API call completed (result: {result})") - + # Execute JavaScript (async, non-blocking) self.web_view.page().runJavaScript(js_code, on_result) - + except Exception as e: logger.exception(f"Error triggering checkout API: {e}") @@ -873,7 +891,12 @@ class MainWindow(QMainWindow): error: Error message """ logger.warning(f"Drag failed for {source}: {error}") - # Can be extended with user notification or status bar message + # Show error dialog to user + QMessageBox.warning( + self, + "Drag-and-Drop Error", + f"Could not complete the drag-and-drop operation.\n\nError: {error}", + ) def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None: """Handle download requests from the embedded web view. @@ -890,49 +913,47 @@ class MainWindow(QMainWindow): logger.debug(f"Download mime type: {download.mimeType()}") logger.debug(f"Download suggested filename: {download.suggestedFileName()}") logger.debug(f"Download state: {download.state()}") - + # Get the system's Downloads folder downloads_path = QStandardPaths.writableLocation( QStandardPaths.StandardLocation.DownloadLocation ) - + if not downloads_path: # Fallback to user's home directory if Downloads folder not available downloads_path = str(Path.home()) logger.warning("Downloads folder not found, using home directory") - + # Use suggested filename if available, fallback to downloadFileName filename = download.suggestedFileName() or download.downloadFileName() if not filename: filename = "download" logger.warning("No filename suggested, using 'download'") - + # Construct full download path download_file = Path(downloads_path) / filename logger.debug(f"Download will be saved to: {download_file}") - + # Set download path and accept download.setDownloadDirectory(str(download_file.parent)) download.setDownloadFileName(download_file.name) download.accept() - + logger.info(f"Download started: {filename}") - + # Update status bar (temporarily) - self.status_bar.showMessage( - f"πŸ“₯ Download: {filename}", 3000 - ) - + self.status_bar.showMessage(f"πŸ“₯ Download: {filename}", 3000) + # Connect to state changed for progress tracking download.stateChanged.connect( lambda state: logger.debug(f"Download state changed to: {state}") ) - + # Connect to finished signal for completion feedback download.isFinishedChanged.connect( lambda: self._on_download_finished(download, download_file) ) - + except Exception as e: logger.error(f"Error handling download: {e}", exc_info=True) self.status_bar.showMessage(f"Download error: {e}", 5000) @@ -947,67 +968,61 @@ class MainWindow(QMainWindow): try: if not download.isFinished(): return - + state = download.state() logger.debug(f"Download finished with state: {state}") - + if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted: logger.info(f"Download completed: {file_path.name}") - self.status_bar.showMessage( - f"Download completed: {file_path.name}", 5000 - ) + self.status_bar.showMessage(f"Download completed: {file_path.name}", 5000) elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled: logger.info(f"Download cancelled: {file_path.name}") - self.status_bar.showMessage( - f"⚠️ Download abgebrochen: {file_path.name}", 3000 - ) + self.status_bar.showMessage(f"⚠️ Download abgebrochen: {file_path.name}", 3000) elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted: logger.warning(f"Download interrupted: {file_path.name}") - self.status_bar.showMessage( - f"❌ Download fehlgeschlagen: {file_path.name}", 5000 - ) + self.status_bar.showMessage(f"❌ Download fehlgeschlagen: {file_path.name}", 5000) except Exception as e: logger.error(f"Error in download finished handler: {e}", exc_info=True) - + def dragEnterEvent(self, event): """Handle drag entering the main window (from WebView or external). - + Note: With intercept script, ALT-drags are prevented in JavaScript and handled via bridge.start_file_drag(). This just handles any remaining drag events. - + Args: event: QDragEnterEvent """ event.ignore() - + def dragMoveEvent(self, event): """Handle drag moving over the main window. - + Args: event: QDragMoveEvent """ event.ignore() - + def dragLeaveEvent(self, event): """Handle drag leaving the main window. - + Args: - event: QDragLeaveEvent + event: QDragLeaveEvent """ event.ignore() - + def dropEvent(self, event): """Handle drop on the main window. - + Args: event: QDropEvent """ event.ignore() - + def _on_js_console_message(self, level, message, line_number, source_id): """Redirect JavaScript console messages to Python logger. - + Args: level: Console message level (JavaScriptConsoleMessageLevel enum) message: The console message @@ -1025,19 +1040,19 @@ class MainWindow(QMainWindow): else: # ErrorMessageLevel logger.error(f"JS Console: {message}") logger.debug(f" at {source_id}:{line_number}") - + def _on_page_loaded(self, success: bool) -> None: """Called when a page finishes loading. - + Checks if the bridge script was successfully injected. - + Args: success: True if page loaded successfully """ if not success: logger.warning("Page failed to load") return - + # Check if bridge script is loaded def check_script(result): if result: @@ -1046,11 +1061,11 @@ class MainWindow(QMainWindow): else: logger.error("WebDrop Bridge script NOT loaded!") logger.error("Drag-and-drop conversion will not work") - + # Execute JS to check if our script is loaded self.web_view.page().runJavaScript( - "typeof window.__webdrop_bridge_injected !== 'undefined' && window.__webdrop_bridge_injected === true", - check_script + "typeof window.__webdrop_intercept_injected !== 'undefined' && window.__webdrop_intercept_injected === true", + check_script, ) def _create_navigation_toolbar(self) -> None: @@ -1065,29 +1080,25 @@ class MainWindow(QMainWindow): self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar) # Back button - back_action = self.web_view.pageAction( - self.web_view.page().WebAction.Back - ) + back_action = self.web_view.pageAction(self.web_view.page().WebAction.Back) toolbar.addAction(back_action) # Forward button - forward_action = self.web_view.pageAction( - self.web_view.page().WebAction.Forward - ) + forward_action = self.web_view.pageAction(self.web_view.page().WebAction.Forward) toolbar.addAction(forward_action) # Separator toolbar.addSeparator() # Home button - home_action = toolbar.addAction(self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon), "") + home_action = toolbar.addAction( + self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon), "" + ) home_action.setToolTip("Home") home_action.triggered.connect(self._navigate_home) # Refresh button - refresh_action = self.web_view.pageAction( - self.web_view.page().WebAction.Reload - ) + refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload) toolbar.addAction(refresh_action) # Add stretch spacer to push help buttons to the right @@ -1113,7 +1124,7 @@ class MainWindow(QMainWindow): def _create_status_bar(self) -> None: """Create status bar with update status indicator.""" self.status_bar = self.statusBar() - + # Update status label self.update_status_label = QLabel("Ready") self.update_status_label.setStyleSheet("margin-right: 10px;") @@ -1121,7 +1132,7 @@ class MainWindow(QMainWindow): def set_update_status(self, status: str, emoji: str = "") -> None: """Update the status bar with update information. - + Args: status: Status text to display emoji: Optional emoji prefix (rotating, checkmark, download, warning symbols) @@ -1133,27 +1144,27 @@ class MainWindow(QMainWindow): def _on_manual_check_for_updates(self) -> None: """Handle manual check for updates from menu. - + Triggers an immediate update check (bypass cache) with user feedback dialog. """ logger.info("Manual update check requested from menu") - + # Show "Checking for Updates..." dialog from webdrop_bridge.ui.update_manager_ui import CheckingDialog - + self.checking_dialog = CheckingDialog(self) self._is_manual_check = True - + # Start the update check self.check_for_updates_startup() - + # Show the dialog self.checking_dialog.show() def _show_about_dialog(self) -> None: """Show About dialog with version and information.""" from PySide6.QtWidgets import QMessageBox - + about_text = ( f"{self.config.app_name}
" f"Version: {self.config.app_version}
" @@ -1174,13 +1185,13 @@ class MainWindow(QMainWindow): f"
" f"Β© 2026 HΓΆrl Information Management GmbH. All rights reserved." ) - + QMessageBox.about(self, f"About {self.config.app_name}", about_text) def _show_settings_dialog(self) -> None: """Show Settings dialog for configuration management.""" from webdrop_bridge.ui.settings_dialog import SettingsDialog - + dialog = SettingsDialog(self.config, self) dialog.exec() @@ -1208,10 +1219,10 @@ class MainWindow(QMainWindow): event: Close event """ logger.debug("Closing application - cleaning up web engine resources") - + # Properly delete WebEnginePage before the profile is released # This ensures cookies and session data are saved correctly - if hasattr(self, 'web_view') and self.web_view: + if hasattr(self, "web_view") and self.web_view: page = self.web_view.page() if page: # Disconnect signals to prevent callbacks during shutdown @@ -1219,95 +1230,93 @@ class MainWindow(QMainWindow): page.loadFinished.disconnect() except RuntimeError: pass # Already disconnected or never connected - + # Delete the page explicitly page.deleteLater() logger.debug("WebEnginePage scheduled for deletion") - + # Clear the page from the view - self.web_view.setPage(None) # type: ignore - + self.web_view.setPage(None) # type: ignore + event.accept() def check_for_updates_startup(self) -> None: """Check for updates on application startup. - + Runs asynchronously in background without blocking UI. Uses 24-hour cache so will not hammer the API. """ from webdrop_bridge.core.updater import UpdateManager - + try: # Create update manager cache_dir = Path.home() / ".webdrop-bridge" - manager = UpdateManager( - current_version=self.config.app_version, - config_dir=cache_dir - ) - + manager = UpdateManager(current_version=self.config.app_version, config_dir=cache_dir) + # Run async check in background self._run_async_check(manager) - + except Exception as e: logger.error(f"Failed to initialize update check: {e}") def _run_async_check(self, manager) -> None: """Run update check in background thread with safety timeout. - + Args: manager: UpdateManager instance """ try: logger.debug("_run_async_check() starting") - + # Create and start background thread thread = QThread() worker = UpdateCheckWorker(manager, self.config.app_version) - + # IMPORTANT: Keep references to prevent garbage collection # Store in a list to keep worker alive during thread execution self._background_threads.append(thread) - self._background_workers = getattr(self, '_background_workers', {}) + self._background_workers = getattr(self, "_background_workers", {}) self._background_workers[id(thread)] = worker - + logger.debug(f"Created worker and thread, thread id: {id(thread)}") - + # Create a safety timeout timer (but don't start it yet) # Use a flag-based approach to avoid thread issues with stopping timers check_started_time = [datetime.now()] # Track when check started check_completed = [False] # Flag to mark when check completes - + def force_close_timeout(): # Check if already completed - if so, don't show error if check_completed[0]: logger.debug("Timeout fired but check already completed, suppressing error") return - + logger.warning("Update check taking too long (30s timeout)") - if hasattr(self, 'checking_dialog') and self.checking_dialog: + if hasattr(self, "checking_dialog") and self.checking_dialog: self.checking_dialog.close() self.set_update_status("Check timed out - no server response", emoji="⏱️") - + # Show error dialog from PySide6.QtWidgets import QMessageBox + QMessageBox.warning( self, "Update Check Timeout", "The server did not respond within 30 seconds.\n\n" "This may be due to a network issue or server unavailability.\n\n" - "Please check your connection and try again." + "Please check your connection and try again.", ) - + safety_timer = QTimer() safety_timer.setSingleShot(True) safety_timer.setInterval(30000) # 30 seconds safety_timer.timeout.connect(force_close_timeout) - + # Mark check as completed when any result arrives def on_check_done(): logger.debug("Check finished, marking as completed to prevent timeout error") check_completed[0] = True - + # Connect signals worker.update_available.connect(self._on_update_available) worker.update_available.connect(on_check_done) @@ -1318,7 +1327,7 @@ class MainWindow(QMainWindow): worker.finished.connect(thread.quit) worker.finished.connect(worker.deleteLater) thread.finished.connect(thread.deleteLater) - + # Clean up finished threads and workers from list def cleanup_thread(): logger.debug(f"Cleaning up thread {id(thread)}") @@ -1326,99 +1335,97 @@ class MainWindow(QMainWindow): self._background_threads.remove(thread) if id(thread) in self._background_workers: del self._background_workers[id(thread)] - + thread.finished.connect(cleanup_thread) - + # Move worker to thread and start logger.debug("Moving worker to thread and connecting started signal") worker.moveToThread(thread) thread.started.connect(worker.run) - + logger.debug("Starting thread...") thread.start() logger.debug("Thread started, starting safety timer") - + # Start the safety timeout safety_timer.start() - + except Exception as e: logger.error(f"Failed to start update check thread: {e}", exc_info=True) def _on_update_status(self, status: str, emoji: str) -> None: """Handle update status changes. - + Args: status: Status text emoji: Status emoji """ self.set_update_status(status, emoji) - + # If this is a manual check and we get the "Ready" status, it means no updates if self._is_manual_check and status == "Ready": # Close checking dialog first, then show result - if hasattr(self, 'checking_dialog') and self.checking_dialog: + if hasattr(self, "checking_dialog") and self.checking_dialog: self.checking_dialog.close() - + from webdrop_bridge.ui.update_manager_ui import NoUpdateDialog + dialog = NoUpdateDialog(parent=self) self._is_manual_check = False dialog.exec() def _on_check_failed(self, error_message: str) -> None: """Handle update check failure. - + Args: error_message: Error description """ logger.error(f"Update check failed: {error_message}") self.set_update_status(f"Check failed: {error_message}", emoji="❌") self._is_manual_check = False - + # Close checking dialog first, then show error - if hasattr(self, 'checking_dialog') and self.checking_dialog: + if hasattr(self, "checking_dialog") and self.checking_dialog: self.checking_dialog.close() - + from PySide6.QtWidgets import QMessageBox + QMessageBox.warning( self, "Update Check Failed", - f"Could not check for updates:\n\n{error_message}\n\nPlease try again later." + f"Could not check for updates:\n\n{error_message}\n\nPlease try again later.", ) def _on_update_available(self, release) -> None: """Handle update available notification. - + Args: release: Release object with update info """ # Update status to show update available self.set_update_status(f"Update available: v{release.version}", emoji="βœ…") - + # Show update available dialog from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog - - dialog = UpdateAvailableDialog( - version=release.version, - changelog=release.body, - parent=self - ) - + + dialog = UpdateAvailableDialog(version=release.version, changelog=release.body, parent=self) + # Connect dialog signals dialog.update_now.connect(lambda: self._on_user_update_now(release)) dialog.update_later.connect(lambda: self._on_user_update_later()) dialog.skip_version.connect(lambda: self._on_user_skip_version(release.version)) - + # Show dialog (modal) dialog.exec() def _on_user_update_now(self, release) -> None: """Handle user clicking 'Update Now' button. - + Args: release: Release object to download and install """ logger.info(f"User clicked 'Update Now' for v{release.version}") - + # Start download self._start_update_download(release) @@ -1429,58 +1436,57 @@ class MainWindow(QMainWindow): def _on_user_skip_version(self, version: str) -> None: """Handle user clicking 'Skip Version' button. - + Args: version: Version to skip """ logger.info(f"User skipped version {version}") - + # Store skipped version in preferences skipped_file = Path.home() / ".webdrop-bridge" / "skipped_version.txt" skipped_file.parent.mkdir(parents=True, exist_ok=True) skipped_file.write_text(version) - + self.set_update_status(f"Skipped v{version}", emoji="") def _start_update_download(self, release) -> None: """Start downloading the update in background thread. - + Args: release: Release object to download """ logger.info(f"Starting download for v{release.version}") self.set_update_status(f"Downloading v{release.version}", emoji="⬇️") - + # Run download in background thread to avoid blocking UI self._perform_update_async(release) def _perform_update_async(self, release) -> None: """Download and install update asynchronously in background thread. - + Args: release: Release object to download and install """ from webdrop_bridge.core.updater import UpdateManager - + try: logger.debug("_perform_update_async() starting") - + # Create update manager manager = UpdateManager( - current_version=self.config.app_version, - config_dir=Path.home() / ".webdrop-bridge" + current_version=self.config.app_version, config_dir=Path.home() / ".webdrop-bridge" ) - + # Create and start background thread thread = QThread() worker = UpdateDownloadWorker(manager, release, self.config.app_version) - + # IMPORTANT: Keep references to prevent garbage collection self._background_threads.append(thread) self._background_workers[id(thread)] = worker - + logger.debug(f"Created download worker and thread, thread id: {id(thread)}") - + # Connect signals worker.download_complete.connect(self._on_download_complete) worker.download_failed.connect(self._on_download_failed) @@ -1488,37 +1494,37 @@ class MainWindow(QMainWindow): worker.finished.connect(thread.quit) worker.finished.connect(worker.deleteLater) thread.finished.connect(thread.deleteLater) - + # Create a safety timeout timer for download (10 minutes) # Use a flag-based approach to avoid thread issues with stopping timers download_started_time = [datetime.now()] # Track when download started download_completed = [False] # Flag to mark when download completes - + def force_timeout(): # Check if already completed - if so, don't show error if download_completed[0]: logger.debug("Timeout fired but download already completed, suppressing error") return - + logger.error("Download taking too long (10 minute timeout)") self.set_update_status("Download timed out - no server response", emoji="⏱️") worker.download_failed.emit("Download took too long with no response") thread.quit() thread.wait() - + safety_timer = QTimer() safety_timer.setSingleShot(True) safety_timer.setInterval(600000) # 10 minutes safety_timer.timeout.connect(force_timeout) - + # Mark download as completed when it finishes def on_download_done(): logger.debug("Download finished, marking as completed to prevent timeout error") download_completed[0] = True - + worker.download_complete.connect(on_download_done) worker.download_failed.connect(on_download_done) - + # Clean up finished threads from list def cleanup_thread(): logger.debug(f"Cleaning up download thread {id(thread)}") @@ -1526,9 +1532,9 @@ class MainWindow(QMainWindow): self._background_threads.remove(thread) if id(thread) in self._background_workers: del self._background_workers[id(thread)] - + thread.finished.connect(cleanup_thread) - + # Start thread logger.debug("Moving download worker to thread and connecting started signal") worker.moveToThread(thread) @@ -1536,63 +1542,61 @@ class MainWindow(QMainWindow): logger.debug("Starting download thread...") thread.start() logger.debug("Download thread started, starting safety timer") - + # Start the safety timeout safety_timer.start() - + except Exception as e: logger.error(f"Failed to start update download: {e}") self.set_update_status(f"Update failed: {str(e)[:30]}", emoji="❌") def _on_download_complete(self, installer_path: Path) -> None: """Handle successful download and verification. - + Args: installer_path: Path to downloaded and verified installer """ from webdrop_bridge.ui.update_manager_ui import InstallDialog - + logger.info(f"Download complete: {installer_path}") self.set_update_status("Ready to install", emoji="βœ…") - + # Show install confirmation dialog install_dialog = InstallDialog(parent=self) - install_dialog.install_now.connect( - lambda: self._do_install(installer_path) - ) + install_dialog.install_now.connect(lambda: self._do_install(installer_path)) install_dialog.exec() def _on_download_failed(self, error: str) -> None: """Handle download failure. - + Args: error: Error message """ logger.error(f"Download failed: {error}") self.set_update_status(error, emoji="❌") - + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical( self, "Download Failed", - f"Could not download the update:\n\n{error}\n\nPlease try again later." + f"Could not download the update:\n\n{error}\n\nPlease try again later.", ) def _do_install(self, installer_path: Path) -> None: """Execute the installer. - + Args: installer_path: Path to installer executable """ logger.info(f"Installing from {installer_path}") - + from webdrop_bridge.core.updater import UpdateManager - + manager = UpdateManager( - current_version=self.config.app_version, - config_dir=Path.home() / ".webdrop-bridge" + current_version=self.config.app_version, config_dir=Path.home() / ".webdrop-bridge" ) - + if manager.install_update(installer_path): self.set_update_status("Installation started", emoji="βœ…") logger.info("Update installer launched successfully") @@ -1606,13 +1610,13 @@ class UpdateCheckWorker(QObject): # Define signals at class level update_available = Signal(object) # Emits Release object - update_status = Signal(str, str) # Emits (status_text, emoji) - check_failed = Signal(str) # Emits error message + update_status = Signal(str, str) # Emits (status_text, emoji) + check_failed = Signal(str) # Emits error message finished = Signal() def __init__(self, manager, current_version: str): """Initialize worker. - + Args: manager: UpdateManager instance current_version: Current app version @@ -1626,26 +1630,23 @@ class UpdateCheckWorker(QObject): loop = None try: logger.debug("UpdateCheckWorker.run() starting") - + # Notify checking status self.update_status.emit("Checking for updates", "πŸ”„") - + # Create a fresh event loop for this thread logger.debug("Creating new event loop for worker thread") loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: # Check for updates with short timeout (network call has its own timeout) logger.debug("Starting update check with 10-second timeout") release = loop.run_until_complete( - asyncio.wait_for( - self.manager.check_for_updates(), - timeout=10 - ) + asyncio.wait_for(self.manager.check_for_updates(), timeout=10) ) logger.debug(f"Update check completed, release={release}") - + # Emit result if release: logger.info(f"Update available: {release.version}") @@ -1654,11 +1655,11 @@ class UpdateCheckWorker(QObject): # No update available - show ready status logger.info("No update available") self.update_status.emit("Ready", "") - + except asyncio.TimeoutError: logger.warning("Update check timed out - server not responding") self.check_failed.emit("Server not responding - check again later") - + except Exception as e: logger.error(f"Update check failed: {e}", exc_info=True) self.check_failed.emit(f"Check failed: {str(e)[:50]}") @@ -1679,13 +1680,13 @@ class UpdateDownloadWorker(QObject): # Define signals at class level download_complete = Signal(Path) # Emits installer_path - download_failed = Signal(str) # Emits error message + download_failed = Signal(str) # Emits error message update_status = Signal(str, str) # Emits (status_text, emoji) finished = Signal() def __init__(self, manager, release, current_version: str): """Initialize worker. - + Args: manager: UpdateManager instance release: Release object to download @@ -1702,56 +1703,54 @@ class UpdateDownloadWorker(QObject): try: # Download the update self.update_status.emit(f"Downloading v{self.release.version}", "⬇️") - + # Create a fresh event loop for this thread loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: # Download with 5 minute timeout (300 seconds) logger.info("Starting download with 5-minute timeout") installer_path = loop.run_until_complete( - asyncio.wait_for( - self.manager.download_update(self.release), - timeout=300 - ) + asyncio.wait_for(self.manager.download_update(self.release), timeout=300) ) - + if not installer_path: self.update_status.emit("Download failed", "❌") self.download_failed.emit("No installer found in release") logger.error("Download failed - no installer found") return - + logger.info(f"Downloaded to {installer_path}") self.update_status.emit("Verifying download", "πŸ”") - + # Verify checksum with 30 second timeout logger.info("Starting checksum verification") checksum_ok = loop.run_until_complete( asyncio.wait_for( - self.manager.verify_checksum(installer_path, self.release), - timeout=30 + self.manager.verify_checksum(installer_path, self.release), timeout=30 ) ) - + if not checksum_ok: self.update_status.emit("Verification failed", "❌") self.download_failed.emit("Checksum verification failed") logger.error("Checksum verification failed") return - + logger.info("Checksum verification passed") self.download_complete.emit(installer_path) - + except asyncio.TimeoutError as e: logger.error(f"Download/verification timed out: {e}") self.update_status.emit("Operation timed out", "⏱️") - self.download_failed.emit("Download or verification timed out (no response from server)") + self.download_failed.emit( + "Download or verification timed out (no response from server)" + ) except Exception as e: logger.error(f"Error during download: {e}") self.download_failed.emit(f"Download error: {str(e)[:50]}") - + except Exception as e: logger.error(f"Download worker failed: {e}") self.download_failed.emit(f"Download error: {str(e)[:50]}") diff --git a/src/webdrop_bridge/ui/settings_dialog.py b/src/webdrop_bridge/ui/settings_dialog.py index dcbd016..6284ea0 100644 --- a/src/webdrop_bridge/ui/settings_dialog.py +++ b/src/webdrop_bridge/ui/settings_dialog.py @@ -2,10 +2,11 @@ import logging from pathlib import Path -from typing import List, Optional +from typing import Any, Dict, List, Optional from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QComboBox, QDialog, QDialogButtonBox, QFileDialog, @@ -32,7 +33,7 @@ logger = logging.getLogger(__name__) class SettingsDialog(QDialog): """Dialog for managing application settings and configuration. - + Provides tabs for: - Paths: Manage allowed root directories - URLs: Manage allowed web URLs @@ -41,9 +42,9 @@ class SettingsDialog(QDialog): - Profiles: Save/load/delete configuration profiles """ - def __init__(self, config: Config, parent=None): + def __init__(self, config: Config, parent: Optional[QWidget] = None): """Initialize the settings dialog. - + Args: config: Current application configuration parent: Parent widget @@ -53,16 +54,16 @@ class SettingsDialog(QDialog): self.profile_manager = ConfigProfile() self.setWindowTitle("Settings") self.setGeometry(100, 100, 600, 500) - + self.setup_ui() def setup_ui(self) -> None: """Set up the dialog UI with tabs.""" layout = QVBoxLayout() - + # Create tab widget self.tabs = QTabWidget() - + # Add tabs self.tabs.addTab(self._create_web_source_tab(), "Web Source") self.tabs.addTab(self._create_paths_tab(), "Paths") @@ -70,9 +71,9 @@ class SettingsDialog(QDialog): self.tabs.addTab(self._create_logging_tab(), "Logging") self.tabs.addTab(self._create_window_tab(), "Window") self.tabs.addTab(self._create_profiles_tab(), "Profiles") - + layout.addWidget(self.tabs) - + # Add buttons button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel @@ -80,12 +81,12 @@ class SettingsDialog(QDialog): button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) - + self.setLayout(layout) def accept(self) -> None: """Handle OK button - save configuration changes to file. - + Validates configuration and saves to the default config path. Applies log level changes immediately in the running application. If validation or save fails, shows error and stays in dialog. @@ -93,50 +94,49 @@ class SettingsDialog(QDialog): try: # Get updated configuration data from UI config_data = self.get_config_data() - + # Convert URL mappings from dict to URLMapping objects from webdrop_bridge.config import URLMapping - + url_mappings = [ - URLMapping( - url_prefix=m["url_prefix"], - local_path=m["local_path"] - ) + URLMapping(url_prefix=m["url_prefix"], local_path=m["local_path"]) for m in config_data["url_mappings"] ] - + # Update the config object with new values old_log_level = self.config.log_level self.config.log_level = config_data["log_level"] - self.config.log_file = Path(config_data["log_file"]) if config_data["log_file"] else None + self.config.log_file = ( + Path(config_data["log_file"]) if config_data["log_file"] else None + ) self.config.allowed_roots = [Path(r).resolve() for r in config_data["allowed_roots"]] self.config.allowed_urls = config_data["allowed_urls"] self.config.webapp_url = config_data["webapp_url"] self.config.url_mappings = url_mappings self.config.window_width = config_data["window_width"] self.config.window_height = config_data["window_height"] - + # Save to file (creates parent dirs if needed) config_path = Config.get_default_config_path() self.config.to_file(config_path) - + logger.info(f"Configuration saved to {config_path}") logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})") logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}") - + # Apply log level change immediately to running application if old_log_level != self.config.log_level: logger.info(f"πŸ”„ Updating log level: {old_log_level} β†’ {self.config.log_level}") reconfigure_logging( logger_name="webdrop_bridge", level=self.config.log_level, - log_file=self.config.log_file + log_file=self.config.log_file, ) logger.info(f"βœ… Log level updated to {self.config.log_level}") - + # Call parent accept to close dialog super().accept() - + except ConfigurationError as e: logger.error(f"Configuration error: {e}") self._show_error(f"Configuration Error:\n\n{e}") @@ -147,67 +147,70 @@ class SettingsDialog(QDialog): def _create_web_source_tab(self) -> QWidget: """Create web source configuration tab.""" from PySide6.QtWidgets import QTableWidget, QTableWidgetItem - + widget = QWidget() layout = QVBoxLayout() - + # Webapp URL configuration layout.addWidget(QLabel("Web Application URL:")) url_layout = QHBoxLayout() - + self.webapp_url_input = QLineEdit() self.webapp_url_input.setText(self.config.webapp_url) - self.webapp_url_input.setPlaceholderText("e.g., http://localhost:8080 or file:///./webapp/index.html") + self.webapp_url_input.setPlaceholderText( + "e.g., http://localhost:8080 or file:///./webapp/index.html" + ) url_layout.addWidget(self.webapp_url_input) - + open_btn = QPushButton("Open") open_btn.clicked.connect(self._open_webapp_url) url_layout.addWidget(open_btn) - + layout.addLayout(url_layout) - + # URL Mappings (Azure Blob URL β†’ Local Path) layout.addWidget(QLabel("URL Mappings (Azure Blob Storage β†’ Local Paths):")) - + # Create table for URL mappings self.url_mappings_table = QTableWidget() self.url_mappings_table.setColumnCount(2) self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"]) self.url_mappings_table.horizontalHeader().setStretchLastSection(True) - + # Populate from config for mapping in self.config.url_mappings: row = self.url_mappings_table.rowCount() self.url_mappings_table.insertRow(row) self.url_mappings_table.setItem(row, 0, QTableWidgetItem(mapping.url_prefix)) self.url_mappings_table.setItem(row, 1, QTableWidgetItem(mapping.local_path)) - + layout.addWidget(self.url_mappings_table) - + # Buttons for URL mapping management button_layout = QHBoxLayout() - + add_mapping_btn = QPushButton("Add Mapping") add_mapping_btn.clicked.connect(self._add_url_mapping) button_layout.addWidget(add_mapping_btn) - + edit_mapping_btn = QPushButton("Edit Selected") edit_mapping_btn.clicked.connect(self._edit_url_mapping) button_layout.addWidget(edit_mapping_btn) - + remove_mapping_btn = QPushButton("Remove Selected") remove_mapping_btn.clicked.connect(self._remove_url_mapping) button_layout.addWidget(remove_mapping_btn) - + layout.addLayout(button_layout) layout.addStretch() - + widget.setLayout(layout) return widget - + def _open_webapp_url(self) -> None: """Open the webapp URL in the default browser.""" import webbrowser + url = self.webapp_url_input.text().strip() if url: # Handle file:// URLs @@ -216,61 +219,55 @@ class SettingsDialog(QDialog): except Exception as e: logger.error(f"Failed to open URL: {e}") self._show_error(f"Failed to open URL:\n\n{e}") - + def _add_url_mapping(self) -> None: """Add new URL mapping.""" from PySide6.QtWidgets import QInputDialog - + url_prefix, ok1 = QInputDialog.getText( self, "Add URL Mapping", - "Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)" + "Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)", ) - + if ok1 and url_prefix: local_path, ok2 = QInputDialog.getText( self, "Add URL Mapping", - "Enter local file system path:\n(e.g., C:\\Share or /mnt/share)" + "Enter local file system path:\n(e.g., C:\\Share or /mnt/share)", ) - + if ok2 and local_path: row = self.url_mappings_table.rowCount() self.url_mappings_table.insertRow(row) self.url_mappings_table.setItem(row, 0, QTableWidgetItem(url_prefix)) self.url_mappings_table.setItem(row, 1, QTableWidgetItem(local_path)) - + def _edit_url_mapping(self) -> None: """Edit selected URL mapping.""" from PySide6.QtWidgets import QInputDialog - + current_row = self.url_mappings_table.currentRow() if current_row < 0: self._show_error("Please select a mapping to edit") return - - url_prefix = self.url_mappings_table.item(current_row, 0).text() - local_path = self.url_mappings_table.item(current_row, 1).text() - + + url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore + local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore + new_url_prefix, ok1 = QInputDialog.getText( - self, - "Edit URL Mapping", - "Enter Azure Blob Storage URL prefix:", - text=url_prefix + self, "Edit URL Mapping", "Enter Azure Blob Storage URL prefix:", text=url_prefix ) - + if ok1 and new_url_prefix: new_local_path, ok2 = QInputDialog.getText( - self, - "Edit URL Mapping", - "Enter local file system path:", - text=local_path + self, "Edit URL Mapping", "Enter local file system path:", text=local_path ) - + if ok2 and new_local_path: self.url_mappings_table.setItem(current_row, 0, QTableWidgetItem(new_url_prefix)) self.url_mappings_table.setItem(current_row, 1, QTableWidgetItem(new_local_path)) - + def _remove_url_mapping(self) -> None: """Remove selected URL mapping.""" current_row = self.url_mappings_table.currentRow() @@ -281,29 +278,29 @@ class SettingsDialog(QDialog): """Create paths configuration tab.""" widget = QWidget() layout = QVBoxLayout() - + layout.addWidget(QLabel("Allowed root directories for file access:")) - + # List widget for paths self.paths_list = QListWidget() for path in self.config.allowed_roots: self.paths_list.addItem(str(path)) layout.addWidget(self.paths_list) - + # Buttons for path management button_layout = QHBoxLayout() - + add_path_btn = QPushButton("Add Path") add_path_btn.clicked.connect(self._add_path) button_layout.addWidget(add_path_btn) - + remove_path_btn = QPushButton("Remove Selected") remove_path_btn.clicked.connect(self._remove_path) button_layout.addWidget(remove_path_btn) - + layout.addLayout(button_layout) layout.addStretch() - + widget.setLayout(layout) return widget @@ -311,29 +308,29 @@ class SettingsDialog(QDialog): """Create URLs configuration tab.""" widget = QWidget() layout = QVBoxLayout() - + layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):")) - + # List widget for URLs self.urls_list = QListWidget() for url in self.config.allowed_urls: self.urls_list.addItem(url) layout.addWidget(self.urls_list) - + # Buttons for URL management button_layout = QHBoxLayout() - + add_url_btn = QPushButton("Add URL") add_url_btn.clicked.connect(self._add_url) button_layout.addWidget(add_url_btn) - + remove_url_btn = QPushButton("Remove Selected") remove_url_btn.clicked.connect(self._remove_url) button_layout.addWidget(remove_url_btn) - + layout.addLayout(button_layout) layout.addStretch() - + widget.setLayout(layout) return widget @@ -341,27 +338,28 @@ class SettingsDialog(QDialog): """Create logging configuration tab.""" widget = QWidget() layout = QVBoxLayout() - + # Log level selection layout.addWidget(QLabel("Log Level:")) from PySide6.QtWidgets import QComboBox + self.log_level_combo: QComboBox = self._create_log_level_widget() layout.addWidget(self.log_level_combo) - + # Log file path layout.addWidget(QLabel("Log File (optional):")) log_file_layout = QHBoxLayout() - + self.log_file_input = QLineEdit() self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "") log_file_layout.addWidget(self.log_file_input) - + browse_btn = QPushButton("Browse...") browse_btn.clicked.connect(self._browse_log_file) log_file_layout.addWidget(browse_btn) - + layout.addLayout(log_file_layout) - + layout.addStretch() widget.setLayout(layout) return widget @@ -370,7 +368,7 @@ class SettingsDialog(QDialog): """Create window settings tab.""" widget = QWidget() layout = QVBoxLayout() - + # Window width width_layout = QHBoxLayout() width_layout.addWidget(QLabel("Window Width:")) @@ -381,7 +379,7 @@ class SettingsDialog(QDialog): width_layout.addWidget(self.width_spin) width_layout.addStretch() layout.addLayout(width_layout) - + # Window height height_layout = QHBoxLayout() height_layout.addWidget(QLabel("Window Height:")) @@ -392,7 +390,7 @@ class SettingsDialog(QDialog): height_layout.addWidget(self.height_spin) height_layout.addStretch() layout.addLayout(height_layout) - + layout.addStretch() widget.setLayout(layout) return widget @@ -401,52 +399,50 @@ class SettingsDialog(QDialog): """Create profiles management tab.""" widget = QWidget() layout = QVBoxLayout() - + layout.addWidget(QLabel("Saved Configuration Profiles:")) - + # List of profiles self.profiles_list = QListWidget() self._refresh_profiles_list() layout.addWidget(self.profiles_list) - + # Profile management buttons button_layout = QHBoxLayout() - + save_profile_btn = QPushButton("Save as Profile") save_profile_btn.clicked.connect(self._save_profile) button_layout.addWidget(save_profile_btn) - + load_profile_btn = QPushButton("Load Profile") load_profile_btn.clicked.connect(self._load_profile) button_layout.addWidget(load_profile_btn) - + delete_profile_btn = QPushButton("Delete Profile") delete_profile_btn.clicked.connect(self._delete_profile) button_layout.addWidget(delete_profile_btn) - + layout.addLayout(button_layout) - + # Export/Import buttons export_layout = QHBoxLayout() - + export_btn = QPushButton("Export Configuration") export_btn.clicked.connect(self._export_config) export_layout.addWidget(export_btn) - + import_btn = QPushButton("Import Configuration") import_btn.clicked.connect(self._import_config) export_layout.addWidget(import_btn) - + layout.addLayout(export_layout) layout.addStretch() - + widget.setLayout(layout) return widget - def _create_log_level_widget(self): + def _create_log_level_widget(self) -> QComboBox: """Create log level selection widget.""" - from PySide6.QtWidgets import QComboBox - combo = QComboBox() levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] combo.addItems(levels) @@ -467,11 +463,9 @@ class SettingsDialog(QDialog): def _add_url(self) -> None: """Add a new allowed URL.""" from PySide6.QtWidgets import QInputDialog - + url, ok = QInputDialog.getText( - self, - "Add URL", - "Enter URL pattern (e.g., http://example.com or http://*.example.com):" + self, "Add URL", "Enter URL pattern (e.g., http://example.com or http://*.example.com):" ) if ok and url: self.urls_list.addItem(url) @@ -484,10 +478,7 @@ class SettingsDialog(QDialog): def _browse_log_file(self) -> None: """Browse for log file location.""" file_path, _ = QFileDialog.getSaveFileName( - self, - "Select Log File", - str(Path.home()), - "Log Files (*.log);;All Files (*)" + self, "Select Log File", str(Path.home()), "Log Files (*.log);;All Files (*)" ) if file_path: self.log_file_input.setText(file_path) @@ -501,13 +492,11 @@ class SettingsDialog(QDialog): def _save_profile(self) -> None: """Save current configuration as a profile.""" from PySide6.QtWidgets import QInputDialog - + profile_name, ok = QInputDialog.getText( - self, - "Save Profile", - "Enter profile name (e.g., work, personal):" + self, "Save Profile", "Enter profile name (e.g., work, personal):" ) - + if ok and profile_name: try: self.profile_manager.save_profile(profile_name, self.config) @@ -521,7 +510,7 @@ class SettingsDialog(QDialog): if not current_item: self._show_error("Please select a profile to load") return - + profile_name = current_item.text() try: config_data = self.profile_manager.load_profile(profile_name) @@ -535,7 +524,7 @@ class SettingsDialog(QDialog): if not current_item: self._show_error("Please select a profile to delete") return - + profile_name = current_item.text() try: self.profile_manager.delete_profile(profile_name) @@ -546,12 +535,9 @@ class SettingsDialog(QDialog): def _export_config(self) -> None: """Export configuration to file.""" file_path, _ = QFileDialog.getSaveFileName( - self, - "Export Configuration", - str(Path.home()), - "JSON Files (*.json);;All Files (*)" + self, "Export Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)" ) - + if file_path: try: ConfigExporter.export_to_json(self.config, Path(file_path)) @@ -561,12 +547,9 @@ class SettingsDialog(QDialog): def _import_config(self) -> None: """Import configuration from file.""" file_path, _ = QFileDialog.getOpenFileName( - self, - "Import Configuration", - str(Path.home()), - "JSON Files (*.json);;All Files (*)" + self, "Import Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)" ) - + if file_path: try: config_data = ConfigExporter.import_from_json(Path(file_path)) @@ -574,9 +557,9 @@ class SettingsDialog(QDialog): except ConfigurationError as e: self._show_error(f"Failed to import configuration: {e}") - def _apply_config_data(self, config_data: dict) -> None: + def _apply_config_data(self, config_data: Dict[str, Any]) -> None: """Apply imported configuration data to UI. - + Args: config_data: Configuration dictionary """ @@ -584,60 +567,67 @@ class SettingsDialog(QDialog): self.paths_list.clear() for path in config_data.get("allowed_roots", []): self.paths_list.addItem(str(path)) - + # Apply URLs self.urls_list.clear() for url in config_data.get("allowed_urls", []): self.urls_list.addItem(url) - + # Apply logging settings self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO")) log_file = config_data.get("log_file") self.log_file_input.setText(str(log_file) if log_file else "") - + # Apply window settings self.width_spin.setValue(config_data.get("window_width", 800)) self.height_spin.setValue(config_data.get("window_height", 600)) - def get_config_data(self) -> dict: + def get_config_data(self) -> Dict[str, Any]: """Get updated configuration data from dialog. - + Returns: Configuration dictionary - + Raises: ConfigurationError: If configuration is invalid """ + if self.url_mappings_table: + url_mappings_table_count = self.url_mappings_table.rowCount() or 0 + else: + url_mappings_table_count = 0 config_data = { "app_name": self.config.app_name, "app_version": self.config.app_version, "log_level": self.log_level_combo.currentText(), "log_file": self.log_file_input.text() or None, - "allowed_roots": [self.paths_list.item(i).text() for i in range(self.paths_list.count())], + "allowed_roots": [ + self.paths_list.item(i).text() for i in range(self.paths_list.count()) + ], "allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())], "webapp_url": self.webapp_url_input.text().strip(), "url_mappings": [ { - "url_prefix": self.url_mappings_table.item(i, 0).text(), - "local_path": self.url_mappings_table.item(i, 1).text() + "url_prefix": self.url_mappings_table.item(i, 0).text() if self.url_mappings_table.item(i, 0) else "", # type: ignore + "local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore } - for i in range(self.url_mappings_table.rowCount()) + for i in range(url_mappings_table_count) ], "window_width": self.width_spin.value(), "window_height": self.height_spin.value(), "enable_logging": self.config.enable_logging, } - + # Validate ConfigValidator.validate_or_raise(config_data) - + return config_data def _show_error(self, message: str) -> None: """Show error message to user. - + Args: message: Error message """ from PySide6.QtWidgets import QMessageBox + QMessageBox.critical(self, "Error", message)