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)