Compare commits

..

No commits in common. "9609a12ae71f947d60af03864bf6a7dcbd710605" and "03991fdea542f063314841515ed5e53d8d229a65" have entirely different histories.

17 changed files with 3640 additions and 3729 deletions

View file

@ -2,7 +2,7 @@
# Application
APP_NAME=WebDrop Bridge
APP_VERSION=0.6.5
APP_VERSION=0.6.0
# Web App
WEBAPP_URL=file:///./webapp/index.html

View file

@ -1,43 +1,3 @@
## [0.6.5] - 2026-02-25
### Added
### Changed
### Fixed
## [0.6.4] - 2026-02-25
### Added
### Changed
### Fixed
## [0.6.3] - 2026-02-25
### Added
### Changed
### Fixed
## [0.6.2] - 2026-02-25
### Added
### Changed
### Fixed
## [0.6.1] - 2026-02-25
### Added
### Changed
### Fixed
## [0.6.0] - 2026-02-20
### Added

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.6.5"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.6.0"
Manufacturer="HIM-Tools"
UpgradeCode="12345678-1234-1234-1234-123456789012">
@ -24,13 +23,6 @@
<UIRef Id="WixUI_InstallDir" />
<UIRef Id="WixUI_ErrorProgressText" />
<!-- Close running application before installation -->
<util:CloseApplication
Target="WebDropBridge.exe"
CloseMessage="yes"
RebootPrompt="no"
ElevatedCloseMessage="no" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" />

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -27,10 +27,13 @@ from typing import Optional
if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
import io
# Reconfigure stdout/stderr for UTF-8 output
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
sys.stdout = io.TextIOWrapper(
sys.stdout.buffer, encoding="utf-8", errors="replace"
)
sys.stderr = io.TextIOWrapper(
sys.stderr.buffer, encoding="utf-8", errors="replace"
)
import subprocess
import shutil
@ -117,7 +120,12 @@ class WindowsBuilder:
env = os.environ.copy()
env["WEBDROP_ENV_FILE"] = str(self.env_file)
result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
result = subprocess.run(
cmd,
cwd=str(self.project_root),
text=True,
env=env
)
if result.returncode != 0:
print("❌ PyInstaller build failed")
@ -133,9 +141,7 @@ class WindowsBuilder:
print(f"📦 Output: {exe_path}")
# Calculate total dist size
total_size = sum(
f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file()
)
total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file())
if total_size > 0:
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
@ -257,15 +263,12 @@ class WindowsBuilder:
str(heat_exe),
"dir",
str(dist_folder),
"-cg",
"AppFiles",
"-dr",
"INSTALLFOLDER",
"-cg", "AppFiles",
"-dr", "INSTALLFOLDER",
"-sfrag",
"-srd",
"-gg",
"-o",
str(harvest_file),
"-o", str(harvest_file),
]
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@ -280,7 +283,10 @@ class WindowsBuilder:
if harvest_file.exists():
content = harvest_file.read_text()
# Add Win64="yes" to all Component tags
content = content.replace("<Component ", '<Component Win64="yes" ')
content = content.replace(
'<Component ',
'<Component Win64="yes" '
)
harvest_file.write_text(content)
print(f" ✓ Marked components as 64-bit")
@ -292,15 +298,11 @@ class WindowsBuilder:
# Run candle compiler - make sure to use correct source directory
candle_cmd = [
str(candle_exe),
"-ext",
"WixUIExtension",
"-ext",
"WixUtilExtension",
"-ext", "WixUIExtension",
f"-dDistDir={self.dist_dir}",
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
"-o",
str(self.build_dir) + "\\",
"-o", str(self.build_dir) + "\\",
str(self.build_dir / "WebDropBridge.wxs"),
]
@ -316,14 +318,9 @@ class WindowsBuilder:
# Link MSI - include both obj files if harvest was successful
light_cmd = [
str(light_exe),
"-ext",
"WixUIExtension",
"-ext",
"WixUtilExtension",
"-b",
str(self.dist_dir / "WebDropBridge"), # Base path for source files
"-o",
str(msi_output),
"-ext", "WixUIExtension",
"-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
"-o", str(msi_output),
str(wix_obj),
]
@ -331,9 +328,7 @@ class WindowsBuilder:
light_cmd.append(str(wix_files_obj))
print(f" Linking MSI installer...")
result = subprocess.run(
light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
result = subprocess.run(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
print("❌ MSI linking failed")
if result.stdout:
@ -358,10 +353,9 @@ class WindowsBuilder:
Creates per-machine installation (Program Files).
Installation requires admin rights, but the app does not.
"""
wix_content = f"""<?xml version="1.0" encoding="UTF-8"?>
wix_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
Manufacturer="HIM-Tools"
UpgradeCode="12345678-1234-1234-1234-123456789012">
@ -384,13 +378,6 @@ class WindowsBuilder:
<UIRef Id="WixUI_InstallDir" />
<UIRef Id="WixUI_ErrorProgressText" />
<!-- Close running application before installation -->
<util:CloseApplication
Target="WebDropBridge.exe"
CloseMessage="yes"
RebootPrompt="no"
ElevatedCloseMessage="no" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" />
@ -446,21 +433,14 @@ class WindowsBuilder:
</DirectoryRef>
</Product>
</Wix>
"""
'''
wix_file = self.build_dir / "WebDropBridge.wxs"
wix_file.write_text(wix_content)
print(f" Created WiX source: {wix_file}")
return True
def _generate_file_elements(
self,
folder: Path,
parent_dir_ref: str,
parent_rel_path: str,
indent: int = 8,
file_counter: Optional[dict] = None,
) -> str:
def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str:
"""Generate WiX File elements for all files in a folder.
Args:
@ -485,7 +465,6 @@ class WindowsBuilder:
if item.is_file():
# Create unique File element ID using hash of full path
import hashlib
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
file_id = f"File_{path_hash}"
file_path = str(item)
@ -493,7 +472,10 @@ class WindowsBuilder:
elif item.is_dir() and item.name != "__pycache__":
# Recursively add files from subdirectories
sub_elements = self._generate_file_elements(
item, parent_dir_ref, f"{parent_rel_path}/{item.name}", indent, file_counter
item, parent_dir_ref,
f"{parent_rel_path}/{item.name}",
indent,
file_counter
)
if sub_elements:
elements.append(sub_elements)
@ -552,7 +534,10 @@ class WindowsBuilder:
str(exe_path),
]
result = subprocess.run(cmd, text=True)
result = subprocess.run(
cmd,
text=True
)
if result.returncode != 0:
print("❌ Code signing failed")
return False
@ -599,7 +584,9 @@ class WindowsBuilder:
def main() -> int:
"""Build Windows MSI installer."""
parser = argparse.ArgumentParser(description="Build WebDrop Bridge Windows installer")
parser = argparse.ArgumentParser(
description="Build WebDrop Bridge Windows installer"
)
parser.add_argument(
"--msi",
action="store_true",

View file

@ -18,6 +18,5 @@
"log_file": null,
"window_width": 1024,
"window_height": 768,
"enable_logging": true,
"enable_checkout": false
"enable_logging": true
}

View file

@ -1,6 +1,6 @@
"""WebDrop Bridge - Qt-based desktop application for intelligent drag-and-drop file handling."""
__version__ = "0.6.5"
__version__ = "0.6.0"
__author__ = "WebDrop Team"
__license__ = "MIT"

View file

@ -58,8 +58,6 @@ class Config:
window_height: Initial window height in pixels
window_title: Main window title (default: "{app_name} v{app_version}")
enable_logging: Whether to write logs to file
enable_checkout: Whether to check asset checkout status and show checkout dialog
on drag. Disabled by default as checkout support is optional.
Raises:
ConfigurationError: If configuration values are invalid
@ -80,7 +78,6 @@ class Config:
window_height: int = 768
window_title: str = ""
enable_logging: bool = True
enable_checkout: bool = False
@classmethod
def from_file(cls, config_path: Path) -> "Config":
@ -109,7 +106,10 @@ class Config:
# Parse URL mappings
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 data.get("url_mappings", [])
]
@ -143,7 +143,6 @@ class Config:
# If the stored title matches the pattern "{app_name} v{version}", regenerate it
# with the current version. This ensures the title updates automatically on upgrades.
import re
version_pattern = re.compile(rf"^{re.escape(app_name)}\s+v[\d.]+$")
if stored_window_title and version_pattern.match(stored_window_title):
# Detected a default-pattern title with old version, regenerate
@ -171,7 +170,6 @@ class Config:
window_height=data.get("window_height", 768),
window_title=window_title,
enable_logging=data.get("enable_logging", True),
enable_checkout=data.get("enable_checkout", False),
)
@classmethod
@ -197,7 +195,6 @@ class Config:
app_name = os.getenv("APP_NAME", "WebDrop Bridge")
# Version always comes from __init__.py for consistency
from webdrop_bridge import __version__
app_version = __version__
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
@ -211,13 +208,13 @@ class Config:
default_title = f"{app_name} v{app_version}"
window_title = os.getenv("WINDOW_TITLE", default_title)
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
# Validate log level
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if log_level not in valid_levels:
raise ConfigurationError(
f"Invalid LOG_LEVEL: {log_level}. " f"Must be one of: {', '.join(valid_levels)}"
f"Invalid LOG_LEVEL: {log_level}. "
f"Must be one of: {', '.join(valid_levels)}"
)
# Validate and parse allowed roots
@ -228,7 +225,9 @@ class Config:
if not root_path.exists():
logger.warning(f"Allowed root does not exist: {p.strip()}")
elif not root_path.is_dir():
raise ConfigurationError(f"Allowed root '{p.strip()}' is not a directory")
raise ConfigurationError(
f"Allowed root '{p.strip()}' is not a directory"
)
else:
allowed_roots.append(root_path)
except ConfigurationError:
@ -241,7 +240,8 @@ class Config:
# Validate window dimensions
if window_width <= 0 or window_height <= 0:
raise ConfigurationError(
f"Window dimensions must be positive: " f"{window_width}x{window_height}"
f"Window dimensions must be positive: "
f"{window_width}x{window_height}"
)
# Create log file path if logging enabled
@ -261,11 +261,10 @@ class Config:
raise ConfigurationError("WEBAPP_URL cannot be empty")
# Parse allowed URLs (empty string = no restriction)
allowed_urls = (
[url.strip() for url in allowed_urls_str.split(",") if url.strip()]
if allowed_urls_str
else []
)
allowed_urls = [
url.strip() for url in allowed_urls_str.split(",")
if url.strip()
] if allowed_urls_str else []
# Parse URL mappings (Azure Blob Storage → Local Paths)
# Format: url_prefix1=local_path1;url_prefix2=local_path2
@ -283,7 +282,10 @@ class Config:
)
url_prefix, local_path_str = mapping.split("=", 1)
url_mappings.append(
URLMapping(url_prefix=url_prefix.strip(), local_path=local_path_str.strip())
URLMapping(
url_prefix=url_prefix.strip(),
local_path=local_path_str.strip()
)
)
except (ValueError, OSError) as e:
raise ConfigurationError(
@ -303,7 +305,6 @@ class Config:
window_height=window_height,
window_title=window_title,
enable_logging=enable_logging,
enable_checkout=enable_checkout,
)
def to_file(self, config_path: Path) -> None:
@ -318,7 +319,11 @@ class Config:
"app_name": self.app_name,
"webapp_url": self.webapp_url,
"url_mappings": [
{"url_prefix": m.url_prefix, "local_path": m.local_path} for m in self.url_mappings
{
"url_prefix": m.url_prefix,
"local_path": m.local_path
}
for m in self.url_mappings
],
"allowed_roots": [str(p) for p in self.allowed_roots],
"allowed_urls": self.allowed_urls,
@ -331,7 +336,6 @@ class Config:
"window_height": self.window_height,
"window_title": self.window_title,
"enable_logging": self.enable_logging,
"enable_checkout": self.enable_checkout,
}
config_path.parent.mkdir(parents=True, exist_ok=True)
@ -346,7 +350,6 @@ class Config:
Path to default config file in user's AppData/Roaming
"""
import platform
if platform.system() == "Windows":
base = Path.home() / "AppData" / "Roaming"
else:
@ -364,7 +367,6 @@ class Config:
Path to default logs directory in user's AppData/Roaming
"""
import platform
if platform.system() == "Windows":
base = Path.home() / "AppData" / "Roaming"
else:

