Compare commits

..

8 commits

Author SHA1 Message Date
9609a12ae7 Bump version to 0.6.5 and enhance update download functionality
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
- Updated version number in __init__.py to 0.6.5.
- Modified the download_update method in updater.py to accept a progress_callback for tracking download progress.
- Implemented chunked downloading in _download_file to report progress via the callback.
- Adjusted installer launching logic in updater.py to handle MSI files correctly using msiexec.
- Connected download progress signal in main_window.py to update the downloading dialog.
2026-02-25 15:26:02 +01:00
fba25534d9 chore: update version to 0.6.4 and add log file opening functionality in main window 2026-02-25 15:09:22 +01:00
bbf5e9f875 fix: correct capitalization of "hörl" in About dialog 2026-02-25 15:04:12 +01:00
239438dddb Bump version to 0.6.3 2026-02-25 15:03:30 +01:00
025e9c888c Refactor Windows build script for improved readability and consistency
- Cleaned up whitespace and formatting in build_windows.py for better readability.
- Consolidated environment variable setup for stdout and stderr.
- Streamlined subprocess command calls by removing unnecessary line breaks.
- Enhanced error handling and logging for better debugging.
- Updated comments for clarity and consistency.

Update updater.py to improve checksum verification logic

- Modified checksum verification to prioritize specific .sha256 files matching installer names.
- Added fallback logic to check for any .sha256 file if no specific match is found.

Enhance update manager UI with download progress dialog

- Introduced DownloadingDialog to provide feedback during update downloads.
- Updated MainWindow to manage the new downloading dialog and handle its lifecycle.
- Removed the skip version functionality from the update dialog as per new requirements.

Refactor update manager UI tests for clarity and maintainability

- Removed tests related to the skip version functionality.
- Updated test cases to reflect changes in the update manager UI.
- Ensured all tests are aligned with the new dialog structure and signal emissions.
2026-02-25 14:38:33 +01:00
88d9f200ab feat: update Windows installer to include WixUtilExtension and close running application
chore: bump version to 0.6.2

fix: improve update manager logic for caching and fetching releases
2026-02-25 14:08:41 +01:00
cbd1f3f77c Add enable_checkout configuration option and update drag handling logic 2026-02-25 13:34:37 +01:00
986793632e Refactor SettingsDialog for improved readability and maintainability
- Cleaned up whitespace and formatting throughout the settings_dialog.py file.
- Enhanced type hints for better clarity and type checking.
- Consolidated URL mapping handling in get_config_data method.
- Improved error handling and logging for configuration operations.
- Added comments for better understanding of the code structure and functionality.
2026-02-25 13:26:46 +01:00
17 changed files with 3729 additions and 3640 deletions

View file

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

View file

@ -1,3 +1,43 @@
## [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 ## [0.6.0] - 2026-02-20
### Added ### Added

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

@ -53,7 +53,9 @@ class ConfigValidator:
# Check type # Check type
expected_type = rules.get("type") expected_type = rules.get("type")
if expected_type and not isinstance(value, expected_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 continue
# Check allowed values # Check allowed values
@ -104,7 +106,7 @@ class ConfigProfile:
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles" PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
def __init__(self): def __init__(self) -> None:
"""Initialize profile manager.""" """Initialize profile manager."""
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True) self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)

View file

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

View file

