1767 lines
69 KiB
Python
1767 lines
69 KiB
Python
"""Main application window with web engine integration."""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import re
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from PySide6.QtCore import (
|
||
QEvent,
|
||
QObject,
|
||
QPoint,
|
||
QSize,
|
||
QStandardPaths,
|
||
Qt,
|
||
QThread,
|
||
QTimer,
|
||
QUrl,
|
||
Signal,
|
||
Slot,
|
||
)
|
||
from PySide6.QtGui import QIcon, QMouseEvent
|
||
from PySide6.QtWebChannel import QWebChannel
|
||
from PySide6.QtWebEngineCore import QWebEngineDownloadRequest, QWebEngineScript
|
||
from PySide6.QtWidgets import (
|
||
QLabel,
|
||
QMainWindow,
|
||
QSizePolicy,
|
||
QSpacerItem,
|
||
QStatusBar,
|
||
QToolBar,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
QWidgetAction,
|
||
)
|
||
|
||
from webdrop_bridge.config import Config
|
||
from webdrop_bridge.core.drag_interceptor import DragInterceptor
|
||
from webdrop_bridge.core.validator import PathValidator
|
||
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Default welcome page HTML when no webapp is configured
|
||
DEFAULT_WELCOME_PAGE = """
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>WebDrop Bridge</title>
|
||
<style>
|
||
* {{
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}}
|
||
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}}
|
||
|
||
.container {{
|
||
background: white;
|
||
border-radius: 12px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||
padding: 60px 40px;
|
||
max-width: 600px;
|
||
text-align: center;
|
||
}}
|
||
|
||
h1 {{
|
||
color: #667eea;
|
||
font-size: 2.5em;
|
||
margin-bottom: 10px;
|
||
}}
|
||
|
||
.version {{
|
||
color: #999;
|
||
font-size: 0.9em;
|
||
margin-bottom: 30px;
|
||
}}
|
||
|
||
p {{
|
||
color: #555;
|
||
font-size: 1.1em;
|
||
line-height: 1.6;
|
||
margin-bottom: 20px;
|
||
}}
|
||
|
||
.features {{
|
||
background: #f5f5f5;
|
||
border-radius: 8px;
|
||
padding: 30px;
|
||
margin: 30px 0;
|
||
text-align: left;
|
||
}}
|
||
|
||
.features h2 {{
|
||
color: #333;
|
||
font-size: 1.2em;
|
||
margin-bottom: 15px;
|
||
text-align: center;
|
||
}}
|
||
|
||
.features ul {{
|
||
list-style: none;
|
||
padding: 0;
|
||
}}
|
||
|
||
.features li {{
|
||
color: #666;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid #ddd;
|
||
}}
|
||
|
||
.features li:last-child {{
|
||
border-bottom: none;
|
||
}}
|
||
|
||
.features li:before {{
|
||
content: "✓ ";
|
||
color: #667eea;
|
||
font-weight: bold;
|
||
margin-right: 10px;
|
||
}}
|
||
|
||
.status {{
|
||
background: #fff3cd;
|
||
border: 1px solid #ffc107;
|
||
border-radius: 6px;
|
||
padding: 15px;
|
||
margin: 20px 0;
|
||
color: #856404;
|
||
}}
|
||
|
||
.status strong {{
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
}}
|
||
|
||
.footer {{
|
||
color: #999;
|
||
font-size: 0.9em;
|
||
margin-top: 30px;
|
||
padding-top: 20px;
|
||
border-top: 1px solid #eee;
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>🌉 WebDrop Bridge</h1>
|
||
<div class="version">Professional Web-to-File Drag-and-Drop Bridge</div>
|
||
|
||
<div class="status">
|
||
<strong>✓ Application Ready</strong>
|
||
No web application is currently configured.
|
||
Configure WEBAPP_URL in your .env file to load your custom application.
|
||
</div>
|
||
|
||
<p>WebDrop Bridge is a professional desktop application that seamlessly converts web-based drag-and-drop interactions into native file operations on Windows and macOS.</p>
|
||
|
||
<div class="features">
|
||
<h2>Key Features</h2>
|
||
<ul>
|
||
<li>Drag-and-drop from web interface to desktop</li>
|
||
<li>Real-time drag state monitoring</li>
|
||
<li>Path validation and security controls</li>
|
||
<li>Cross-platform support (Windows & macOS)</li>
|
||
<li>Professional production-grade architecture</li>
|
||
<li>Comprehensive logging and monitoring</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<p><strong>To configure your web application:</strong></p>
|
||
<ol style="text-align: left; color: #666; margin-top: 15px;">
|
||
<li>Create a <code>.env</code> file in your application directory</li>
|
||
<li>Set <code>WEBAPP_URL</code> to your HTML file path or HTTP URL</li>
|
||
<li>Example: <code>WEBAPP_URL=file:///./webapp/index.html</code></li>
|
||
<li>Restart the application</li>
|
||
</ol>
|
||
|
||
<div class="footer">
|
||
<strong>Version:</strong> {version}<br>
|
||
<strong>Status:</strong> Ready for configuration
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
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):
|
||
"""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
|
||
"""
|
||
logger.debug(f"JS Debug: {message}")
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""Main application window for WebDrop Bridge.
|
||
|
||
Displays web content in a QWebEngineView and provides drag-and-drop
|
||
integration with the native filesystem.
|
||
"""
|
||
|
||
# Signals
|
||
check_for_updates = Signal()
|
||
update_available = Signal(object) # Emits Release object
|
||
|
||
def __init__(
|
||
self,
|
||
config: Config,
|
||
parent: Optional[QWidget] = None,
|
||
):
|
||
"""Initialize the main window.
|
||
|
||
Args:
|
||
config: Application configuration
|
||
parent: Parent widget
|
||
"""
|
||
super().__init__(parent)
|
||
self.config = config
|
||
self._background_threads = [] # Keep references to background threads
|
||
self._background_workers = {} # Keep references to background workers
|
||
self.checking_dialog = None # Track the checking dialog
|
||
self._is_manual_check = False # Track if this is a manual check (for UI feedback)
|
||
|
||
# Set window properties
|
||
self.setWindowTitle(config.window_title)
|
||
self.setGeometry(
|
||
100,
|
||
100,
|
||
config.window_width,
|
||
config.window_height,
|
||
)
|
||
|
||
# Set window icon
|
||
# Support both development mode and PyInstaller bundle
|
||
if hasattr(sys, '_MEIPASS'):
|
||
# Running as PyInstaller bundle
|
||
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"
|
||
|
||
if icon_path.exists():
|
||
self.setWindowIcon(QIcon(str(icon_path)))
|
||
logger.debug(f"Window icon set from {icon_path}")
|
||
else:
|
||
logger.warning(f"Window icon not found at {icon_path}")
|
||
|
||
# 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
|
||
|
||
# Create navigation toolbar (Kiosk-mode navigation)
|
||
self._create_navigation_toolbar()
|
||
|
||
# Create status bar
|
||
self._create_status_bar()
|
||
|
||
# Create drag interceptor with config (includes URL converter)
|
||
self.drag_interceptor = DragInterceptor(config)
|
||
|
||
# Connect drag interceptor signals
|
||
self.drag_interceptor.drag_started.connect(self._on_drag_started)
|
||
self.drag_interceptor.drag_failed.connect(self._on_drag_failed)
|
||
|
||
# Set up JavaScript bridge with QWebChannel
|
||
self._drag_bridge = _DragBridge(self)
|
||
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
|
||
)
|
||
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
|
||
central_widget = QWidget()
|
||
layout = QVBoxLayout()
|
||
layout.addWidget(self.web_view)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
central_widget.setLayout(layout)
|
||
self.setCentralWidget(central_widget)
|
||
|
||
# Load web application
|
||
self._load_webapp()
|
||
|
||
# Apply styling if available
|
||
self._apply_stylesheet()
|
||
|
||
def _load_webapp(self) -> None:
|
||
"""Load the web application.
|
||
|
||
Loads HTML from the configured webapp URL or from local file.
|
||
Injects the WebChannel bridge JavaScript for drag-and-drop.
|
||
Supports both bundled apps (PyInstaller) and development mode.
|
||
Falls back to default welcome page if webapp not found.
|
||
"""
|
||
webapp_url = self.config.webapp_url
|
||
|
||
if webapp_url.startswith("http://") or webapp_url.startswith("https://"):
|
||
# Remote URL
|
||
self.web_view.load(QUrl(webapp_url))
|
||
else:
|
||
# 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:
|
||
# Try without leading "./"
|
||
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)
|
||
self.web_view.setHtml(welcome_html)
|
||
return
|
||
|
||
# Load local file
|
||
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))
|
||
|
||
except (OSError, ValueError) as e:
|
||
# Show welcome page on error
|
||
welcome_html = DEFAULT_WELCOME_PAGE.format(version=self.config.app_version)
|
||
self.web_view.setHtml(welcome_html)
|
||
|
||
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_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
|
||
# 2. PyInstaller bundle: sys._MEIPASS / webdrop_bridge / ui / script.js
|
||
# 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
|
||
|
||
# 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:")
|
||
for i, path in enumerate(search_paths, 1):
|
||
logger.error(f" [{i}] {path} (exists: {path.exists()})")
|
||
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:
|
||
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")
|
||
|
||
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:
|
||
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"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")
|
||
logger.debug(f" Script size: {len(combined_code)} chars")
|
||
logger.debug(f" Loaded from: {script_path}")
|
||
except (OSError, IOError) as e:
|
||
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
|
||
})
|
||
|
||
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() {{
|
||
try {{
|
||
// WebDrop Bridge - Configuration Injection
|
||
console.log('[WebDrop Config] Starting configuration injection...');
|
||
window.webdropConfig = {config_json};
|
||
console.log('[WebDrop Config] Configuration object created');
|
||
|
||
if (window.webdropConfig && window.webdropConfig.urlMappings) {{
|
||
console.log('[WebDrop Config] SUCCESS: ' + window.webdropConfig.urlMappings.length + ' URL mappings loaded');
|
||
for (var i = 0; i < window.webdropConfig.urlMappings.length; i++) {{
|
||
var m = window.webdropConfig.urlMappings[i];
|
||
console.log('[WebDrop Config] [' + (i+1) + '] ' + m.url_prefix + ' -> ' + m.local_path);
|
||
}}
|
||
}} else {{
|
||
console.warn('[WebDrop Config] WARNING: No valid URL mappings found in config object');
|
||
}}
|
||
}} catch(e) {{
|
||
console.error('[WebDrop Config] ERROR during configuration injection: ' + e.message);
|
||
if (e.stack) console.error('[WebDrop Config] Stack: ' + e.stack);
|
||
}}
|
||
}})();
|
||
"""
|
||
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
|
||
"""
|
||
return html_content
|
||
|
||
def _apply_stylesheet(self) -> None:
|
||
"""Apply application stylesheet if available."""
|
||
stylesheet_path = Path(__file__).parent.parent.parent.parent / \
|
||
"resources" / "stylesheets" / "default.qss"
|
||
|
||
if stylesheet_path.exists():
|
||
try:
|
||
with open(stylesheet_path, "r") as f:
|
||
stylesheet = f.read()
|
||
self.setStyleSheet(stylesheet)
|
||
except (OSError, IOError):
|
||
# Silently fail if stylesheet can't be read
|
||
pass
|
||
|
||
def _on_drag_started(self, source: str, local_path: str) -> None:
|
||
"""Handle successful drag initiation.
|
||
|
||
Args:
|
||
source: Original URL or path from web content
|
||
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'):
|
||
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
|
||
"""
|
||
from PySide6.QtWidgets import QMessageBox
|
||
|
||
# Extract filename for display
|
||
filename = Path(local_path).name
|
||
|
||
# Extract asset ID
|
||
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 () => {{
|
||
try {{
|
||
const authToken = window.capturedAuthToken;
|
||
if (!authToken) {{
|
||
console.log('[Checkout Check] No auth token available');
|
||
window['{callback_id}'] = JSON.stringify({{ error: 'No auth token' }});
|
||
return;
|
||
}}
|
||
|
||
console.log('[Checkout Check] Fetching asset data for {asset_id}');
|
||
const response = await fetch(
|
||
'https://devagravityprivate.azurewebsites.net/api/assets/{asset_id}?fields=checkout',
|
||
{{
|
||
method: 'GET',
|
||
headers: {{
|
||
'Accept': 'application/json',
|
||
'Authorization': authToken
|
||
}}
|
||
}}
|
||
);
|
||
|
||
console.log('[Checkout Check] Response status:', response.status);
|
||
|
||
if (response.ok) {{
|
||
const data = await response.json();
|
||
console.log('[Checkout Check] Full data:', JSON.stringify(data));
|
||
console.log('[Checkout Check] Checkout field:', data.checkout);
|
||
const hasCheckout = !!(data.checkout && Object.keys(data.checkout).length > 0);
|
||
console.log('[Checkout Check] Has checkout:', hasCheckout);
|
||
window['{callback_id}'] = JSON.stringify({{ checkout: data.checkout || null, hasCheckout: hasCheckout }});
|
||
}} else {{
|
||
console.log('[Checkout Check] Failed to fetch, status:', response.status);
|
||
window['{callback_id}'] = JSON.stringify({{ error: 'Failed to fetch asset', status: response.status }});
|
||
}}
|
||
}} catch (error) {{
|
||
console.error('[Checkout Check] Error:', error);
|
||
window['{callback_id}'] = JSON.stringify({{ error: error.toString() }});
|
||
}}
|
||
}})();
|
||
"""
|
||
|
||
# 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))
|
||
|
||
# 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:
|
||
"""Handle the result of checkout status check.
|
||
|
||
Args:
|
||
result: Result from JavaScript (JSON string)
|
||
azure_url: Azure URL
|
||
filename: Asset filename
|
||
callback_id: Callback ID to clean up
|
||
"""
|
||
# 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'):
|
||
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)
|
||
if has_checkout:
|
||
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
|
||
)
|
||
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
logger.info(f"User confirmed checkout for {filename}")
|
||
self._trigger_checkout_api(azure_url)
|
||
else:
|
||
logger.info(f"User declined checkout for {filename}")
|
||
|
||
def _trigger_checkout_api(self, azure_url: str) -> None:
|
||
"""Trigger checkout via API call using JavaScript.
|
||
|
||
Calls the checkout API from JavaScript so HttpOnly cookies are automatically included.
|
||
Example URL: https://devagravitystg.file.core.windows.net/devagravitysync/anPGZszKzgKaSz1SIx2HFgduy/filename
|
||
Asset ID: anPGZszKzgKaSz1SIx2HFgduy
|
||
|
||
Args:
|
||
azure_url: Azure Blob Storage URL containing asset ID
|
||
"""
|
||
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)
|
||
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() {{
|
||
try {{
|
||
// Get captured auth token (from intercepted XHR)
|
||
const authToken = window.capturedAuthToken;
|
||
|
||
if (!authToken) {{
|
||
console.error('No authorization token available');
|
||
return {{ success: false, error: 'No auth token' }};
|
||
}}
|
||
|
||
const headers = {{
|
||
'Accept': 'application/json',
|
||
'Content-Type': 'application/json',
|
||
'Accept-Language': 'de',
|
||
'Authorization': authToken
|
||
}};
|
||
|
||
const response = await fetch(
|
||
'https://devagravityprivate.azurewebsites.net/api/assets/checkout/bulk?checkout=true',
|
||
{{
|
||
method: 'PUT',
|
||
headers: headers,
|
||
body: JSON.stringify({{asset_ids: ['{asset_id}']}})
|
||
}}
|
||
);
|
||
|
||
if (response.ok) {{
|
||
console.log('✅ Checkout API successful for asset {asset_id}');
|
||
return {{ success: true, status: response.status }};
|
||
}} else {{
|
||
const text = await response.text();
|
||
console.warn('Checkout API returned status ' + response.status + ': ' + text.substring(0, 200));
|
||
return {{ success: false, status: response.status, error: text }};
|
||
}}
|
||
}} catch (error) {{
|
||
console.error('Checkout API call failed:', error);
|
||
return {{ success: false, error: error.toString() }};
|
||
}}
|
||
}})();
|
||
"""
|
||
|
||
def on_result(result):
|
||
"""Callback when JavaScript completes."""
|
||
if result and isinstance(result, dict):
|
||
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')
|
||
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}")
|
||
|
||
def _on_drag_failed(self, source: str, error: str) -> None:
|
||
"""Handle drag operation failure.
|
||
|
||
Args:
|
||
source: Original URL or path from web content
|
||
error: Error message
|
||
"""
|
||
logger.warning(f"Drag failed for {source}: {error}")
|
||
# Can be extended with user notification or status bar message
|
||
|
||
def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None:
|
||
"""Handle download requests from the embedded web view.
|
||
|
||
Downloads are automatically saved to the user's Downloads folder.
|
||
|
||
Args:
|
||
download: Download request from the web engine
|
||
"""
|
||
try:
|
||
# Log download details for debugging
|
||
logger.debug(f"Download URL: {download.url().toString()}")
|
||
logger.debug(f"Download filename: {download.downloadFileName()}")
|
||
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
|
||
)
|
||
|
||
# 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)
|
||
|
||
def _on_download_finished(self, download: QWebEngineDownloadRequest, file_path: Path) -> None:
|
||
"""Handle download completion.
|
||
|
||
Args:
|
||
download: The completed download request
|
||
file_path: Path where file was saved
|
||
"""
|
||
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
|
||
)
|
||
elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled:
|
||
logger.info(f"Download cancelled: {file_path.name}")
|
||
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
|
||
)
|
||
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.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
|
||
line_number: Line number where the message originated
|
||
source_id: Source file/URL where the message originated
|
||
"""
|
||
from PySide6.QtWebEngineCore import QWebEnginePage
|
||
|
||
# Map JS log levels to Python log levels using enum
|
||
if level == QWebEnginePage.JavaScriptConsoleMessageLevel.InfoMessageLevel:
|
||
logger.debug(f"JS Console: {message}")
|
||
elif level == QWebEnginePage.JavaScriptConsoleMessageLevel.WarningMessageLevel:
|
||
logger.warning(f"JS Console: {message}")
|
||
logger.debug(f" at {source_id}:{line_number}")
|
||
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:
|
||
logger.debug("WebDrop Bridge script is active")
|
||
logger.debug("QWebChannel bridge is ready")
|
||
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
|
||
)
|
||
|
||
def _create_navigation_toolbar(self) -> None:
|
||
"""Create navigation toolbar with Home, Back, Forward, Refresh buttons.
|
||
|
||
In Kiosk-mode, users can navigate history but cannot freely browse.
|
||
Help actions are positioned on the right side of the toolbar.
|
||
"""
|
||
toolbar = QToolBar("Navigation")
|
||
toolbar.setMovable(False)
|
||
toolbar.setIconSize(QSize(24, 24))
|
||
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar)
|
||
|
||
# Back button
|
||
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
|
||
)
|
||
toolbar.addAction(forward_action)
|
||
|
||
# Separator
|
||
toolbar.addSeparator()
|
||
|
||
# Home button
|
||
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
|
||
)
|
||
toolbar.addAction(refresh_action)
|
||
|
||
# Add stretch spacer to push help buttons to the right
|
||
spacer = QWidget()
|
||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
|
||
toolbar.addWidget(spacer)
|
||
|
||
# About button (info icon) on the right
|
||
about_action = toolbar.addAction("ℹ️")
|
||
about_action.setToolTip("About WebDrop Bridge")
|
||
about_action.triggered.connect(self._show_about_dialog)
|
||
|
||
# Settings button on the right
|
||
settings_action = toolbar.addAction("⚙️")
|
||
settings_action.setToolTip("Settings")
|
||
settings_action.triggered.connect(self._show_settings_dialog)
|
||
|
||
# Check for Updates button on the right
|
||
check_updates_action = toolbar.addAction("🔄")
|
||
check_updates_action.setToolTip("Check for Updates")
|
||
check_updates_action.triggered.connect(self._on_manual_check_for_updates)
|
||
|
||
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;")
|
||
self.status_bar.addPermanentWidget(self.update_status_label)
|
||
|
||
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)
|
||
"""
|
||
if emoji:
|
||
self.update_status_label.setText(f"{emoji} {status}")
|
||
else:
|
||
self.update_status_label.setText(status)
|
||
|
||
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"<b>{self.config.app_name}</b><br>"
|
||
f"Version: {self.config.app_version}<br>"
|
||
f"<br>"
|
||
f"Bridges web-based drag-and-drop workflows with native file operations "
|
||
f"for professional desktop applications.<br>"
|
||
f"<br>"
|
||
f"<b>Product of:</b><br>"
|
||
f"<b>Hörl Information Management GmbH</b><br>"
|
||
f"Silberburgstraße 126<br>"
|
||
f"70176 Stuttgart, Germany<br>"
|
||
f"<br>"
|
||
f"<small>"
|
||
f"<b>Email:</b> <a href='mailto:info@hoerl-im.de'>info@hoerl-im.de</a><br>"
|
||
f"<b>Phone:</b> +49 (0) 711 933 42 52 – 0<br>"
|
||
f"<b>Web:</b> <a href='https://www.hoerl-im.de/'>https://www.hoerl-im.de/</a><br>"
|
||
f"</small>"
|
||
f"<br>"
|
||
f"<small>© 2026 Hörl Information Management GmbH. All rights reserved.</small>"
|
||
)
|
||
|
||
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()
|
||
|
||
def _navigate_home(self) -> None:
|
||
"""Navigate to the home (start) URL."""
|
||
home_url = self.config.webapp_url
|
||
if home_url.startswith("http://") or home_url.startswith("https://"):
|
||
self.web_view.load(QUrl(home_url))
|
||
else:
|
||
try:
|
||
file_path = Path(home_url).resolve()
|
||
file_url = file_path.as_uri()
|
||
self.web_view.load(QUrl(file_url))
|
||
except (OSError, ValueError):
|
||
pass
|
||
|
||
def closeEvent(self, event) -> None:
|
||
"""Handle window close event.
|
||
|
||
Properly cleanup WebEnginePage before closing to avoid
|
||
"Release of profile requested but WebEnginePage still not deleted" warning.
|
||
This ensures session data (cookies, login state) is properly saved.
|
||
|
||
Args:
|
||
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:
|
||
page = self.web_view.page()
|
||
if page:
|
||
# Disconnect signals to prevent callbacks during shutdown
|
||
try:
|
||
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
|
||
|
||
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
|
||
)
|
||
|
||
# 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[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:
|
||
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."
|
||
)
|
||
|
||
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)
|
||
worker.update_status.connect(self._on_update_status)
|
||
worker.update_status.connect(on_check_done) # "Ready" status means check done
|
||
worker.check_failed.connect(self._on_check_failed)
|
||
worker.check_failed.connect(on_check_done)
|
||
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)}")
|
||
if thread in self._background_threads:
|
||
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:
|
||
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:
|
||
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."
|
||
)
|
||
|
||
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
|
||
)
|
||
|
||
# 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)
|
||
|
||
def _on_user_update_later(self) -> None:
|
||
"""Handle user clicking 'Later' button."""
|
||
logger.info("User deferred update")
|
||
self.set_update_status("Update deferred", emoji="")
|
||
|
||
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"
|
||
)
|
||
|
||
# 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)
|
||
worker.update_status.connect(self._on_update_status)
|
||
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)}")
|
||
if thread in self._background_threads:
|
||
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)
|
||
thread.started.connect(worker.run)
|
||
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.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."
|
||
)
|
||
|
||
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"
|
||
)
|
||
|
||
if manager.install_update(installer_path):
|
||
self.set_update_status("Installation started", emoji="✅")
|
||
logger.info("Update installer launched successfully")
|
||
else:
|
||
self.set_update_status("Installation failed", emoji="❌")
|
||
logger.error("Failed to launch update installer")
|
||
|
||
|
||
class UpdateCheckWorker(QObject):
|
||
"""Worker for running update check asynchronously."""
|
||
|
||
# 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
|
||
finished = Signal()
|
||
|
||
def __init__(self, manager, current_version: str):
|
||
"""Initialize worker.
|
||
|
||
Args:
|
||
manager: UpdateManager instance
|
||
current_version: Current app version
|
||
"""
|
||
super().__init__()
|
||
self.manager = manager
|
||
self.current_version = current_version
|
||
|
||
def run(self) -> None:
|
||
"""Run the update check."""
|
||
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
|
||
)
|
||
)
|
||
logger.debug(f"Update check completed, release={release}")
|
||
|
||
# Emit result
|
||
if release:
|
||
logger.info(f"Update available: {release.version}")
|
||
self.update_available.emit(release)
|
||
else:
|
||
# 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]}")
|
||
finally:
|
||
# Properly close the event loop
|
||
if loop is not None:
|
||
try:
|
||
if not loop.is_closed():
|
||
loop.close()
|
||
logger.debug("Event loop closed")
|
||
except Exception as e:
|
||
logger.warning(f"Error closing event loop: {e}")
|
||
self.finished.emit()
|
||
|
||
|
||
class UpdateDownloadWorker(QObject):
|
||
"""Worker for downloading and verifying update asynchronously."""
|
||
|
||
# Define signals at class level
|
||
download_complete = Signal(Path) # Emits installer_path
|
||
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
|
||
current_version: Current app version
|
||
"""
|
||
super().__init__()
|
||
self.manager = manager
|
||
self.release = release
|
||
self.current_version = current_version
|
||
|
||
def run(self) -> None:
|
||
"""Run the download and verification."""
|
||
loop = None
|
||
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
|
||
)
|
||
)
|
||
|
||
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
|
||
)
|
||
)
|
||
|
||
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)")
|
||
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]}")
|
||
finally:
|
||
# Properly close the event loop
|
||
if loop is not None:
|
||
try:
|
||
if not loop.is_closed():
|
||
loop.close()
|
||
logger.debug("Event loop closed")
|
||
except Exception as e:
|
||
logger.warning(f"Error closing event loop: {e}")
|
||
self.finished.emit()
|