View file

@ -53,9 +53,7 @@ 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
@ -106,7 +104,7 @@ class ConfigProfile:
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
def __init__(self) -> None:
def __init__(self):
"""Initialize profile manager."""
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)

View file

@ -44,7 +44,9 @@ class UpdateManager:
self.current_version = current_version
self.forgejo_url = "https://git.him-tools.de"
self.repo = "HIM-public/webdrop-bridge"
self.api_endpoint = f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
self.api_endpoint = (
f"{self.forgejo_url}/api/v1/repos/{self.repo}/releases/latest"
)
# Cache management
self.cache_dir = config_dir or Path.home() / ".webdrop-bridge"
@ -145,44 +147,43 @@ class UpdateManager:
"""
logger.debug(f"check_for_updates() called, current version: {self.current_version}")
# Only use cache when a pending update was already found (avoids
# showing the update dialog on every start). "No update" is never
# cached so that a freshly published release is visible immediately.
logger.debug("Checking cache for pending update...")
# Try cache first
logger.debug("Checking cache...")
cached = self._load_cache()
if cached:
logger.debug("Found cached release")
release_data = cached.get("release")
if release_data:
version = release_data["tag_name"].lstrip("v")
logger.debug(f"Cached pending update version: {version}")
if self._is_newer_version(version):
logger.info(f"Returning cached pending update: {version}")
return Release(**release_data)
else:
# Current version is >= cached release (e.g. already updated)
logger.debug("Cached release is no longer newer — discarding cache")
self.cache_file.unlink(missing_ok=True)
if not self._is_newer_version(version):
logger.info("No newer version available (cached)")
return None
return Release(**release_data)
# Always fetch fresh from API so new releases are seen immediately
# Fetch from API
logger.debug("Fetching from API...")
try:
logger.info(f"Checking for updates from {self.api_endpoint}")
# Run in thread pool with aggressive timeout
loop = asyncio.get_event_loop()
response = await asyncio.wait_for(
loop.run_in_executor(None, self._fetch_release),
timeout=8,
loop.run_in_executor(
None, self._fetch_release
),
timeout=8 # Timeout after network call also has timeout
)
if not response:
return None
# Check if newer version
version = response["tag_name"].lstrip("v")
if not self._is_newer_version(version):
logger.info(f"Latest version {version} is not newer than {self.current_version}")
self._save_cache(response)
return None
# Cache the found update so repeated starts don't hammer the API
logger.info(f"New version available: {version}")
release = Release(**response)
self._save_cache(response)
@ -230,15 +231,17 @@ 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
logger.debug(traceback.format_exc())
return None
async def download_update(
self, release: Release, output_dir: Optional[Path] = None, progress_callback=None
self, release: Release, output_dir: Optional[Path] = None
) -> Optional[Path]:
"""Download installer from release assets.
@ -279,9 +282,8 @@ class UpdateManager:
self._download_file,
installer_asset["browser_download_url"],
output_file,
progress_callback,
),
timeout=300,
timeout=300
)
if success:
@ -300,13 +302,12 @@ class UpdateManager:
output_file.unlink()
return None
def _download_file(self, url: str, output_path: Path, progress_callback=None) -> bool:
def _download_file(self, url: str, output_path: Path) -> bool:
"""Download file from URL (blocking).
Args:
url: URL to download from
output_path: Path to save file
progress_callback: Optional callable(bytes_downloaded, total_bytes)
Returns:
True if successful, False otherwise
@ -314,28 +315,17 @@ class UpdateManager:
try:
logger.debug(f"Downloading from {url}")
with urlopen(url, timeout=300) as response: # 5 min timeout
total = int(response.headers.get("Content-Length", 0))
downloaded = 0
chunk_size = 65536 # 64 KB chunks
with open(output_path, "wb") as f:
while True:
chunk = response.read(chunk_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if progress_callback:
try:
progress_callback(downloaded, total)
except Exception:
pass # Never let progress errors abort the download
f.write(response.read())
logger.debug(f"Downloaded {output_path.stat().st_size} bytes")
return True
except URLError as e:
logger.error(f"Download failed: {e}")
return False
async def verify_checksum(self, file_path: Path, release: Release) -> bool:
async def verify_checksum(
self, file_path: Path, release: Release
) -> bool:
"""Verify file checksum against release checksum file.
Args:
@ -345,12 +335,10 @@ class UpdateManager:
Returns:
True if checksum matches, False otherwise
"""
# Find .sha256 file matching the installer name (e.g. Setup.msi.sha256)
# Fall back to any .sha256 only if no specific match exists
installer_name = file_path.name
# Find .sha256 file in release assets
checksum_asset = None
for asset in release.assets:
if asset["name"] == f"{installer_name}.sha256":
if asset["name"].endswith(".sha256"):
checksum_asset = asset
break
@ -369,7 +357,7 @@ class UpdateManager:
self._download_checksum,
checksum_asset["browser_download_url"],
),
timeout=30,
timeout=30
)
if not checksum_content:
@ -389,7 +377,9 @@ class UpdateManager:
logger.info("Checksum verification passed")
return True
else:
logger.error(f"Checksum mismatch: {file_checksum} != {expected_checksum}")
logger.error(
f"Checksum mismatch: {file_checksum} != {expected_checksum}"
)
return False
except asyncio.TimeoutError:
@ -436,12 +426,9 @@ class UpdateManager:
import subprocess
if platform.system() == "Windows":
# Windows: MSI files must be launched via msiexec
# Windows: Run MSI installer
logger.info(f"Launching installer: {installer_path}")
if str(installer_path).lower().endswith(".msi"):
subprocess.Popen(["msiexec.exe", "/i", str(installer_path)])
else:
subprocess.Popen([str(installer_path)])
subprocess.Popen([str(installer_path)])
return True
elif platform.system() == "Darwin":
# macOS: Mount DMG and run installer

View file

@ -22,13 +22,12 @@ from PySide6.QtCore import (
Signal,
Slot,
)
from PySide6.QtGui import QDesktopServices, QIcon, QMouseEvent
from PySide6.QtGui import QIcon, QMouseEvent
from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import QWebEngineDownloadRequest, QWebEngineScript
from PySide6.QtWidgets import (
QLabel,
QMainWindow,
QMessageBox,
QSizePolicy,
QSpacerItem,
QStatusBar,
@ -206,7 +205,7 @@ class _DragBridge(QObject):
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:
@ -270,7 +269,6 @@ class MainWindow(QMainWindow):
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.downloading_dialog = None # Track the download dialog
self._is_manual_check = False # Track if this is a manual check (for UI feedback)
# Set window properties
@ -284,14 +282,12 @@ class MainWindow(QMainWindow):
# 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)))
@ -408,7 +404,7 @@ 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)
@ -442,7 +438,7 @@ class MainWindow(QMainWindow):
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:
@ -466,8 +462,8 @@ class MainWindow(QMainWindow):
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
@ -491,21 +487,17 @@ class MainWindow(QMainWindow):
try:
if script_path is None:
raise FileNotFoundError(
"bridge_script_intercept.js not found in any expected location"
)
raise FileNotFoundError("bridge_script_intercept.js not found in any expected location")
with open(script_path, "r", encoding="utf-8") as f:
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:
@ -515,7 +507,7 @@ class MainWindow(QMainWindow):
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:
@ -529,13 +521,11 @@ class MainWindow(QMainWindow):
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}")
@ -563,7 +553,10 @@ class MainWindow(QMainWindow):
# 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):
@ -617,9 +610,8 @@ 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:
@ -639,8 +631,8 @@ class MainWindow(QMainWindow):
"""
logger.info(f"Drag started: {source} -> {local_path}")
# Ask user if they want to check out the asset (only when enabled in config)
if source.startswith("http") and self.config.enable_checkout:
# 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:
@ -658,7 +650,7 @@ class MainWindow(QMainWindow):
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
@ -717,21 +709,13 @@ class MainWindow(QMainWindow):
# 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:
@ -754,25 +738,22 @@ class MainWindow(QMainWindow):
# 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
@ -793,7 +774,7 @@ class MainWindow(QMainWindow):
"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:
@ -815,7 +796,7 @@ 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
@ -869,11 +850,11 @@ 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})")
@ -892,12 +873,7 @@ class MainWindow(QMainWindow):
error: Error message
"""
logger.warning(f"Drag failed for {source}: {error}")
# Show error dialog to user
QMessageBox.warning(
self,
"Drag-and-Drop Error",
f"Could not complete the drag-and-drop operation.\n\nError: {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.
@ -943,7 +919,9 @@ class MainWindow(QMainWindow):
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(
@ -975,13 +953,19 @@ class MainWindow(QMainWindow):
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)
@ -1065,8 +1049,8 @@ class MainWindow(QMainWindow):
# Execute JS to check if our script is loaded
self.web_view.page().runJavaScript(
"typeof window.__webdrop_intercept_injected !== 'undefined' && window.__webdrop_intercept_injected === true",
check_script,
"typeof window.__webdrop_bridge_injected !== 'undefined' && window.__webdrop_bridge_injected === true",
check_script
)
def _create_navigation_toolbar(self) -> None:
@ -1081,25 +1065,29 @@ 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
@ -1122,38 +1110,6 @@ class MainWindow(QMainWindow):
check_updates_action.setToolTip("Check for Updates")
check_updates_action.triggered.connect(self._on_manual_check_for_updates)
# Log file button on the right
log_action = toolbar.addAction("📋")
log_action.setToolTip("Open Log File")
log_action.triggered.connect(self._open_log_file)
def _open_log_file(self) -> None:
"""Open the application log file in the system default text editor.
Resolves the log file path from config, falls back to the default
AppData location, and opens it with QDesktopServices. Shows an
informational message if the file does not exist yet.
"""
log_file: Optional[Path] = None
if self.config.log_file:
log_file = Path(self.config.log_file)
else:
# Default location: <AppData/Roaming>/webdrop_bridge/logs/webdrop_bridge.log
app_data = Path(
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
)
log_file = app_data / "logs" / "webdrop_bridge.log"
if log_file.exists():
QDesktopServices.openUrl(QUrl.fromLocalFile(str(log_file)))
else:
QMessageBox.information(
self,
"Log File Not Found",
f"No log file found at:\n{log_file}",
)
def _create_status_bar(self) -> None:
"""Create status bar with update status indicator."""
self.status_bar = self.statusBar()
@ -1206,7 +1162,7 @@ class MainWindow(QMainWindow):
f"for professional desktop applications.<br>"
f"<br>"
f"<b>Product of:</b><br>"
f"<b>hörl Information Management GmbH</b><br>"
f"<b>Hörl Information Management GmbH</b><br>"
f"Silberburgstraße 126<br>"
f"70176 Stuttgart, Germany<br>"
f"<br>"
@ -1216,7 +1172,7 @@ class MainWindow(QMainWindow):
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>"
f"<small>© 2026 Hörl Information Management GmbH. All rights reserved.</small>"
)
QMessageBox.about(self, f"About {self.config.app_name}", about_text)
@ -1255,7 +1211,7 @@ class MainWindow(QMainWindow):
# 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
@ -1269,7 +1225,7 @@ class MainWindow(QMainWindow):
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()
@ -1284,7 +1240,10 @@ class MainWindow(QMainWindow):
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)
@ -1308,7 +1267,7 @@ class MainWindow(QMainWindow):
# 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)}")
@ -1325,19 +1284,18 @@ class MainWindow(QMainWindow):
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()
@ -1398,11 +1356,10 @@ class MainWindow(QMainWindow):
# 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()
@ -1418,15 +1375,14 @@ class MainWindow(QMainWindow):
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:
@ -1435,23 +1391,22 @@ class MainWindow(QMainWindow):
Args:
release: Release object with update info
"""
# Close checking dialog if open (manual check case)
if hasattr(self, "checking_dialog") and self.checking_dialog:
self.checking_dialog.close()
self.checking_dialog = None
self._is_manual_check = False
# 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()
@ -1472,6 +1427,21 @@ class MainWindow(QMainWindow):
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.
@ -1481,12 +1451,6 @@ class MainWindow(QMainWindow):
logger.info(f"Starting download for v{release.version}")
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️")
# Show download progress dialog
from webdrop_bridge.ui.update_manager_ui import DownloadingDialog
self.downloading_dialog = DownloadingDialog(self)
self.downloading_dialog.show()
# Run download in background thread to avoid blocking UI
self._perform_update_async(release)
@ -1503,7 +1467,8 @@ class MainWindow(QMainWindow):
# 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
@ -1519,7 +1484,6 @@ class MainWindow(QMainWindow):
# Connect signals
worker.download_complete.connect(self._on_download_complete)
worker.download_failed.connect(self._on_download_failed)
worker.download_progress.connect(self._on_download_progress)
worker.update_status.connect(self._on_update_status)
worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
@ -1588,16 +1552,14 @@ class MainWindow(QMainWindow):
"""
from webdrop_bridge.ui.update_manager_ui import InstallDialog
if hasattr(self, "downloading_dialog") and self.downloading_dialog:
self.downloading_dialog.close()
self.downloading_dialog = None
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:
@ -1606,31 +1568,16 @@ class MainWindow(QMainWindow):
Args:
error: Error message
"""
if hasattr(self, "downloading_dialog") and self.downloading_dialog:
self.downloading_dialog.close()
self.downloading_dialog = None
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 _on_download_progress(self, downloaded: int, total: int) -> None:
"""Forward download progress to the downloading dialog.
Args:
downloaded: Bytes downloaded so far
total: Total bytes (0 if unknown)
"""
if hasattr(self, "downloading_dialog") and self.downloading_dialog:
self.downloading_dialog.set_progress(downloaded, total)
def _do_install(self, installer_path: Path) -> None:
"""Execute the installer.
@ -1642,7 +1589,8 @@ class MainWindow(QMainWindow):
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):
@ -1658,8 +1606,8 @@ 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):
@ -1691,7 +1639,10 @@ class UpdateCheckWorker(QObject):
# 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}")
@ -1728,8 +1679,7 @@ class UpdateDownloadWorker(QObject):
# Define signals at class level
download_complete = Signal(Path) # Emits installer_path
download_failed = Signal(str) # Emits error message
download_progress = Signal(int, int) # Emits (bytes_downloaded, total_bytes)
download_failed = Signal(str) # Emits error message
update_status = Signal(str, str) # Emits (status_text, emoji)
finished = Signal()
@ -1762,13 +1712,8 @@ class UpdateDownloadWorker(QObject):
logger.info("Starting download with 5-minute timeout")
installer_path = loop.run_until_complete(
asyncio.wait_for(
self.manager.download_update(
self.release,
progress_callback=lambda cur, tot: self.download_progress.emit(
cur, tot
),
),
timeout=300,
self.manager.download_update(self.release),
timeout=300
)
)
@ -1785,7 +1730,8 @@ class UpdateDownloadWorker(QObject):
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
)
)
@ -1801,9 +1747,7 @@ class UpdateDownloadWorker(QObject):
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]}")

View file

@ -2,11 +2,10 @@
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import List, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
@ -42,7 +41,7 @@ class SettingsDialog(QDialog):
- Profiles: Save/load/delete configuration profiles
"""
def __init__(self, config: Config, parent: Optional[QWidget] = None):
def __init__(self, config: Config, parent=None):
"""Initialize the settings dialog.
Args:
@ -99,16 +98,17 @@ class SettingsDialog(QDialog):
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"]
@ -130,7 +130,7 @@ class SettingsDialog(QDialog):
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}")
@ -157,9 +157,7 @@ class SettingsDialog(QDialog):
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")
@ -210,7 +208,6 @@ class SettingsDialog(QDialog):
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
@ -227,14 +224,14 @@ class SettingsDialog(QDialog):
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:
@ -252,16 +249,22 @@ class SettingsDialog(QDialog):
self._show_error("Please select a mapping to edit")
return
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
url_prefix = self.url_mappings_table.item(current_row, 0).text()
local_path = self.url_mappings_table.item(current_row, 1).text()
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:
@ -342,7 +345,6 @@ class SettingsDialog(QDialog):
# 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)
@ -441,8 +443,10 @@ class SettingsDialog(QDialog):
widget.setLayout(layout)
return widget
def _create_log_level_widget(self) -> QComboBox:
def _create_log_level_widget(self):
"""Create log level selection widget."""
from PySide6.QtWidgets import QComboBox
combo = QComboBox()
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
combo.addItems(levels)
@ -465,7 +469,9 @@ class SettingsDialog(QDialog):
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)
@ -478,7 +484,10 @@ 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)
@ -494,7 +503,9 @@ class SettingsDialog(QDialog):
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:
@ -535,7 +546,10 @@ 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:
@ -547,7 +561,10 @@ 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:
@ -557,7 +574,7 @@ class SettingsDialog(QDialog):
except ConfigurationError as e:
self._show_error(f"Failed to import configuration: {e}")
def _apply_config_data(self, config_data: Dict[str, Any]) -> None:
def _apply_config_data(self, config_data: dict) -> None:
"""Apply imported configuration data to UI.
Args:
@ -582,7 +599,7 @@ class SettingsDialog(QDialog):
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[str, Any]:
def get_config_data(self) -> dict:
"""Get updated configuration data from dialog.
Returns:
@ -591,26 +608,20 @@ class SettingsDialog(QDialog):
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() 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
"url_prefix": self.url_mappings_table.item(i, 0).text(),
"local_path": self.url_mappings_table.item(i, 1).text()
}
for i in range(url_mappings_table_count)
for i in range(self.url_mappings_table.rowCount())
],
"window_width": self.width_spin.value(),
"window_height": self.height_spin.value(),
@ -629,5 +640,4 @@ class SettingsDialog(QDialog):
message: Error message
"""
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error", message)