@ -22,12 +22,13 @@ from PySide6.QtCore import (
Signal, Signal,
Slot, Slot,
) )
from PySide6.QtGui import QIcon, QMouseEvent from PySide6.QtGui import QDesktopServices, QIcon, QMouseEvent
from PySide6.QtWebChannel import QWebChannel from PySide6.QtWebChannel import QWebChannel
from PySide6.QtWebEngineCore import QWebEngineDownloadRequest, QWebEngineScript from PySide6.QtWebEngineCore import QWebEngineDownloadRequest, QWebEngineScript
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QLabel, QLabel,
QMainWindow, QMainWindow,
QMessageBox,
QSizePolicy, QSizePolicy,
QSpacerItem, QSpacerItem,
QStatusBar, QStatusBar,
@ -205,7 +206,7 @@ class _DragBridge(QObject):
Exposed to JavaScript as 'bridge' object. 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. """Initialize the drag bridge.
Args: Args:
@ -269,6 +270,7 @@ class MainWindow(QMainWindow):
self._background_threads = [] # Keep references to background threads self._background_threads = [] # Keep references to background threads
self._background_workers = {} # Keep references to background workers self._background_workers = {} # Keep references to background workers
self.checking_dialog = None # Track the checking dialog 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) self._is_manual_check = False # Track if this is a manual check (for UI feedback)
# Set window properties # Set window properties
@ -282,12 +284,14 @@ class MainWindow(QMainWindow):
# Set window icon # Set window icon
# Support both development mode and PyInstaller bundle # Support both development mode and PyInstaller bundle
if hasattr(sys, '_MEIPASS'): if hasattr(sys, "_MEIPASS"):
# Running as PyInstaller bundle # 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: else:
# Running in development mode # 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(): if icon_path.exists():
self.setWindowIcon(QIcon(str(icon_path))) self.setWindowIcon(QIcon(str(icon_path)))
@ -404,7 +408,7 @@ class MainWindow(QMainWindow):
return return
# Load local file # 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 # Inject WebChannel bridge JavaScript
injected_html = self._inject_drag_bridge(html_content) injected_html = self._inject_drag_bridge(html_content)
@ -438,7 +442,7 @@ class MainWindow(QMainWindow):
qwebchannel_code = "" qwebchannel_code = ""
qwebchannel_file = QFile(":/qtwebchannel/qwebchannel.js") qwebchannel_file = QFile(":/qtwebchannel/qwebchannel.js")
if qwebchannel_file.open(QIODevice.OpenModeFlag.ReadOnly | QIODevice.OpenModeFlag.Text): 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() qwebchannel_file.close()
logger.debug("Loaded qwebchannel.js inline to avoid CSP issues") logger.debug("Loaded qwebchannel.js inline to avoid CSP issues")
else: else:
@ -462,8 +466,8 @@ class MainWindow(QMainWindow):
search_paths.append(Path(__file__).parent / "bridge_script_intercept.js") search_paths.append(Path(__file__).parent / "bridge_script_intercept.js")
# 2. PyInstaller bundle (via sys._MEIPASS) # 2. PyInstaller bundle (via sys._MEIPASS)
if hasattr(sys, '_MEIPASS'): if hasattr(sys, "_MEIPASS"):
search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "bridge_script_intercept.js") # type: ignore 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) # 3. Installed executable's directory (handles MSI installation where all files are packaged together)
exe_dir = Path(sys.executable).parent exe_dir = Path(sys.executable).parent
@ -487,17 +491,21 @@ class MainWindow(QMainWindow):
try: try:
if script_path is None: 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() bridge_code = f.read()
# Load download interceptor using similar search path logic # Load download interceptor using similar search path logic
download_search_paths = [] download_search_paths = []
download_search_paths.append(Path(__file__).parent / "download_interceptor.js") download_search_paths.append(Path(__file__).parent / "download_interceptor.js")
if hasattr(sys, '_MEIPASS'): if hasattr(sys, "_MEIPASS"):
download_search_paths.append(Path(sys._MEIPASS) / "webdrop_bridge" / "ui" / "download_interceptor.js") # type: ignore 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_search_paths.append(
exe_dir / "webdrop_bridge" / "ui" / "download_interceptor.js"
)
download_interceptor_code = "" download_interceptor_code = ""
for path in download_search_paths: for path in download_search_paths:
@ -507,7 +515,7 @@ class MainWindow(QMainWindow):
if download_interceptor_path: if download_interceptor_path:
try: 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() download_interceptor_code = f.read()
logger.debug(f"Loaded download interceptor from {download_interceptor_path}") logger.debug(f"Loaded download interceptor from {download_interceptor_path}")
except (OSError, IOError) as e: except (OSError, IOError) as e:
@ -521,11 +529,13 @@ class MainWindow(QMainWindow):
if download_interceptor_code: if download_interceptor_code:
combined_code += "\n\n" + download_interceptor_code combined_code += "\n\n" + download_interceptor_code
logger.debug(f"Combined script size: {len(combined_code)} chars " logger.debug(
f"(qwebchannel: {len(qwebchannel_code)}, " f"Combined script size: {len(combined_code)} chars "
f"config: {len(config_code)}, " f"(qwebchannel: {len(qwebchannel_code)}, "
f"bridge: {len(bridge_code)}, " f"config: {len(config_code)}, "
f"interceptor: {len(download_interceptor_code)})") f"bridge: {len(bridge_code)}, "
f"interceptor: {len(download_interceptor_code)})"
)
logger.debug(f"URL mappings in config: {len(self.config.url_mappings)}") logger.debug(f"URL mappings in config: {len(self.config.url_mappings)}")
for i, mapping in enumerate(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}") logger.debug(f" Mapping {i+1}: {mapping.url_prefix}{mapping.local_path}")
@ -553,10 +563,7 @@ class MainWindow(QMainWindow):
# Convert URL mappings to format expected by bridge script # Convert URL mappings to format expected by bridge script
mappings = [] mappings = []
for mapping in self.config.url_mappings: for mapping in self.config.url_mappings:
mappings.append({ mappings.append({"url_prefix": mapping.url_prefix, "local_path": mapping.local_path})
"url_prefix": mapping.url_prefix,
"local_path": mapping.local_path
})
logger.debug(f"Generating config injection with {len(mappings)} URL mappings") logger.debug(f"Generating config injection with {len(mappings)} URL mappings")
for i, m in enumerate(mappings): for i, m in enumerate(mappings):
@ -610,8 +617,9 @@ class MainWindow(QMainWindow):
def _apply_stylesheet(self) -> None: def _apply_stylesheet(self) -> None:
"""Apply application stylesheet if available.""" """Apply application stylesheet if available."""
stylesheet_path = Path(__file__).parent.parent.parent.parent / \ stylesheet_path = (
"resources" / "stylesheets" / "default.qss" Path(__file__).parent.parent.parent.parent / "resources" / "stylesheets" / "default.qss"
)
if stylesheet_path.exists(): if stylesheet_path.exists():
try: try:
@ -631,8 +639,8 @@ class MainWindow(QMainWindow):
""" """
logger.info(f"Drag started: {source} -> {local_path}") logger.info(f"Drag started: {source} -> {local_path}")
# Ask user if they want to check out the asset # Ask user if they want to check out the asset (only when enabled in config)
if source.startswith('http'): if source.startswith("http") and self.config.enable_checkout:
self._prompt_checkout(source, local_path) self._prompt_checkout(source, local_path)
def _prompt_checkout(self, azure_url: str, local_path: str) -> None: def _prompt_checkout(self, azure_url: str, local_path: str) -> None:
@ -650,7 +658,7 @@ class MainWindow(QMainWindow):
filename = Path(local_path).name filename = Path(local_path).name
# Extract asset ID # Extract asset ID
match = re.search(r'/([^/]+)/[^/]+$', azure_url) match = re.search(r"/([^/]+)/[^/]+$", azure_url)
if not match: if not match:
logger.warning(f"Could not extract asset ID from URL: {azure_url}") logger.warning(f"Could not extract asset ID from URL: {azure_url}")
return return
@ -709,13 +717,21 @@ class MainWindow(QMainWindow):
# After a short delay, read the result from window variable # After a short delay, read the result from window variable
def check_result(): def check_result():
read_code = f"window['{callback_id}']" 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 # Wait 500ms for async fetch to complete
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
QTimer.singleShot(500, check_result) 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. """Handle the result of checkout status check.
Args: Args:
@ -738,22 +754,25 @@ class MainWindow(QMainWindow):
# Parse JSON string # Parse JSON string
try: try:
import json import json
parsed_result = json.loads(result) parsed_result = json.loads(result)
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
logger.warning(f"Failed to parse checkout status result: {e}") logger.warning(f"Failed to parse checkout status result: {e}")
self._show_checkout_dialog(azure_url, filename) self._show_checkout_dialog(azure_url, filename)
return return
if parsed_result.get('error'): if parsed_result.get("error"):
logger.warning(f"Could not check checkout status: {parsed_result}") logger.warning(f"Could not check checkout status: {parsed_result}")
self._show_checkout_dialog(azure_url, filename) self._show_checkout_dialog(azure_url, filename)
return return
# Check if already checked out # Check if already checked out
has_checkout = parsed_result.get('hasCheckout', False) has_checkout = parsed_result.get("hasCheckout", False)
if has_checkout: if has_checkout:
checkout_info = parsed_result.get('checkout', {}) checkout_info = parsed_result.get("checkout", {})
logger.info(f"Asset {filename} is already checked out: {checkout_info}, skipping dialog") logger.info(
f"Asset {filename} is already checked out: {checkout_info}, skipping dialog"
)
return return
# Not checked out, show confirmation dialog # Not checked out, show confirmation dialog
@ -774,7 +793,7 @@ class MainWindow(QMainWindow):
"Checkout Asset", "Checkout Asset",
f"Do you want to check out this asset?\n\n{filename}", f"Do you want to check out this asset?\n\n{filename}",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes QMessageBox.StandardButton.Yes,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
@ -796,7 +815,7 @@ class MainWindow(QMainWindow):
try: try:
# Extract asset ID from URL (middle segment between domain and filename) # Extract asset ID from URL (middle segment between domain and filename)
# Format: https://domain/container/ASSET_ID/filename # Format: https://domain/container/ASSET_ID/filename
match = re.search(r'/([^/]+)/[^/]+$', azure_url) match = re.search(r"/([^/]+)/[^/]+$", azure_url)
if not match: if not match:
logger.warning(f"Could not extract asset ID from URL: {azure_url}") logger.warning(f"Could not extract asset ID from URL: {azure_url}")
return return
@ -850,11 +869,11 @@ class MainWindow(QMainWindow):
def on_result(result): def on_result(result):
"""Callback when JavaScript completes.""" """Callback when JavaScript completes."""
if result and isinstance(result, dict): if result and isinstance(result, dict):
if result.get('success'): if result.get("success"):
logger.info(f"✅ Checkout successful for asset {asset_id}") logger.info(f"✅ Checkout successful for asset {asset_id}")
else: else:
status = result.get('status', 'unknown') status = result.get("status", "unknown")
error = result.get('error', 'unknown error') error = result.get("error", "unknown error")
logger.warning(f"Checkout API returned status {status}: {error}") logger.warning(f"Checkout API returned status {status}: {error}")
else: else:
logger.debug(f"Checkout API call completed (result: {result})") logger.debug(f"Checkout API call completed (result: {result})")
@ -873,7 +892,12 @@ class MainWindow(QMainWindow):
error: Error message error: Error message
""" """
logger.warning(f"Drag failed for {source}: {error}") logger.warning(f"Drag failed for {source}: {error}")
# Can be extended with user notification or status bar message # Show error dialog to user
QMessageBox.warning(
self,
"Drag-and-Drop Error",
f"Could not complete the drag-and-drop operation.\n\nError: {error}",
)
def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None: def _on_download_requested(self, download: QWebEngineDownloadRequest) -> None:
"""Handle download requests from the embedded web view. """Handle download requests from the embedded web view.
@ -919,9 +943,7 @@ class MainWindow(QMainWindow):
logger.info(f"Download started: {filename}") logger.info(f"Download started: {filename}")
# Update status bar (temporarily) # Update status bar (temporarily)
self.status_bar.showMessage( self.status_bar.showMessage(f"📥 Download: {filename}", 3000)
f"📥 Download: {filename}", 3000
)
# Connect to state changed for progress tracking # Connect to state changed for progress tracking
download.stateChanged.connect( download.stateChanged.connect(
@ -953,19 +975,13 @@ class MainWindow(QMainWindow):
if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted: if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted:
logger.info(f"Download completed: {file_path.name}") logger.info(f"Download completed: {file_path.name}")
self.status_bar.showMessage( self.status_bar.showMessage(f"Download completed: {file_path.name}", 5000)
f"Download completed: {file_path.name}", 5000
)
elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled: elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled:
logger.info(f"Download cancelled: {file_path.name}") logger.info(f"Download cancelled: {file_path.name}")
self.status_bar.showMessage( self.status_bar.showMessage(f"⚠️ Download abgebrochen: {file_path.name}", 3000)
f"⚠️ Download abgebrochen: {file_path.name}", 3000
)
elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted: elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted:
logger.warning(f"Download interrupted: {file_path.name}") logger.warning(f"Download interrupted: {file_path.name}")
self.status_bar.showMessage( self.status_bar.showMessage(f"❌ Download fehlgeschlagen: {file_path.name}", 5000)
f"❌ Download fehlgeschlagen: {file_path.name}", 5000
)
except Exception as e: except Exception as e:
logger.error(f"Error in download finished handler: {e}", exc_info=True) logger.error(f"Error in download finished handler: {e}", exc_info=True)
@ -1049,8 +1065,8 @@ class MainWindow(QMainWindow):
# Execute JS to check if our script is loaded # Execute JS to check if our script is loaded
self.web_view.page().runJavaScript( self.web_view.page().runJavaScript(
"typeof window.__webdrop_bridge_injected !== 'undefined' && window.__webdrop_bridge_injected === true", "typeof window.__webdrop_intercept_injected !== 'undefined' && window.__webdrop_intercept_injected === true",
check_script check_script,
) )
def _create_navigation_toolbar(self) -> None: def _create_navigation_toolbar(self) -> None:
@ -1065,29 +1081,25 @@ class MainWindow(QMainWindow):
self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar) self.addToolBar(Qt.ToolBarArea.TopToolBarArea, toolbar)
# Back button # Back button
back_action = self.web_view.pageAction( back_action = self.web_view.pageAction(self.web_view.page().WebAction.Back)
self.web_view.page().WebAction.Back
)
toolbar.addAction(back_action) toolbar.addAction(back_action)
# Forward button # Forward button
forward_action = self.web_view.pageAction( forward_action = self.web_view.pageAction(self.web_view.page().WebAction.Forward)
self.web_view.page().WebAction.Forward
)
toolbar.addAction(forward_action) toolbar.addAction(forward_action)
# Separator # Separator
toolbar.addSeparator() toolbar.addSeparator()
# Home button # 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.setToolTip("Home")
home_action.triggered.connect(self._navigate_home) home_action.triggered.connect(self._navigate_home)
# Refresh button # Refresh button
refresh_action = self.web_view.pageAction( refresh_action = self.web_view.pageAction(self.web_view.page().WebAction.Reload)
self.web_view.page().WebAction.Reload
)
toolbar.addAction(refresh_action) toolbar.addAction(refresh_action)
# Add stretch spacer to push help buttons to the right # Add stretch spacer to push help buttons to the right
@ -1110,6 +1122,38 @@ class MainWindow(QMainWindow):
check_updates_action.setToolTip("Check for Updates") check_updates_action.setToolTip("Check for Updates")
check_updates_action.triggered.connect(self._on_manual_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: def _create_status_bar(self) -> None:
"""Create status bar with update status indicator.""" """Create status bar with update status indicator."""
self.status_bar = self.statusBar() self.status_bar = self.statusBar()
@ -1162,7 +1206,7 @@ class MainWindow(QMainWindow):
f"for professional desktop applications.<br>" f"for professional desktop applications.<br>"
f"<br>" f"<br>"
f"<b>Product of:</b><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"Silberburgstraße 126<br>"
f"70176 Stuttgart, Germany<br>" f"70176 Stuttgart, Germany<br>"
f"<br>" f"<br>"
@ -1172,7 +1216,7 @@ class MainWindow(QMainWindow):
f"<b>Web:</b> <a href='https://www.hoerl-im.de/'>https://www.hoerl-im.de/</a><br>" f"<b>Web:</b> <a href='https://www.hoerl-im.de/'>https://www.hoerl-im.de/</a><br>"
f"</small>" f"</small>"
f"<br>" 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) QMessageBox.about(self, f"About {self.config.app_name}", about_text)
@ -1211,7 +1255,7 @@ class MainWindow(QMainWindow):
# Properly delete WebEnginePage before the profile is released # Properly delete WebEnginePage before the profile is released
# This ensures cookies and session data are saved correctly # 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() page = self.web_view.page()
if page: if page:
# Disconnect signals to prevent callbacks during shutdown # Disconnect signals to prevent callbacks during shutdown
@ -1225,7 +1269,7 @@ class MainWindow(QMainWindow):
logger.debug("WebEnginePage scheduled for deletion") logger.debug("WebEnginePage scheduled for deletion")
# Clear the page from the view # Clear the page from the view
self.web_view.setPage(None) # type: ignore self.web_view.setPage(None) # type: ignore
event.accept() event.accept()
@ -1240,10 +1284,7 @@ class MainWindow(QMainWindow):
try: try:
# Create update manager # Create update manager
cache_dir = Path.home() / ".webdrop-bridge" cache_dir = Path.home() / ".webdrop-bridge"
manager = UpdateManager( manager = UpdateManager(current_version=self.config.app_version, config_dir=cache_dir)
current_version=self.config.app_version,
config_dir=cache_dir
)
# Run async check in background # Run async check in background
self._run_async_check(manager) self._run_async_check(manager)
@ -1267,7 +1308,7 @@ class MainWindow(QMainWindow):
# IMPORTANT: Keep references to prevent garbage collection # IMPORTANT: Keep references to prevent garbage collection
# Store in a list to keep worker alive during thread execution # Store in a list to keep worker alive during thread execution
self._background_threads.append(thread) 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 self._background_workers[id(thread)] = worker
logger.debug(f"Created worker and thread, thread id: {id(thread)}") logger.debug(f"Created worker and thread, thread id: {id(thread)}")
@ -1284,18 +1325,19 @@ class MainWindow(QMainWindow):
return return
logger.warning("Update check taking too long (30s timeout)") 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.checking_dialog.close()
self.set_update_status("Check timed out - no server response", emoji="⏱️") self.set_update_status("Check timed out - no server response", emoji="⏱️")
# Show error dialog # Show error dialog
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
QMessageBox.warning( QMessageBox.warning(
self, self,
"Update Check Timeout", "Update Check Timeout",
"The server did not respond within 30 seconds.\n\n" "The server did not respond within 30 seconds.\n\n"
"This may be due to a network issue or server unavailability.\n\n" "This may be due to a network issue or server unavailability.\n\n"
"Please check your connection and try again." "Please check your connection and try again.",
) )
safety_timer = QTimer() safety_timer = QTimer()
@ -1356,10 +1398,11 @@ class MainWindow(QMainWindow):
# If this is a manual check and we get the "Ready" status, it means no updates # If this is a manual check and we get the "Ready" status, it means no updates
if self._is_manual_check and status == "Ready": if self._is_manual_check and status == "Ready":
# Close checking dialog first, then show result # 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() self.checking_dialog.close()
from webdrop_bridge.ui.update_manager_ui import NoUpdateDialog from webdrop_bridge.ui.update_manager_ui import NoUpdateDialog
dialog = NoUpdateDialog(parent=self) dialog = NoUpdateDialog(parent=self)
self._is_manual_check = False self._is_manual_check = False
dialog.exec() dialog.exec()
@ -1375,14 +1418,15 @@ class MainWindow(QMainWindow):
self._is_manual_check = False self._is_manual_check = False
# Close checking dialog first, then show error # 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() self.checking_dialog.close()
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
QMessageBox.warning( QMessageBox.warning(
self, self,
"Update Check Failed", "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: def _on_update_available(self, release) -> None:
@ -1391,22 +1435,23 @@ class MainWindow(QMainWindow):
Args: Args:
release: Release object with update info 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 # Update status to show update available
self.set_update_status(f"Update available: v{release.version}", emoji="") self.set_update_status(f"Update available: v{release.version}", emoji="")
# Show update available dialog # Show update available dialog
from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog
dialog = UpdateAvailableDialog( dialog = UpdateAvailableDialog(version=release.version, changelog=release.body, parent=self)
version=release.version,
changelog=release.body,
parent=self
)
# Connect dialog signals # Connect dialog signals
dialog.update_now.connect(lambda: self._on_user_update_now(release)) dialog.update_now.connect(lambda: self._on_user_update_now(release))
dialog.update_later.connect(lambda: self._on_user_update_later()) 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) # Show dialog (modal)
dialog.exec() dialog.exec()
@ -1427,21 +1472,6 @@ class MainWindow(QMainWindow):
logger.info("User deferred update") logger.info("User deferred update")
self.set_update_status("Update deferred", emoji="") 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: def _start_update_download(self, release) -> None:
"""Start downloading the update in background thread. """Start downloading the update in background thread.
@ -1451,6 +1481,12 @@ class MainWindow(QMainWindow):
logger.info(f"Starting download for v{release.version}") logger.info(f"Starting download for v{release.version}")
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️") 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 # Run download in background thread to avoid blocking UI
self._perform_update_async(release) self._perform_update_async(release)
@ -1467,8 +1503,7 @@ class MainWindow(QMainWindow):
# Create update manager # Create update manager
manager = UpdateManager( manager = UpdateManager(
current_version=self.config.app_version, current_version=self.config.app_version, config_dir=Path.home() / ".webdrop-bridge"
config_dir=Path.home() / ".webdrop-bridge"
) )
# Create and start background thread # Create and start background thread
@ -1484,6 +1519,7 @@ class MainWindow(QMainWindow):
# Connect signals # Connect signals
worker.download_complete.connect(self._on_download_complete) worker.download_complete.connect(self._on_download_complete)
worker.download_failed.connect(self._on_download_failed) 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.update_status.connect(self._on_update_status)
worker.finished.connect(thread.quit) worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater) worker.finished.connect(worker.deleteLater)
@ -1552,14 +1588,16 @@ class MainWindow(QMainWindow):
""" """
from webdrop_bridge.ui.update_manager_ui import InstallDialog 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}") logger.info(f"Download complete: {installer_path}")
self.set_update_status("Ready to install", emoji="") self.set_update_status("Ready to install", emoji="")
# Show install confirmation dialog # Show install confirmation dialog
install_dialog = InstallDialog(parent=self) install_dialog = InstallDialog(parent=self)
install_dialog.install_now.connect( install_dialog.install_now.connect(lambda: self._do_install(installer_path))
lambda: self._do_install(installer_path)
)
install_dialog.exec() install_dialog.exec()
def _on_download_failed(self, error: str) -> None: def _on_download_failed(self, error: str) -> None:
@ -1568,16 +1606,31 @@ class MainWindow(QMainWindow):
Args: Args:
error: Error message 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}") logger.error(f"Download failed: {error}")
self.set_update_status(error, emoji="") self.set_update_status(error, emoji="")
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
QMessageBox.critical( QMessageBox.critical(
self, self,
"Download Failed", "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: def _do_install(self, installer_path: Path) -> None:
"""Execute the installer. """Execute the installer.
@ -1589,8 +1642,7 @@ class MainWindow(QMainWindow):
from webdrop_bridge.core.updater import UpdateManager from webdrop_bridge.core.updater import UpdateManager
manager = UpdateManager( manager = UpdateManager(
current_version=self.config.app_version, current_version=self.config.app_version, config_dir=Path.home() / ".webdrop-bridge"
config_dir=Path.home() / ".webdrop-bridge"
) )
if manager.install_update(installer_path): if manager.install_update(installer_path):
@ -1606,8 +1658,8 @@ class UpdateCheckWorker(QObject):
# Define signals at class level # Define signals at class level
update_available = Signal(object) # Emits Release object update_available = Signal(object) # Emits Release object
update_status = Signal(str, str) # Emits (status_text, emoji) update_status = Signal(str, str) # Emits (status_text, emoji)
check_failed = Signal(str) # Emits error message check_failed = Signal(str) # Emits error message
finished = Signal() finished = Signal()
def __init__(self, manager, current_version: str): def __init__(self, manager, current_version: str):
@ -1639,10 +1691,7 @@ class UpdateCheckWorker(QObject):
# Check for updates with short timeout (network call has its own timeout) # Check for updates with short timeout (network call has its own timeout)
logger.debug("Starting update check with 10-second timeout") logger.debug("Starting update check with 10-second timeout")
release = loop.run_until_complete( release = loop.run_until_complete(
asyncio.wait_for( asyncio.wait_for(self.manager.check_for_updates(), timeout=10)
self.manager.check_for_updates(),
timeout=10
)
) )
logger.debug(f"Update check completed, release={release}") logger.debug(f"Update check completed, release={release}")
@ -1679,7 +1728,8 @@ class UpdateDownloadWorker(QObject):
# Define signals at class level # Define signals at class level
download_complete = Signal(Path) # Emits installer_path download_complete = Signal(Path) # Emits installer_path
download_failed = Signal(str) # Emits error message download_failed = Signal(str) # Emits error message
download_progress = Signal(int, int) # Emits (bytes_downloaded, total_bytes)
update_status = Signal(str, str) # Emits (status_text, emoji) update_status = Signal(str, str) # Emits (status_text, emoji)
finished = Signal() finished = Signal()
@ -1712,8 +1762,13 @@ class UpdateDownloadWorker(QObject):
logger.info("Starting download with 5-minute timeout") logger.info("Starting download with 5-minute timeout")
installer_path = loop.run_until_complete( installer_path = loop.run_until_complete(
asyncio.wait_for( asyncio.wait_for(
self.manager.download_update(self.release), self.manager.download_update(
timeout=300 self.release,
progress_callback=lambda cur, tot: self.download_progress.emit(
cur, tot
),
),
timeout=300,
) )
) )
@ -1730,8 +1785,7 @@ class UpdateDownloadWorker(QObject):
logger.info("Starting checksum verification") logger.info("Starting checksum verification")
checksum_ok = loop.run_until_complete( checksum_ok = loop.run_until_complete(
asyncio.wait_for( asyncio.wait_for(
self.manager.verify_checksum(installer_path, self.release), self.manager.verify_checksum(installer_path, self.release), timeout=30
timeout=30
) )
) )
@ -1747,7 +1801,9 @@ class UpdateDownloadWorker(QObject):
except asyncio.TimeoutError as e: except asyncio.TimeoutError as e:
logger.error(f"Download/verification timed out: {e}") logger.error(f"Download/verification timed out: {e}")
self.update_status.emit("Operation timed out", "⏱️") 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: except Exception as e:
logger.error(f"Error during download: {e}") logger.error(f"Error during download: {e}")
self.download_failed.emit(f"Download error: {str(e)[:50]}") self.download_failed.emit(f"Download error: {str(e)[:50]}")

View file

@ -2,10 +2,11 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
QFileDialog, QFileDialog,
@ -41,7 +42,7 @@ class SettingsDialog(QDialog):
- Profiles: Save/load/delete configuration profiles - Profiles: Save/load/delete configuration profiles
""" """
def __init__(self, config: Config, parent=None): def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the settings dialog. """Initialize the settings dialog.
Args: Args:
@ -98,17 +99,16 @@ class SettingsDialog(QDialog):
from webdrop_bridge.config import URLMapping from webdrop_bridge.config import URLMapping
url_mappings = [ url_mappings = [
URLMapping( URLMapping(url_prefix=m["url_prefix"], local_path=m["local_path"])
url_prefix=m["url_prefix"],
local_path=m["local_path"]
)
for m in config_data["url_mappings"] for m in config_data["url_mappings"]
] ]
# Update the config object with new values # Update the config object with new values
old_log_level = self.config.log_level old_log_level = self.config.log_level
self.config.log_level = config_data["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_roots = [Path(r).resolve() for r in config_data["allowed_roots"]]
self.config.allowed_urls = config_data["allowed_urls"] self.config.allowed_urls = config_data["allowed_urls"]
self.config.webapp_url = config_data["webapp_url"] self.config.webapp_url = config_data["webapp_url"]
@ -130,7 +130,7 @@ class SettingsDialog(QDialog):
reconfigure_logging( reconfigure_logging(
logger_name="webdrop_bridge", logger_name="webdrop_bridge",
level=self.config.log_level, 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}") logger.info(f"✅ Log level updated to {self.config.log_level}")
@ -157,7 +157,9 @@ class SettingsDialog(QDialog):
self.webapp_url_input = QLineEdit() self.webapp_url_input = QLineEdit()
self.webapp_url_input.setText(self.config.webapp_url) 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) url_layout.addWidget(self.webapp_url_input)
open_btn = QPushButton("Open") open_btn = QPushButton("Open")
@ -208,6 +210,7 @@ class SettingsDialog(QDialog):
def _open_webapp_url(self) -> None: def _open_webapp_url(self) -> None:
"""Open the webapp URL in the default browser.""" """Open the webapp URL in the default browser."""
import webbrowser import webbrowser
url = self.webapp_url_input.text().strip() url = self.webapp_url_input.text().strip()
if url: if url:
# Handle file:// URLs # Handle file:// URLs
@ -224,14 +227,14 @@ class SettingsDialog(QDialog):
url_prefix, ok1 = QInputDialog.getText( url_prefix, ok1 = QInputDialog.getText(
self, self,
"Add URL Mapping", "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: if ok1 and url_prefix:
local_path, ok2 = QInputDialog.getText( local_path, ok2 = QInputDialog.getText(
self, self,
"Add URL Mapping", "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: if ok2 and local_path:
@ -249,22 +252,16 @@ class SettingsDialog(QDialog):
self._show_error("Please select a mapping to edit") self._show_error("Please select a mapping to edit")
return return
url_prefix = self.url_mappings_table.item(current_row, 0).text() url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore
local_path = self.url_mappings_table.item(current_row, 1).text() local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore
new_url_prefix, ok1 = QInputDialog.getText( new_url_prefix, ok1 = QInputDialog.getText(
self, self, "Edit URL Mapping", "Enter Azure Blob Storage URL prefix:", text=url_prefix
"Edit URL Mapping",
"Enter Azure Blob Storage URL prefix:",
text=url_prefix
) )
if ok1 and new_url_prefix: if ok1 and new_url_prefix:
new_local_path, ok2 = QInputDialog.getText( new_local_path, ok2 = QInputDialog.getText(
self, self, "Edit URL Mapping", "Enter local file system path:", text=local_path
"Edit URL Mapping",
"Enter local file system path:",
text=local_path
) )
if ok2 and new_local_path: if ok2 and new_local_path:
@ -345,6 +342,7 @@ class SettingsDialog(QDialog):
# Log level selection # Log level selection
layout.addWidget(QLabel("Log Level:")) layout.addWidget(QLabel("Log Level:"))
from PySide6.QtWidgets import QComboBox from PySide6.QtWidgets import QComboBox
self.log_level_combo: QComboBox = self._create_log_level_widget() self.log_level_combo: QComboBox = self._create_log_level_widget()
layout.addWidget(self.log_level_combo) layout.addWidget(self.log_level_combo)
@ -443,10 +441,8 @@ class SettingsDialog(QDialog):
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
def _create_log_level_widget(self): def _create_log_level_widget(self) -> QComboBox:
"""Create log level selection widget.""" """Create log level selection widget."""
from PySide6.QtWidgets import QComboBox
combo = QComboBox() combo = QComboBox()
levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
combo.addItems(levels) combo.addItems(levels)
@ -469,9 +465,7 @@ class SettingsDialog(QDialog):
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog
url, ok = QInputDialog.getText( url, ok = QInputDialog.getText(
self, self, "Add URL", "Enter URL pattern (e.g., http://example.com or http://*.example.com):"
"Add URL",
"Enter URL pattern (e.g., http://example.com or http://*.example.com):"
) )
if ok and url: if ok and url:
self.urls_list.addItem(url) self.urls_list.addItem(url)
@ -484,10 +478,7 @@ class SettingsDialog(QDialog):
def _browse_log_file(self) -> None: def _browse_log_file(self) -> None:
"""Browse for log file location.""" """Browse for log file location."""
file_path, _ = QFileDialog.getSaveFileName( file_path, _ = QFileDialog.getSaveFileName(
self, self, "Select Log File", str(Path.home()), "Log Files (*.log);;All Files (*)"
"Select Log File",
str(Path.home()),
"Log Files (*.log);;All Files (*)"
) )
if file_path: if file_path:
self.log_file_input.setText(file_path) self.log_file_input.setText(file_path)
@ -503,9 +494,7 @@ class SettingsDialog(QDialog):
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog
profile_name, ok = QInputDialog.getText( profile_name, ok = QInputDialog.getText(
self, self, "Save Profile", "Enter profile name (e.g., work, personal):"
"Save Profile",
"Enter profile name (e.g., work, personal):"
) )
if ok and profile_name: if ok and profile_name:
@ -546,10 +535,7 @@ class SettingsDialog(QDialog):
def _export_config(self) -> None: def _export_config(self) -> None:
"""Export configuration to file.""" """Export configuration to file."""
file_path, _ = QFileDialog.getSaveFileName( file_path, _ = QFileDialog.getSaveFileName(
self, self, "Export Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)"
"Export Configuration",
str(Path.home()),
"JSON Files (*.json);;All Files (*)"
) )
if file_path: if file_path:
@ -561,10 +547,7 @@ class SettingsDialog(QDialog):
def _import_config(self) -> None: def _import_config(self) -> None:
"""Import configuration from file.""" """Import configuration from file."""
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, self, "Import Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)"
"Import Configuration",
str(Path.home()),
"JSON Files (*.json);;All Files (*)"
) )
if file_path: if file_path:
@ -574,7 +557,7 @@ class SettingsDialog(QDialog):
except ConfigurationError as e: except ConfigurationError as e:
self._show_error(f"Failed to import configuration: {e}") self._show_error(f"Failed to import configuration: {e}")
def _apply_config_data(self, config_data: dict) -> None: def _apply_config_data(self, config_data: Dict[str, Any]) -> None:
"""Apply imported configuration data to UI. """Apply imported configuration data to UI.
Args: Args:
@ -599,7 +582,7 @@ class SettingsDialog(QDialog):
self.width_spin.setValue(config_data.get("window_width", 800)) self.width_spin.setValue(config_data.get("window_width", 800))
self.height_spin.setValue(config_data.get("window_height", 600)) self.height_spin.setValue(config_data.get("window_height", 600))
def get_config_data(self) -> dict: def get_config_data(self) -> Dict[str, Any]:
"""Get updated configuration data from dialog. """Get updated configuration data from dialog.
Returns: Returns:
@ -608,20 +591,26 @@ class SettingsDialog(QDialog):
Raises: Raises:
ConfigurationError: If configuration is invalid 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 = { config_data = {
"app_name": self.config.app_name, "app_name": self.config.app_name,
"app_version": self.config.app_version, "app_version": self.config.app_version,
"log_level": self.log_level_combo.currentText(), "log_level": self.log_level_combo.currentText(),
"log_file": self.log_file_input.text() or None, "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())], "allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())],
"webapp_url": self.webapp_url_input.text().strip(), "webapp_url": self.webapp_url_input.text().strip(),
"url_mappings": [ "url_mappings": [
{ {
"url_prefix": self.url_mappings_table.item(i, 0).text(), "url_prefix": self.url_mappings_table.item(i, 0).text() if self.url_mappings_table.item(i, 0) else "", # type: ignore
"local_path": self.url_mappings_table.item(i, 1).text() "local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore
} }
for i in range(self.url_mappings_table.rowCount()) for i in range(url_mappings_table_count)
], ],
"window_width": self.width_spin.value(), "window_width": self.width_spin.value(),
"window_height": self.height_spin.value(), "window_height": self.height_spin.value(),
@ -640,4 +629,5 @@ class SettingsDialog(QDialog):
message: Error message message: Error message
""" """
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error", message) QMessageBox.critical(self, "Error", message)

View file

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

View file

@ -87,13 +87,6 @@ class TestUpdateAvailableDialog:
with qtbot.waitSignal(dialog.update_later): with qtbot.waitSignal(dialog.update_later):
dialog.update_later_btn.click() 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: class TestDownloadingDialog:
"""Tests for DownloadingDialog.""" """Tests for DownloadingDialog."""

View file

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