View file

@ -78,6 +78,7 @@ class UpdateAvailableDialog(QDialog):
# Signals
update_now = Signal()
update_later = Signal()
skip_version = Signal()
def __init__(self, version: str, changelog: str, parent=None):
"""Initialize update available dialog.
@ -121,6 +122,10 @@ class UpdateAvailableDialog(QDialog):
self.update_later_btn.clicked.connect(self._on_update_later)
button_layout.addWidget(self.update_later_btn)
self.skip_btn = QPushButton("Skip Version")
self.skip_btn.clicked.connect(self._on_skip)
button_layout.addWidget(self.skip_btn)
layout.addLayout(button_layout)
self.setLayout(layout)
@ -134,6 +139,11 @@ class UpdateAvailableDialog(QDialog):
self.update_later.emit()
self.reject()
def _on_skip(self):
"""Handle skip version button click."""
self.skip_version.emit()
self.reject()
class DownloadingDialog(QDialog):
"""Dialog shown while downloading the update.
@ -354,7 +364,9 @@ class ErrorDialog(QDialog):
layout.addWidget(self.error_text)
# Info message
info = QLabel("Please try again or visit the website to download the update manually.")
info = QLabel(
"Please try again or visit the website to download the update manually."
)
info.setWordWrap(True)
info.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(info)

View file

@ -87,6 +87,13 @@ class TestUpdateAvailableDialog:
with qtbot.waitSignal(dialog.update_later):
dialog.update_later_btn.click()
def test_signals_emitted_skip(self, qapp, qtbot):
"""Test skip version signal is emitted."""
dialog = UpdateAvailableDialog("0.0.2", "Changes")
with qtbot.waitSignal(dialog.skip_version):
dialog.skip_btn.click()
class TestDownloadingDialog:
"""Tests for DownloadingDialog."""

View file

@ -166,7 +166,9 @@ class TestCheckForUpdates:
@pytest.mark.asyncio
@patch.object(UpdateManager, "_fetch_release")
async def test_check_for_updates_no_update(self, mock_fetch, update_manager):
async def test_check_for_updates_no_update(
self, mock_fetch, update_manager
):
"""Test no update available."""
mock_fetch.return_value = {
"tag_name": "v0.0.1",
@ -182,7 +184,9 @@ class TestCheckForUpdates:
@pytest.mark.asyncio
@patch.object(UpdateManager, "_fetch_release")
async def test_check_for_updates_uses_cache(self, mock_fetch, update_manager, sample_release):
async def test_check_for_updates_uses_cache(
self, mock_fetch, update_manager, sample_release
):
"""Test cache is used on subsequent calls."""
mock_fetch.return_value = sample_release
@ -203,7 +207,9 @@ class TestDownloading:
"""Test update downloading."""
@pytest.mark.asyncio
async def test_download_update_success(self, update_manager, tmp_path):
async def test_download_update_success(
self, update_manager, tmp_path
):
"""Test successful update download."""
# Create release with .msi asset
release_data = {
@ -231,7 +237,9 @@ class TestDownloading:
@pytest.mark.asyncio
@patch.object(UpdateManager, "_download_file")
async def test_download_update_no_installer(self, mock_download, update_manager):
async def test_download_update_no_installer(
self, mock_download, update_manager
):
"""Test download fails when no installer in release."""
release_data = {
"tag_name": "v0.0.2",
@ -262,8 +270,8 @@ class TestChecksumVerification:
self, mock_download_checksum, update_manager, sample_release, tmp_path
):
"""Test successful checksum verification."""
# File must match the asset name so the .sha256 lookup succeeds
test_file = tmp_path / "WebDropBridge.exe"
# Create test file
test_file = tmp_path / "test.exe"
test_file.write_bytes(b"test content")
# Calculate actual checksum
@ -283,8 +291,7 @@ class TestChecksumVerification:
self, mock_download_checksum, update_manager, sample_release, tmp_path
):
"""Test checksum verification fails on mismatch."""
# File must match the asset name so the .sha256 lookup succeeds
test_file = tmp_path / "WebDropBridge.exe"
test_file = tmp_path / "test.exe"
test_file.write_bytes(b"test content")
# Return wrong checksum
@ -296,7 +303,9 @@ class TestChecksumVerification:
assert result is False
@pytest.mark.asyncio
async def test_verify_checksum_no_checksum_file(self, update_manager, tmp_path):
async def test_verify_checksum_no_checksum_file(
self, update_manager, tmp_path
):
"""Test verification skipped when no checksum file in release."""
test_file = tmp_path / "test.exe"
test_file.write_bytes(b"test content")
@ -327,7 +336,9 @@ class TestInstallation:
@patch("subprocess.Popen")
@patch("platform.system")
def test_install_update_windows(self, mock_platform, mock_popen, update_manager, tmp_path):
def test_install_update_windows(
self, mock_platform, mock_popen, update_manager, tmp_path
):
"""Test installation on Windows."""
mock_platform.return_value = "Windows"
installer = tmp_path / "WebDropBridge.msi"
@ -340,7 +351,9 @@ class TestInstallation:
@patch("subprocess.Popen")
@patch("platform.system")
def test_install_update_macos(self, mock_platform, mock_popen, update_manager, tmp_path):
def test_install_update_macos(
self, mock_platform, mock_popen, update_manager, tmp_path
):
"""Test installation on macOS."""
mock_platform.return_value = "Darwin"
installer = tmp_path / "WebDropBridge.dmg"