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 # Application
APP_NAME=WebDrop Bridge APP_NAME=WebDrop Bridge
APP_VERSION=0.6.5 APP_VERSION=0.6.0
# Web App # Web App
WEBAPP_URL=file:///./webapp/index.html 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 ## [0.6.0] - 2026-02-20
### Added ### 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"?> <?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="0.6.0"
<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">
@ -24,13 +23,6 @@
<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

@ -10,7 +10,7 @@ Requirements:
Usage: Usage:
python build_windows.py [--msi] [--code-sign] [--env-file PATH] python build_windows.py [--msi] [--code-sign] [--env-file PATH]
Options: Options:
--msi Create MSI installer (requires WiX Toolset) --msi Create MSI installer (requires WiX Toolset)
--code-sign Sign executable (requires certificate) --code-sign Sign executable (requires certificate)
@ -27,10 +27,13 @@ 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.buffer, encoding="utf-8", errors="replace") sys.stdout = io.TextIOWrapper(
sys.stderr = io.TextIOWrapper(sys.stderr.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"
)
import subprocess import subprocess
import shutil import shutil
@ -47,7 +50,7 @@ class WindowsBuilder:
def __init__(self, env_file: Path | None = None): def __init__(self, env_file: Path | None = None):
"""Initialize builder paths. """Initialize builder paths.
Args: Args:
env_file: Path to .env file to bundle. If None, uses project root .env. env_file: Path to .env file to bundle. If None, uses project root .env.
If that doesn't exist, raises error. If that doesn't exist, raises error.
@ -58,20 +61,20 @@ class WindowsBuilder:
self.temp_dir = self.build_dir / "temp" / "windows" self.temp_dir = self.build_dir / "temp" / "windows"
self.spec_file = self.build_dir / "webdrop_bridge.spec" self.spec_file = self.build_dir / "webdrop_bridge.spec"
self.version = get_current_version() self.version = get_current_version()
# Validate and set env file # Validate and set env file
if env_file is None: if env_file is None:
env_file = self.project_root / ".env" env_file = self.project_root / ".env"
else: else:
env_file = Path(env_file).resolve() env_file = Path(env_file).resolve()
if not env_file.exists(): if not env_file.exists():
raise FileNotFoundError( raise FileNotFoundError(
f"Configuration file not found: {env_file}\n" f"Configuration file not found: {env_file}\n"
f"Please provide a .env file using --env-file parameter\n" f"Please provide a .env file using --env-file parameter\n"
f"or ensure .env exists in project root" f"or ensure .env exists in project root"
) )
self.env_file = env_file self.env_file = env_file
print(f"📋 Using configuration: {self.env_file}") print(f"📋 Using configuration: {self.env_file}")
@ -112,12 +115,17 @@ class WindowsBuilder:
] ]
print(f" Command: {' '.join(cmd)}") print(f" Command: {' '.join(cmd)}")
# Set environment variable for spec file to use # Set environment variable for spec file to use
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(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: if result.returncode != 0:
print("❌ PyInstaller build failed") print("❌ PyInstaller build failed")
@ -131,11 +139,9 @@ class WindowsBuilder:
print("✅ Executable built successfully") print("✅ Executable built successfully")
print(f"📦 Output: {exe_path}") print(f"📦 Output: {exe_path}")
# Calculate total dist size # Calculate total dist size
total_size = sum( total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file())
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")
@ -168,15 +174,15 @@ class WindowsBuilder:
def set_exe_version(self, exe_path: Path) -> None: def set_exe_version(self, exe_path: Path) -> None:
"""Set executable file version for Windows. """Set executable file version for Windows.
This is important for MSI updates: Windows Installer compares file versions This is important for MSI updates: Windows Installer compares file versions
to determine if files should be updated during a major upgrade. to determine if files should be updated during a major upgrade.
Args: Args:
exe_path: Path to the executable file exe_path: Path to the executable file
""" """
print("\n🏷️ Setting executable version information...") print("\n🏷️ Setting executable version information...")
try: try:
import pefile import pefile
except ImportError: except ImportError:
@ -184,30 +190,30 @@ class WindowsBuilder:
print(" Note: Install with: pip install pefile") print(" Note: Install with: pip install pefile")
print(" EXE version info will be blank (MSI updates may not work correctly)") print(" EXE version info will be blank (MSI updates may not work correctly)")
return return
try: try:
pe = pefile.PE(str(exe_path)) pe = pefile.PE(str(exe_path))
# Parse version into 4-part format (Major, Minor, Build, Revision) # Parse version into 4-part format (Major, Minor, Build, Revision)
version_parts = self.version.split(".") version_parts = self.version.split(".")
while len(version_parts) < 4: while len(version_parts) < 4:
version_parts.append("0") version_parts.append("0")
file_version = tuple(int(v) for v in version_parts[:4]) file_version = tuple(int(v) for v in version_parts[:4])
# Set version resource if it exists # Set version resource if it exists
if hasattr(pe, "VS_FIXEDFILEINFO"): if hasattr(pe, "VS_FIXEDFILEINFO"):
pe.VS_FIXEDFILEINFO[0].FileVersionMS = (file_version[0] << 16) | file_version[1] pe.VS_FIXEDFILEINFO[0].FileVersionMS = (file_version[0] << 16) | file_version[1]
pe.VS_FIXEDFILEINFO[0].FileVersionLS = (file_version[2] << 16) | file_version[3] pe.VS_FIXEDFILEINFO[0].FileVersionLS = (file_version[2] << 16) | file_version[3]
pe.VS_FIXEDFILEINFO[0].ProductVersionMS = (file_version[0] << 16) | file_version[1] pe.VS_FIXEDFILEINFO[0].ProductVersionMS = (file_version[0] << 16) | file_version[1]
pe.VS_FIXEDFILEINFO[0].ProductVersionLS = (file_version[2] << 16) | file_version[3] pe.VS_FIXEDFILEINFO[0].ProductVersionLS = (file_version[2] << 16) | file_version[3]
# Write modified PE back to file # Write modified PE back to file
pe.write(filename=str(exe_path)) pe.write(filename=str(exe_path))
print(f"✅ Version set to {self.version}") print(f"✅ Version set to {self.version}")
else: else:
print("⚠️ No version resource found in EXE") print("⚠️ No version resource found in EXE")
except Exception as e: except Exception as e:
print(f"⚠️ Could not set EXE version: {e}") print(f"⚠️ Could not set EXE version: {e}")
print(" MSI updates may not work correctly without file version info") print(" MSI updates may not work correctly without file version info")
@ -249,25 +255,22 @@ class WindowsBuilder:
if not dist_folder.exists(): if not dist_folder.exists():
print(f"❌ Distribution folder not found: {dist_folder}") print(f"❌ Distribution folder not found: {dist_folder}")
return False return False
harvest_file = self.build_dir / "WebDropBridge_Files.wxs" harvest_file = self.build_dir / "WebDropBridge_Files.wxs"
# Use Heat to harvest all files # Use Heat to harvest all files
heat_cmd = [ heat_cmd = [
str(heat_exe), str(heat_exe),
"dir", "dir",
str(dist_folder), str(dist_folder),
"-cg", "-cg", "AppFiles",
"AppFiles", "-dr", "INSTALLFOLDER",
"-dr",
"INSTALLFOLDER",
"-sfrag", "-sfrag",
"-srd", "-srd",
"-gg", "-gg",
"-o", "-o", str(harvest_file),
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)
if result.returncode != 0: if result.returncode != 0:
print("⚠️ Heat harvest warnings (may be non-critical)") print("⚠️ Heat harvest warnings (may be non-critical)")
@ -275,12 +278,15 @@ class WindowsBuilder:
print(result.stderr[:200]) # Show first 200 chars of errors print(result.stderr[:200]) # Show first 200 chars of errors
else: else:
print(f" ✓ Harvested files") print(f" ✓ Harvested files")
# Post-process harvested file to mark components as 64-bit # Post-process harvested file to mark components as 64-bit
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("<Component ", '<Component Win64="yes" ') content = content.replace(
'<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")
@ -292,18 +298,14 @@ 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", "-ext", "WixUIExtension",
"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", "-o", str(self.build_dir) + "\\",
str(self.build_dir) + "\\",
str(self.build_dir / "WebDropBridge.wxs"), str(self.build_dir / "WebDropBridge.wxs"),
] ]
if harvest_file.exists(): if harvest_file.exists():
candle_cmd.append(str(harvest_file)) candle_cmd.append(str(harvest_file))
@ -316,24 +318,17 @@ 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", "-ext", "WixUIExtension",
"WixUIExtension", "-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
"-ext", "-o", str(msi_output),
"WixUtilExtension",
"-b",
str(self.dist_dir / "WebDropBridge"), # Base path for source files
"-o",
str(msi_output),
str(wix_obj), str(wix_obj),
] ]
if wix_files_obj.exists(): if wix_files_obj.exists():
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( result = subprocess.run(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
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:
@ -354,14 +349,13 @@ class WindowsBuilder:
def _create_wix_source(self) -> bool: def _create_wix_source(self) -> bool:
"""Create WiX source file for MSI generation. """Create WiX source file for MSI generation.
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">
@ -384,13 +378,6 @@ 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" />
@ -446,46 +433,38 @@ 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( def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str:
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:
folder: Root folder to scan folder: Root folder to scan
parent_dir_ref: Parent WiX DirectoryRef ID parent_dir_ref: Parent WiX DirectoryRef ID
parent_rel_path: Relative path for component structure parent_rel_path: Relative path for component structure
indent: Indentation level indent: Indentation level
file_counter: Dictionary to track file IDs for uniqueness file_counter: Dictionary to track file IDs for uniqueness
Returns: Returns:
WiX XML string with all File elements WiX XML string with all File elements
""" """
if file_counter is None: if file_counter is None:
file_counter = {} file_counter = {}
elements = [] elements = []
indent_str = " " * indent indent_str = " " * indent
try: try:
# Get all files in current folder # Get all files in current folder
for item in sorted(folder.iterdir()): for item in sorted(folder.iterdir()):
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)
@ -493,21 +472,24 @@ 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, 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: if sub_elements:
elements.append(sub_elements) elements.append(sub_elements)
except PermissionError: except PermissionError:
print(f" ⚠️ Permission denied accessing {folder}") print(f" ⚠️ Permission denied accessing {folder}")
return "\n".join(elements) return "\n".join(elements)
def _sanitize_id(self, filename: str) -> str: def _sanitize_id(self, filename: str) -> str:
"""Sanitize filename to be a valid WiX identifier. """Sanitize filename to be a valid WiX identifier.
Args: Args:
filename: Filename to sanitize filename: Filename to sanitize
Returns: Returns:
Sanitized identifier Sanitized identifier
""" """
@ -552,7 +534,10 @@ class WindowsBuilder:
str(exe_path), str(exe_path),
] ]
result = subprocess.run(cmd, text=True) result = subprocess.run(
cmd,
text=True
)
if result.returncode != 0: if result.returncode != 0:
print("❌ Code signing failed") print("❌ Code signing failed")
return False return False
@ -599,7 +584,9 @@ class WindowsBuilder:
def main() -> int: def main() -> int:
"""Build Windows MSI installer.""" """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( parser.add_argument(
"--msi", "--msi",
action="store_true", action="store_true",
@ -616,9 +603,9 @@ def main() -> int:
default=None, default=None,
help="Path to .env file to bundle (default: project root .env)", help="Path to .env file to bundle (default: project root .env)",
) )
args = parser.parse_args() args = parser.parse_args()
print("🔄 Syncing version...") print("🔄 Syncing version...")
do_sync_version() do_sync_version()

View file

@ -18,6 +18,5 @@
"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.5" __version__ = "0.6.0"
__author__ = "WebDrop Team" __author__ = "WebDrop Team"
__license__ = "MIT" __license__ = "MIT"

View file

@ -58,8 +58,6 @@ 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
@ -80,7 +78,6 @@ 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":
@ -109,7 +106,10 @@ class Config:
# Parse URL mappings # Parse URL mappings
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", []) for m in data.get("url_mappings", [])
] ]
@ -138,12 +138,11 @@ class Config:
app_name = data.get("app_name", "WebDrop Bridge") app_name = data.get("app_name", "WebDrop Bridge")
stored_window_title = data.get("window_title", "") stored_window_title = data.get("window_title", "")
# Regenerate default window titles on version upgrade # Regenerate default window titles on version upgrade
# 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
@ -171,7 +170,6 @@ 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
@ -197,9 +195,8 @@ 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()
log_file_str = os.getenv("LOG_FILE", None) log_file_str = os.getenv("LOG_FILE", None)
allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public") allowed_roots_str = os.getenv("ALLOWED_ROOTS", "Z:/,C:/Users/Public")
@ -211,13 +208,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"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 # Validate and parse allowed roots
@ -228,7 +225,9 @@ 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(f"Allowed root '{p.strip()}' is not a directory") raise ConfigurationError(
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:
@ -241,7 +240,8 @@ 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_width}x{window_height}" f"Window dimensions must be positive: "
f"{window_width}x{window_height}"
) )
# Create log file path if logging enabled # Create log file path if logging enabled
@ -261,11 +261,10 @@ 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(",") if url.strip()] url.strip() for url in allowed_urls_str.split(",")
if allowed_urls_str if url.strip()
else [] ] if allowed_urls_str 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
@ -283,7 +282,10 @@ 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(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: except (ValueError, OSError) as e:
raise ConfigurationError( raise ConfigurationError(
@ -303,7 +305,6 @@ 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:
@ -311,14 +312,18 @@ class Config:
Args: Args:
config_path: Path to save configuration to config_path: Path to save configuration to
Creates parent directories if they don't exist. Creates parent directories if they don't exist.
""" """
data = { data = {
"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,
@ -331,7 +336,6 @@ 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)
@ -346,7 +350,6 @@ 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:
@ -356,7 +359,7 @@ class Config:
@staticmethod @staticmethod
def get_default_log_dir() -> Path: def get_default_log_dir() -> Path:
"""Get the default directory for log files. """Get the default directory for log files.
Always uses user's AppData directory to ensure permissions work Always uses user's AppData directory to ensure permissions work
correctly in both development and installed scenarios. correctly in both development and installed scenarios.
@ -364,7 +367,6 @@ 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

@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
class ConfigValidator: class ConfigValidator:
"""Validates configuration values against schema. """Validates configuration values against schema.
Provides detailed error messages for invalid configurations. Provides detailed error messages for invalid configurations.
""" """
@ -33,10 +33,10 @@ class ConfigValidator:
@staticmethod @staticmethod
def validate(config_dict: Dict[str, Any]) -> List[str]: def validate(config_dict: Dict[str, Any]) -> List[str]:
"""Validate configuration dictionary. """Validate configuration dictionary.
Args: Args:
config_dict: Configuration dictionary to validate config_dict: Configuration dictionary to validate
Returns: Returns:
List of validation error messages (empty if valid) List of validation error messages (empty if valid)
""" """
@ -53,9 +53,7 @@ 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( errors.append(f"{field}: expected {expected_type.__name__}, got {type(value).__name__}")
f"{field}: expected {expected_type.__name__}, got {type(value).__name__}"
)
continue continue
# Check allowed values # Check allowed values
@ -86,10 +84,10 @@ class ConfigValidator:
@staticmethod @staticmethod
def validate_or_raise(config_dict: Dict[str, Any]) -> None: def validate_or_raise(config_dict: Dict[str, Any]) -> None:
"""Validate configuration and raise error if invalid. """Validate configuration and raise error if invalid.
Args: Args:
config_dict: Configuration dictionary to validate config_dict: Configuration dictionary to validate
Raises: Raises:
ConfigurationError: If configuration is invalid ConfigurationError: If configuration is invalid
""" """
@ -100,26 +98,26 @@ class ConfigValidator:
class ConfigProfile: class ConfigProfile:
"""Manages named configuration profiles. """Manages named configuration profiles.
Profiles are stored in ~/.webdrop-bridge/profiles/ directory as JSON files. Profiles are stored in ~/.webdrop-bridge/profiles/ directory as JSON files.
""" """
PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles" PROFILES_DIR = Path.home() / ".webdrop-bridge" / "profiles"
def __init__(self) -> None: def __init__(self):
"""Initialize profile manager.""" """Initialize profile manager."""
self.PROFILES_DIR.mkdir(parents=True, exist_ok=True) self.PROFILES_DIR.mkdir(parents=True, exist_ok=True)
def save_profile(self, profile_name: str, config: Config) -> Path: def save_profile(self, profile_name: str, config: Config) -> Path:
"""Save configuration as a named profile. """Save configuration as a named profile.
Args: Args:
profile_name: Name of the profile (e.g., "work", "personal") profile_name: Name of the profile (e.g., "work", "personal")
config: Config object to save config: Config object to save
Returns: Returns:
Path to the saved profile file Path to the saved profile file
Raises: Raises:
ConfigurationError: If profile name is invalid ConfigurationError: If profile name is invalid
""" """
@ -150,13 +148,13 @@ class ConfigProfile:
def load_profile(self, profile_name: str) -> Dict[str, Any]: def load_profile(self, profile_name: str) -> Dict[str, Any]:
"""Load configuration from a named profile. """Load configuration from a named profile.
Args: Args:
profile_name: Name of the profile to load profile_name: Name of the profile to load
Returns: Returns:
Configuration dictionary Configuration dictionary
Raises: Raises:
ConfigurationError: If profile not found or invalid ConfigurationError: If profile not found or invalid
""" """
@ -175,7 +173,7 @@ class ConfigProfile:
def list_profiles(self) -> List[str]: def list_profiles(self) -> List[str]:
"""List all available profiles. """List all available profiles.
Returns: Returns:
List of profile names (without .json extension) List of profile names (without .json extension)
""" """
@ -186,10 +184,10 @@ class ConfigProfile:
def delete_profile(self, profile_name: str) -> None: def delete_profile(self, profile_name: str) -> None:
"""Delete a profile. """Delete a profile.
Args: Args:
profile_name: Name of the profile to delete profile_name: Name of the profile to delete
Raises: Raises:
ConfigurationError: If profile not found ConfigurationError: If profile not found
""" """
@ -211,11 +209,11 @@ class ConfigExporter:
@staticmethod @staticmethod
def export_to_json(config: Config, output_path: Path) -> None: def export_to_json(config: Config, output_path: Path) -> None:
"""Export configuration to JSON file. """Export configuration to JSON file.
Args: Args:
config: Config object to export config: Config object to export
output_path: Path to write JSON file output_path: Path to write JSON file
Raises: Raises:
ConfigurationError: If export fails ConfigurationError: If export fails
""" """
@ -242,13 +240,13 @@ class ConfigExporter:
@staticmethod @staticmethod
def import_from_json(input_path: Path) -> Dict[str, Any]: def import_from_json(input_path: Path) -> Dict[str, Any]:
"""Import configuration from JSON file. """Import configuration from JSON file.
Args: Args:
input_path: Path to JSON file to import input_path: Path to JSON file to import
Returns: Returns:
Configuration dictionary Configuration dictionary
Raises: Raises:
ConfigurationError: If import fails or validation fails ConfigurationError: If import fails or validation fails
""" """

View file

@ -44,8 +44,10 @@ 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 = 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 # Cache management
self.cache_dir = config_dir or Path.home() / ".webdrop-bridge" self.cache_dir = config_dir or Path.home() / ".webdrop-bridge"
self.cache_dir.mkdir(parents=True, exist_ok=True) self.cache_dir.mkdir(parents=True, exist_ok=True)
@ -66,7 +68,7 @@ class UpdateManager:
""" """
# Remove 'v' prefix if present # Remove 'v' prefix if present
version_str = version_str.lstrip("v") version_str = version_str.lstrip("v")
try: try:
parts = version_str.split(".") parts = version_str.split(".")
if len(parts) != 3: if len(parts) != 3:
@ -144,45 +146,44 @@ class UpdateManager:
Release object if newer version available, None otherwise Release object if newer version available, None otherwise
""" """
logger.debug(f"check_for_updates() called, current version: {self.current_version}") logger.debug(f"check_for_updates() called, current version: {self.current_version}")
# Only use cache when a pending update was already found (avoids # Try cache first
# showing the update dialog on every start). "No update" is never logger.debug("Checking cache...")
# 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")
logger.debug(f"Cached pending update version: {version}") if not self._is_newer_version(version):
if self._is_newer_version(version): logger.info("No newer version available (cached)")
logger.info(f"Returning cached pending update: {version}") return None
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)
# Always fetch fresh from API so new releases are seen immediately # Fetch from API
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(None, self._fetch_release), loop.run_in_executor(
timeout=8, None, self._fetch_release
),
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)
@ -203,11 +204,11 @@ class UpdateManager:
""" """
try: try:
logger.debug(f"Fetching release from {self.api_endpoint}") logger.debug(f"Fetching release from {self.api_endpoint}")
# Set socket timeout to prevent hanging # Set socket timeout to prevent hanging
old_timeout = socket.getdefaulttimeout() old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(5) socket.setdefaulttimeout(5)
try: try:
logger.debug("Opening URL connection...") logger.debug("Opening URL connection...")
with urlopen(self.api_endpoint, timeout=5) as response: with urlopen(self.api_endpoint, timeout=5) as response:
@ -226,19 +227,21 @@ class UpdateManager:
} }
finally: finally:
socket.setdefaulttimeout(old_timeout) socket.setdefaulttimeout(old_timeout)
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, progress_callback=None self, release: Release, output_dir: Optional[Path] = None
) -> Optional[Path]: ) -> Optional[Path]:
"""Download installer from release assets. """Download installer from release assets.
@ -270,7 +273,7 @@ class UpdateManager:
try: try:
logger.info(f"Downloading {installer_asset['name']}") logger.info(f"Downloading {installer_asset['name']}")
# Run in thread pool with 5-minute timeout for large files # Run in thread pool with 5-minute timeout for large files
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
success = await asyncio.wait_for( success = await asyncio.wait_for(
@ -279,11 +282,10 @@ 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:
logger.info(f"Downloaded to {output_file}") logger.info(f"Downloaded to {output_file}")
return output_file return output_file
@ -300,13 +302,12 @@ class UpdateManager:
output_file.unlink() output_file.unlink()
return None 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). """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
@ -314,28 +315,17 @@ 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:
while True: f.write(response.read())
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(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. """Verify file checksum against release checksum file.
Args: Args:
@ -345,12 +335,10 @@ class UpdateManager:
Returns: Returns:
True if checksum matches, False otherwise True if checksum matches, False otherwise
""" """
# Find .sha256 file matching the installer name (e.g. Setup.msi.sha256) # Find .sha256 file in release assets
# 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"] == f"{installer_name}.sha256": if asset["name"].endswith(".sha256"):
checksum_asset = asset checksum_asset = asset
break break
@ -360,7 +348,7 @@ class UpdateManager:
try: try:
logger.info("Verifying checksum...") logger.info("Verifying checksum...")
# Download checksum file with 30 second timeout # Download checksum file with 30 second timeout
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
checksum_content = await asyncio.wait_for( checksum_content = await asyncio.wait_for(
@ -369,7 +357,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:
@ -389,7 +377,9 @@ class UpdateManager:
logger.info("Checksum verification passed") logger.info("Checksum verification passed")
return True return True
else: else:
logger.error(f"Checksum mismatch: {file_checksum} != {expected_checksum}") logger.error(
f"Checksum mismatch: {file_checksum} != {expected_checksum}"
)
return False return False
except asyncio.TimeoutError: except asyncio.TimeoutError:
@ -436,12 +426,9 @@ class UpdateManager:
import subprocess import subprocess
if platform.system() == "Windows": if platform.system() == "Windows":
# Windows: MSI files must be launched via msiexec # Windows: Run MSI installer
logger.info(f"Launching installer: {installer_path}") logger.info(f"Launching installer: {installer_path}")
if str(installer_path).lower().endswith(".msi"): subprocess.Popen([str(installer_path)])
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

File diff suppressed because it is too large Load diff

View file

@ -2,11 +2,10 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import 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,
@ -33,7 +32,7 @@ logger = logging.getLogger(__name__)
class SettingsDialog(QDialog): class SettingsDialog(QDialog):
"""Dialog for managing application settings and configuration. """Dialog for managing application settings and configuration.
Provides tabs for: Provides tabs for:
- Paths: Manage allowed root directories - Paths: Manage allowed root directories
- URLs: Manage allowed web URLs - URLs: Manage allowed web URLs
@ -42,9 +41,9 @@ class SettingsDialog(QDialog):
- Profiles: Save/load/delete configuration profiles - 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. """Initialize the settings dialog.
Args: Args:
config: Current application configuration config: Current application configuration
parent: Parent widget parent: Parent widget
@ -54,16 +53,16 @@ class SettingsDialog(QDialog):
self.profile_manager = ConfigProfile() self.profile_manager = ConfigProfile()
self.setWindowTitle("Settings") self.setWindowTitle("Settings")
self.setGeometry(100, 100, 600, 500) self.setGeometry(100, 100, 600, 500)
self.setup_ui() self.setup_ui()
def setup_ui(self) -> None: def setup_ui(self) -> None:
"""Set up the dialog UI with tabs.""" """Set up the dialog UI with tabs."""
layout = QVBoxLayout() layout = QVBoxLayout()
# Create tab widget # Create tab widget
self.tabs = QTabWidget() self.tabs = QTabWidget()
# Add tabs # Add tabs
self.tabs.addTab(self._create_web_source_tab(), "Web Source") self.tabs.addTab(self._create_web_source_tab(), "Web Source")
self.tabs.addTab(self._create_paths_tab(), "Paths") self.tabs.addTab(self._create_paths_tab(), "Paths")
@ -71,9 +70,9 @@ class SettingsDialog(QDialog):
self.tabs.addTab(self._create_logging_tab(), "Logging") self.tabs.addTab(self._create_logging_tab(), "Logging")
self.tabs.addTab(self._create_window_tab(), "Window") self.tabs.addTab(self._create_window_tab(), "Window")
self.tabs.addTab(self._create_profiles_tab(), "Profiles") self.tabs.addTab(self._create_profiles_tab(), "Profiles")
layout.addWidget(self.tabs) layout.addWidget(self.tabs)
# Add buttons # Add buttons
button_box = QDialogButtonBox( button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
@ -81,12 +80,12 @@ class SettingsDialog(QDialog):
button_box.accepted.connect(self.accept) button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject) button_box.rejected.connect(self.reject)
layout.addWidget(button_box) layout.addWidget(button_box)
self.setLayout(layout) self.setLayout(layout)
def accept(self) -> None: def accept(self) -> None:
"""Handle OK button - save configuration changes to file. """Handle OK button - save configuration changes to file.
Validates configuration and saves to the default config path. Validates configuration and saves to the default config path.
Applies log level changes immediately in the running application. Applies log level changes immediately in the running application.
If validation or save fails, shows error and stays in dialog. If validation or save fails, shows error and stays in dialog.
@ -94,49 +93,50 @@ class SettingsDialog(QDialog):
try: try:
# Get updated configuration data from UI # Get updated configuration data from UI
config_data = self.get_config_data() config_data = self.get_config_data()
# Convert URL mappings from dict to URLMapping objects # Convert URL mappings from dict to URLMapping objects
from webdrop_bridge.config import URLMapping from webdrop_bridge.config import URLMapping
url_mappings = [ 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"] 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 = ( self.config.log_file = Path(config_data["log_file"]) if config_data["log_file"] else None
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"]
self.config.url_mappings = url_mappings self.config.url_mappings = url_mappings
self.config.window_width = config_data["window_width"] self.config.window_width = config_data["window_width"]
self.config.window_height = config_data["window_height"] self.config.window_height = config_data["window_height"]
# Save to file (creates parent dirs if needed) # Save to file (creates parent dirs if needed)
config_path = Config.get_default_config_path() config_path = Config.get_default_config_path()
self.config.to_file(config_path) self.config.to_file(config_path)
logger.info(f"Configuration saved to {config_path}") logger.info(f"Configuration saved to {config_path}")
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})") logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}") logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
# Apply log level change immediately to running application # Apply log level change immediately to running application
if old_log_level != self.config.log_level: if old_log_level != self.config.log_level:
logger.info(f"🔄 Updating log level: {old_log_level}{self.config.log_level}") logger.info(f"🔄 Updating log level: {old_log_level}{self.config.log_level}")
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}")
# Call parent accept to close dialog # Call parent accept to close dialog
super().accept() super().accept()
except ConfigurationError as e: except ConfigurationError as e:
logger.error(f"Configuration error: {e}") logger.error(f"Configuration error: {e}")
self._show_error(f"Configuration Error:\n\n{e}") self._show_error(f"Configuration Error:\n\n{e}")
@ -147,70 +147,67 @@ class SettingsDialog(QDialog):
def _create_web_source_tab(self) -> QWidget: def _create_web_source_tab(self) -> QWidget:
"""Create web source configuration tab.""" """Create web source configuration tab."""
from PySide6.QtWidgets import QTableWidget, QTableWidgetItem from PySide6.QtWidgets import QTableWidget, QTableWidgetItem
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
# Webapp URL configuration # Webapp URL configuration
layout.addWidget(QLabel("Web Application URL:")) layout.addWidget(QLabel("Web Application URL:"))
url_layout = QHBoxLayout() url_layout = QHBoxLayout()
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( self.webapp_url_input.setPlaceholderText("e.g., http://localhost:8080 or file:///./webapp/index.html")
"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")
open_btn.clicked.connect(self._open_webapp_url) open_btn.clicked.connect(self._open_webapp_url)
url_layout.addWidget(open_btn) url_layout.addWidget(open_btn)
layout.addLayout(url_layout) layout.addLayout(url_layout)
# URL Mappings (Azure Blob URL → Local Path) # URL Mappings (Azure Blob URL → Local Path)
layout.addWidget(QLabel("URL Mappings (Azure Blob Storage → Local Paths):")) layout.addWidget(QLabel("URL Mappings (Azure Blob Storage → Local Paths):"))
# Create table for URL mappings # Create table for URL mappings
self.url_mappings_table = QTableWidget() self.url_mappings_table = QTableWidget()
self.url_mappings_table.setColumnCount(2) self.url_mappings_table.setColumnCount(2)
self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"]) self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"])
self.url_mappings_table.horizontalHeader().setStretchLastSection(True) self.url_mappings_table.horizontalHeader().setStretchLastSection(True)
# Populate from config # Populate from config
for mapping in self.config.url_mappings: for mapping in self.config.url_mappings:
row = self.url_mappings_table.rowCount() row = self.url_mappings_table.rowCount()
self.url_mappings_table.insertRow(row) self.url_mappings_table.insertRow(row)
self.url_mappings_table.setItem(row, 0, QTableWidgetItem(mapping.url_prefix)) self.url_mappings_table.setItem(row, 0, QTableWidgetItem(mapping.url_prefix))
self.url_mappings_table.setItem(row, 1, QTableWidgetItem(mapping.local_path)) self.url_mappings_table.setItem(row, 1, QTableWidgetItem(mapping.local_path))
layout.addWidget(self.url_mappings_table) layout.addWidget(self.url_mappings_table)
# Buttons for URL mapping management # Buttons for URL mapping management
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
add_mapping_btn = QPushButton("Add Mapping") add_mapping_btn = QPushButton("Add Mapping")
add_mapping_btn.clicked.connect(self._add_url_mapping) add_mapping_btn.clicked.connect(self._add_url_mapping)
button_layout.addWidget(add_mapping_btn) button_layout.addWidget(add_mapping_btn)
edit_mapping_btn = QPushButton("Edit Selected") edit_mapping_btn = QPushButton("Edit Selected")
edit_mapping_btn.clicked.connect(self._edit_url_mapping) edit_mapping_btn.clicked.connect(self._edit_url_mapping)
button_layout.addWidget(edit_mapping_btn) button_layout.addWidget(edit_mapping_btn)
remove_mapping_btn = QPushButton("Remove Selected") remove_mapping_btn = QPushButton("Remove Selected")
remove_mapping_btn.clicked.connect(self._remove_url_mapping) remove_mapping_btn.clicked.connect(self._remove_url_mapping)
button_layout.addWidget(remove_mapping_btn) button_layout.addWidget(remove_mapping_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
layout.addStretch() layout.addStretch()
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
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
@ -219,55 +216,61 @@ class SettingsDialog(QDialog):
except Exception as e: except Exception as e:
logger.error(f"Failed to open URL: {e}") logger.error(f"Failed to open URL: {e}")
self._show_error(f"Failed to open URL:\n\n{e}") self._show_error(f"Failed to open URL:\n\n{e}")
def _add_url_mapping(self) -> None: def _add_url_mapping(self) -> None:
"""Add new URL mapping.""" """Add new URL mapping."""
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog
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:
row = self.url_mappings_table.rowCount() row = self.url_mappings_table.rowCount()
self.url_mappings_table.insertRow(row) self.url_mappings_table.insertRow(row)
self.url_mappings_table.setItem(row, 0, QTableWidgetItem(url_prefix)) self.url_mappings_table.setItem(row, 0, QTableWidgetItem(url_prefix))
self.url_mappings_table.setItem(row, 1, QTableWidgetItem(local_path)) self.url_mappings_table.setItem(row, 1, QTableWidgetItem(local_path))
def _edit_url_mapping(self) -> None: def _edit_url_mapping(self) -> None:
"""Edit selected URL mapping.""" """Edit selected URL mapping."""
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog
current_row = self.url_mappings_table.currentRow() current_row = self.url_mappings_table.currentRow()
if current_row < 0: if current_row < 0:
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() # type: ignore url_prefix = self.url_mappings_table.item(current_row, 0).text()
local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore local_path = self.url_mappings_table.item(current_row, 1).text()
new_url_prefix, ok1 = QInputDialog.getText( 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: if ok1 and new_url_prefix:
new_local_path, ok2 = QInputDialog.getText( 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: if ok2 and new_local_path:
self.url_mappings_table.setItem(current_row, 0, QTableWidgetItem(new_url_prefix)) self.url_mappings_table.setItem(current_row, 0, QTableWidgetItem(new_url_prefix))
self.url_mappings_table.setItem(current_row, 1, QTableWidgetItem(new_local_path)) self.url_mappings_table.setItem(current_row, 1, QTableWidgetItem(new_local_path))
def _remove_url_mapping(self) -> None: def _remove_url_mapping(self) -> None:
"""Remove selected URL mapping.""" """Remove selected URL mapping."""
current_row = self.url_mappings_table.currentRow() current_row = self.url_mappings_table.currentRow()
@ -278,29 +281,29 @@ class SettingsDialog(QDialog):
"""Create paths configuration tab.""" """Create paths configuration tab."""
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed root directories for file access:")) layout.addWidget(QLabel("Allowed root directories for file access:"))
# List widget for paths # List widget for paths
self.paths_list = QListWidget() self.paths_list = QListWidget()
for path in self.config.allowed_roots: for path in self.config.allowed_roots:
self.paths_list.addItem(str(path)) self.paths_list.addItem(str(path))
layout.addWidget(self.paths_list) layout.addWidget(self.paths_list)
# Buttons for path management # Buttons for path management
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
add_path_btn = QPushButton("Add Path") add_path_btn = QPushButton("Add Path")
add_path_btn.clicked.connect(self._add_path) add_path_btn.clicked.connect(self._add_path)
button_layout.addWidget(add_path_btn) button_layout.addWidget(add_path_btn)
remove_path_btn = QPushButton("Remove Selected") remove_path_btn = QPushButton("Remove Selected")
remove_path_btn.clicked.connect(self._remove_path) remove_path_btn.clicked.connect(self._remove_path)
button_layout.addWidget(remove_path_btn) button_layout.addWidget(remove_path_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
layout.addStretch() layout.addStretch()
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
@ -308,29 +311,29 @@ class SettingsDialog(QDialog):
"""Create URLs configuration tab.""" """Create URLs configuration tab."""
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):")) layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):"))
# List widget for URLs # List widget for URLs
self.urls_list = QListWidget() self.urls_list = QListWidget()
for url in self.config.allowed_urls: for url in self.config.allowed_urls:
self.urls_list.addItem(url) self.urls_list.addItem(url)
layout.addWidget(self.urls_list) layout.addWidget(self.urls_list)
# Buttons for URL management # Buttons for URL management
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
add_url_btn = QPushButton("Add URL") add_url_btn = QPushButton("Add URL")
add_url_btn.clicked.connect(self._add_url) add_url_btn.clicked.connect(self._add_url)
button_layout.addWidget(add_url_btn) button_layout.addWidget(add_url_btn)
remove_url_btn = QPushButton("Remove Selected") remove_url_btn = QPushButton("Remove Selected")
remove_url_btn.clicked.connect(self._remove_url) remove_url_btn.clicked.connect(self._remove_url)
button_layout.addWidget(remove_url_btn) button_layout.addWidget(remove_url_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
layout.addStretch() layout.addStretch()
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
@ -338,28 +341,27 @@ class SettingsDialog(QDialog):
"""Create logging configuration tab.""" """Create logging configuration tab."""
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
# 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)
# Log file path # Log file path
layout.addWidget(QLabel("Log File (optional):")) layout.addWidget(QLabel("Log File (optional):"))
log_file_layout = QHBoxLayout() log_file_layout = QHBoxLayout()
self.log_file_input = QLineEdit() self.log_file_input = QLineEdit()
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "") self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
log_file_layout.addWidget(self.log_file_input) log_file_layout.addWidget(self.log_file_input)
browse_btn = QPushButton("Browse...") browse_btn = QPushButton("Browse...")
browse_btn.clicked.connect(self._browse_log_file) browse_btn.clicked.connect(self._browse_log_file)
log_file_layout.addWidget(browse_btn) log_file_layout.addWidget(browse_btn)
layout.addLayout(log_file_layout) layout.addLayout(log_file_layout)
layout.addStretch() layout.addStretch()
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
@ -368,7 +370,7 @@ class SettingsDialog(QDialog):
"""Create window settings tab.""" """Create window settings tab."""
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
# Window width # Window width
width_layout = QHBoxLayout() width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Window Width:")) width_layout.addWidget(QLabel("Window Width:"))
@ -379,7 +381,7 @@ class SettingsDialog(QDialog):
width_layout.addWidget(self.width_spin) width_layout.addWidget(self.width_spin)
width_layout.addStretch() width_layout.addStretch()
layout.addLayout(width_layout) layout.addLayout(width_layout)
# Window height # Window height
height_layout = QHBoxLayout() height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Window Height:")) height_layout.addWidget(QLabel("Window Height:"))
@ -390,7 +392,7 @@ class SettingsDialog(QDialog):
height_layout.addWidget(self.height_spin) height_layout.addWidget(self.height_spin)
height_layout.addStretch() height_layout.addStretch()
layout.addLayout(height_layout) layout.addLayout(height_layout)
layout.addStretch() layout.addStretch()
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
@ -399,50 +401,52 @@ class SettingsDialog(QDialog):
"""Create profiles management tab.""" """Create profiles management tab."""
widget = QWidget() widget = QWidget()
layout = QVBoxLayout() layout = QVBoxLayout()
layout.addWidget(QLabel("Saved Configuration Profiles:")) layout.addWidget(QLabel("Saved Configuration Profiles:"))
# List of profiles # List of profiles
self.profiles_list = QListWidget() self.profiles_list = QListWidget()
self._refresh_profiles_list() self._refresh_profiles_list()
layout.addWidget(self.profiles_list) layout.addWidget(self.profiles_list)
# Profile management buttons # Profile management buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
save_profile_btn = QPushButton("Save as Profile") save_profile_btn = QPushButton("Save as Profile")
save_profile_btn.clicked.connect(self._save_profile) save_profile_btn.clicked.connect(self._save_profile)
button_layout.addWidget(save_profile_btn) button_layout.addWidget(save_profile_btn)
load_profile_btn = QPushButton("Load Profile") load_profile_btn = QPushButton("Load Profile")
load_profile_btn.clicked.connect(self._load_profile) load_profile_btn.clicked.connect(self._load_profile)
button_layout.addWidget(load_profile_btn) button_layout.addWidget(load_profile_btn)
delete_profile_btn = QPushButton("Delete Profile") delete_profile_btn = QPushButton("Delete Profile")
delete_profile_btn.clicked.connect(self._delete_profile) delete_profile_btn.clicked.connect(self._delete_profile)
button_layout.addWidget(delete_profile_btn) button_layout.addWidget(delete_profile_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
# Export/Import buttons # Export/Import buttons
export_layout = QHBoxLayout() export_layout = QHBoxLayout()
export_btn = QPushButton("Export Configuration") export_btn = QPushButton("Export Configuration")
export_btn.clicked.connect(self._export_config) export_btn.clicked.connect(self._export_config)
export_layout.addWidget(export_btn) export_layout.addWidget(export_btn)
import_btn = QPushButton("Import Configuration") import_btn = QPushButton("Import Configuration")
import_btn.clicked.connect(self._import_config) import_btn.clicked.connect(self._import_config)
export_layout.addWidget(import_btn) export_layout.addWidget(import_btn)
layout.addLayout(export_layout) layout.addLayout(export_layout)
layout.addStretch() layout.addStretch()
widget.setLayout(layout) widget.setLayout(layout)
return widget return widget
def _create_log_level_widget(self) -> QComboBox: def _create_log_level_widget(self):
"""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)
@ -463,9 +467,11 @@ class SettingsDialog(QDialog):
def _add_url(self) -> None: def _add_url(self) -> None:
"""Add a new allowed URL.""" """Add a new allowed URL."""
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog
url, ok = QInputDialog.getText( 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: if ok and url:
self.urls_list.addItem(url) self.urls_list.addItem(url)
@ -478,7 +484,10 @@ 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, "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: if file_path:
self.log_file_input.setText(file_path) self.log_file_input.setText(file_path)
@ -492,11 +501,13 @@ class SettingsDialog(QDialog):
def _save_profile(self) -> None: def _save_profile(self) -> None:
"""Save current configuration as a profile.""" """Save current configuration as a profile."""
from PySide6.QtWidgets import QInputDialog from PySide6.QtWidgets import QInputDialog
profile_name, ok = QInputDialog.getText( 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: if ok and profile_name:
try: try:
self.profile_manager.save_profile(profile_name, self.config) self.profile_manager.save_profile(profile_name, self.config)
@ -510,7 +521,7 @@ class SettingsDialog(QDialog):
if not current_item: if not current_item:
self._show_error("Please select a profile to load") self._show_error("Please select a profile to load")
return return
profile_name = current_item.text() profile_name = current_item.text()
try: try:
config_data = self.profile_manager.load_profile(profile_name) config_data = self.profile_manager.load_profile(profile_name)
@ -524,7 +535,7 @@ class SettingsDialog(QDialog):
if not current_item: if not current_item:
self._show_error("Please select a profile to delete") self._show_error("Please select a profile to delete")
return return
profile_name = current_item.text() profile_name = current_item.text()
try: try:
self.profile_manager.delete_profile(profile_name) self.profile_manager.delete_profile(profile_name)
@ -535,9 +546,12 @@ 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, "Export Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)" self,
"Export Configuration",
str(Path.home()),
"JSON Files (*.json);;All Files (*)"
) )
if file_path: if file_path:
try: try:
ConfigExporter.export_to_json(self.config, Path(file_path)) ConfigExporter.export_to_json(self.config, Path(file_path))
@ -547,9 +561,12 @@ 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, "Import Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)" self,
"Import Configuration",
str(Path.home()),
"JSON Files (*.json);;All Files (*)"
) )
if file_path: if file_path:
try: try:
config_data = ConfigExporter.import_from_json(Path(file_path)) config_data = ConfigExporter.import_from_json(Path(file_path))
@ -557,9 +574,9 @@ 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[str, Any]) -> None: def _apply_config_data(self, config_data: dict) -> None:
"""Apply imported configuration data to UI. """Apply imported configuration data to UI.
Args: Args:
config_data: Configuration dictionary config_data: Configuration dictionary
""" """
@ -567,67 +584,60 @@ class SettingsDialog(QDialog):
self.paths_list.clear() self.paths_list.clear()
for path in config_data.get("allowed_roots", []): for path in config_data.get("allowed_roots", []):
self.paths_list.addItem(str(path)) self.paths_list.addItem(str(path))
# Apply URLs # Apply URLs
self.urls_list.clear() self.urls_list.clear()
for url in config_data.get("allowed_urls", []): for url in config_data.get("allowed_urls", []):
self.urls_list.addItem(url) self.urls_list.addItem(url)
# Apply logging settings # Apply logging settings
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO")) self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
log_file = config_data.get("log_file") log_file = config_data.get("log_file")
self.log_file_input.setText(str(log_file) if log_file else "") self.log_file_input.setText(str(log_file) if log_file else "")
# Apply window settings # Apply window settings
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[str, Any]: def get_config_data(self) -> dict:
"""Get updated configuration data from dialog. """Get updated configuration data from dialog.
Returns: Returns:
Configuration dictionary Configuration dictionary
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": [ "allowed_roots": [self.paths_list.item(i).text() for i in range(self.paths_list.count())],
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() if self.url_mappings_table.item(i, 0) else "", # type: ignore "url_prefix": self.url_mappings_table.item(i, 0).text(),
"local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore "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_width": self.width_spin.value(),
"window_height": self.height_spin.value(), "window_height": self.height_spin.value(),
"enable_logging": self.config.enable_logging, "enable_logging": self.config.enable_logging,
} }
# Validate # Validate
ConfigValidator.validate_or_raise(config_data) ConfigValidator.validate_or_raise(config_data)
return config_data return config_data
def _show_error(self, message: str) -> None: def _show_error(self, message: str) -> None:
"""Show error message to user. """Show error message to user.
Args: Args:
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

@ -30,13 +30,13 @@ logger = logging.getLogger(__name__)
class CheckingDialog(QDialog): class CheckingDialog(QDialog):
"""Dialog shown while checking for updates. """Dialog shown while checking for updates.
Shows an animated progress indicator and times out after 10 seconds. Shows an animated progress indicator and times out after 10 seconds.
""" """
def __init__(self, parent=None): def __init__(self, parent=None):
"""Initialize checking dialog. """Initialize checking dialog.
Args: Args:
parent: Parent widget parent: Parent widget
""" """
@ -45,43 +45,44 @@ class CheckingDialog(QDialog):
self.setModal(True) self.setModal(True)
self.setMinimumWidth(300) self.setMinimumWidth(300)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
layout = QVBoxLayout() layout = QVBoxLayout()
# Status label # Status label
self.label = QLabel("Checking for updates...") self.label = QLabel("Checking for updates...")
layout.addWidget(self.label) layout.addWidget(self.label)
# Animated progress bar # Animated progress bar
self.progress = QProgressBar() self.progress = QProgressBar()
self.progress.setMaximum(0) # Makes it animated self.progress.setMaximum(0) # Makes it animated
layout.addWidget(self.progress) layout.addWidget(self.progress)
# Timeout info # Timeout info
info_label = QLabel("This may take up to 10 seconds") info_label = QLabel("This may take up to 10 seconds")
info_label.setStyleSheet("color: gray; font-size: 11px;") info_label.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(info_label) layout.addWidget(info_label)
self.setLayout(layout) self.setLayout(layout)
class UpdateAvailableDialog(QDialog): class UpdateAvailableDialog(QDialog):
"""Dialog shown when an update is available. """Dialog shown when an update is available.
Displays: Displays:
- Current version - Current version
- Available version - Available version
- Changelog/release notes - Changelog/release notes
- Buttons: Update Now, Update Later, Skip This Version - Buttons: Update Now, Update Later, Skip This Version
""" """
# 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.
Args: Args:
version: New version string (e.g., "0.0.2") version: New version string (e.g., "0.0.2")
changelog: Release notes text changelog: Release notes text
@ -92,63 +93,72 @@ class UpdateAvailableDialog(QDialog):
self.setModal(True) self.setModal(True)
self.setMinimumWidth(400) self.setMinimumWidth(400)
self.setMinimumHeight(300) self.setMinimumHeight(300)
layout = QVBoxLayout() layout = QVBoxLayout()
# Header # Header
header = QLabel(f"WebDrop Bridge v{version} is available") header = QLabel(f"WebDrop Bridge v{version} is available")
header.setStyleSheet("font-weight: bold; font-size: 14px;") header.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(header) layout.addWidget(header)
# Changelog # Changelog
changelog_label = QLabel("Release Notes:") changelog_label = QLabel("Release Notes:")
changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;") changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
layout.addWidget(changelog_label) layout.addWidget(changelog_label)
self.changelog = QTextEdit() self.changelog = QTextEdit()
self.changelog.setText(changelog) self.changelog.setText(changelog)
self.changelog.setReadOnly(True) self.changelog.setReadOnly(True)
layout.addWidget(self.changelog) layout.addWidget(self.changelog)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.update_now_btn = QPushButton("Update Now") self.update_now_btn = QPushButton("Update Now")
self.update_now_btn.clicked.connect(self._on_update_now) self.update_now_btn.clicked.connect(self._on_update_now)
button_layout.addWidget(self.update_now_btn) button_layout.addWidget(self.update_now_btn)
self.update_later_btn = QPushButton("Later") self.update_later_btn = QPushButton("Later")
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)
def _on_update_now(self): def _on_update_now(self):
"""Handle update now button click.""" """Handle update now button click."""
self.update_now.emit() self.update_now.emit()
self.accept() self.accept()
def _on_update_later(self): def _on_update_later(self):
"""Handle update later button click.""" """Handle update later button click."""
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.
Displays: Displays:
- Download progress bar - Download progress bar
- Current file being downloaded - Current file being downloaded
- Cancel button - Cancel button
""" """
cancel_download = Signal() cancel_download = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
"""Initialize downloading dialog. """Initialize downloading dialog.
Args: Args:
parent: Parent widget parent: Parent widget
""" """
@ -157,40 +167,40 @@ class DownloadingDialog(QDialog):
self.setModal(True) self.setModal(True)
self.setMinimumWidth(350) self.setMinimumWidth(350)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
layout = QVBoxLayout() layout = QVBoxLayout()
# Header # Header
header = QLabel("Downloading update...") header = QLabel("Downloading update...")
header.setStyleSheet("font-weight: bold;") header.setStyleSheet("font-weight: bold;")
layout.addWidget(header) layout.addWidget(header)
# File label # File label
self.file_label = QLabel("Preparing download") self.file_label = QLabel("Preparing download")
layout.addWidget(self.file_label) layout.addWidget(self.file_label)
# Progress bar # Progress bar
self.progress = QProgressBar() self.progress = QProgressBar()
self.progress.setMinimum(0) self.progress.setMinimum(0)
self.progress.setMaximum(100) self.progress.setMaximum(100)
self.progress.setValue(0) self.progress.setValue(0)
layout.addWidget(self.progress) layout.addWidget(self.progress)
# Size info # Size info
self.size_label = QLabel("0 MB / 0 MB") self.size_label = QLabel("0 MB / 0 MB")
self.size_label.setStyleSheet("color: gray; font-size: 11px;") self.size_label.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(self.size_label) layout.addWidget(self.size_label)
# Cancel button # Cancel button
self.cancel_btn = QPushButton("Cancel") self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.clicked.connect(self._on_cancel) self.cancel_btn.clicked.connect(self._on_cancel)
layout.addWidget(self.cancel_btn) layout.addWidget(self.cancel_btn)
self.setLayout(layout) self.setLayout(layout)
def set_progress(self, current: int, total: int): def set_progress(self, current: int, total: int):
"""Update progress bar. """Update progress bar.
Args: Args:
current: Current bytes downloaded current: Current bytes downloaded
total: Total bytes to download total: Total bytes to download
@ -198,20 +208,20 @@ class DownloadingDialog(QDialog):
if total > 0: if total > 0:
percentage = int((current / total) * 100) percentage = int((current / total) * 100)
self.progress.setValue(percentage) self.progress.setValue(percentage)
# Format size display # Format size display
current_mb = current / (1024 * 1024) current_mb = current / (1024 * 1024)
total_mb = total / (1024 * 1024) total_mb = total / (1024 * 1024)
self.size_label.setText(f"{current_mb:.1f} MB / {total_mb:.1f} MB") self.size_label.setText(f"{current_mb:.1f} MB / {total_mb:.1f} MB")
def set_filename(self, filename: str): def set_filename(self, filename: str):
"""Set the filename being downloaded. """Set the filename being downloaded.
Args: Args:
filename: Name of file being downloaded filename: Name of file being downloaded
""" """
self.file_label.setText(f"Downloading: {filename}") self.file_label.setText(f"Downloading: {filename}")
def _on_cancel(self): def _on_cancel(self):
"""Handle cancel button click.""" """Handle cancel button click."""
self.cancel_download.emit() self.cancel_download.emit()
@ -220,18 +230,18 @@ class DownloadingDialog(QDialog):
class InstallDialog(QDialog): class InstallDialog(QDialog):
"""Dialog shown before installing update and restarting. """Dialog shown before installing update and restarting.
Displays: Displays:
- Installation confirmation message - Installation confirmation message
- Warning about unsaved changes - Warning about unsaved changes
- Buttons: Install Now, Cancel - Buttons: Install Now, Cancel
""" """
install_now = Signal() install_now = Signal()
def __init__(self, parent=None): def __init__(self, parent=None):
"""Initialize install dialog. """Initialize install dialog.
Args: Args:
parent: Parent widget parent: Parent widget
""" """
@ -239,18 +249,18 @@ class InstallDialog(QDialog):
self.setWindowTitle("Install Update") self.setWindowTitle("Install Update")
self.setModal(True) self.setModal(True)
self.setMinimumWidth(350) self.setMinimumWidth(350)
layout = QVBoxLayout() layout = QVBoxLayout()
# Header # Header
header = QLabel("Ready to Install") header = QLabel("Ready to Install")
header.setStyleSheet("font-weight: bold; font-size: 14px;") header.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(header) layout.addWidget(header)
# Message # Message
message = QLabel("The update is ready to install. The application will restart.") message = QLabel("The update is ready to install. The application will restart.")
layout.addWidget(message) layout.addWidget(message)
# Warning # Warning
warning = QLabel( warning = QLabel(
"⚠️ Please save any unsaved work before continuing.\n" "⚠️ Please save any unsaved work before continuing.\n"
@ -259,22 +269,22 @@ class InstallDialog(QDialog):
warning.setStyleSheet("background-color: #fff3cd; padding: 10px; border-radius: 4px;") warning.setStyleSheet("background-color: #fff3cd; padding: 10px; border-radius: 4px;")
warning.setWordWrap(True) warning.setWordWrap(True)
layout.addWidget(warning) layout.addWidget(warning)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.install_btn = QPushButton("Install Now") self.install_btn = QPushButton("Install Now")
self.install_btn.setStyleSheet("background-color: #28a745; color: white;") self.install_btn.setStyleSheet("background-color: #28a745; color: white;")
self.install_btn.clicked.connect(self._on_install) self.install_btn.clicked.connect(self._on_install)
button_layout.addWidget(self.install_btn) button_layout.addWidget(self.install_btn)
self.cancel_btn = QPushButton("Cancel") self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.clicked.connect(self.reject) self.cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_btn) button_layout.addWidget(self.cancel_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
self.setLayout(layout) self.setLayout(layout)
def _on_install(self): def _on_install(self):
"""Handle install now button click.""" """Handle install now button click."""
self.install_now.emit() self.install_now.emit()
@ -283,13 +293,13 @@ class InstallDialog(QDialog):
class NoUpdateDialog(QDialog): class NoUpdateDialog(QDialog):
"""Dialog shown when no updates are available. """Dialog shown when no updates are available.
Simple confirmation that the application is up to date. Simple confirmation that the application is up to date.
""" """
def __init__(self, parent=None): def __init__(self, parent=None):
"""Initialize no update dialog. """Initialize no update dialog.
Args: Args:
parent: Parent widget parent: Parent widget
""" """
@ -297,39 +307,39 @@ class NoUpdateDialog(QDialog):
self.setWindowTitle("No Updates Available") self.setWindowTitle("No Updates Available")
self.setModal(True) self.setModal(True)
self.setMinimumWidth(300) self.setMinimumWidth(300)
layout = QVBoxLayout() layout = QVBoxLayout()
# Message # Message
message = QLabel("✓ You're using the latest version") message = QLabel("✓ You're using the latest version")
message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;") message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;")
layout.addWidget(message) layout.addWidget(message)
info = QLabel("WebDrop Bridge is up to date.") info = QLabel("WebDrop Bridge is up to date.")
layout.addWidget(info) layout.addWidget(info)
# Close button # Close button
close_btn = QPushButton("OK") close_btn = QPushButton("OK")
close_btn.clicked.connect(self.accept) close_btn.clicked.connect(self.accept)
layout.addWidget(close_btn) layout.addWidget(close_btn)
self.setLayout(layout) self.setLayout(layout)
class ErrorDialog(QDialog): class ErrorDialog(QDialog):
"""Dialog shown when update check or installation fails. """Dialog shown when update check or installation fails.
Displays: Displays:
- Error message - Error message
- Buttons: Retry, Manual Download, Cancel - Buttons: Retry, Manual Download, Cancel
""" """
retry = Signal() retry = Signal()
manual_download = Signal() manual_download = Signal()
def __init__(self, error_message: str, parent=None): def __init__(self, error_message: str, parent=None):
"""Initialize error dialog. """Initialize error dialog.
Args: Args:
error_message: Description of the error error_message: Description of the error
parent: Parent widget parent: Parent widget
@ -338,50 +348,52 @@ class ErrorDialog(QDialog):
self.setWindowTitle("Update Failed") self.setWindowTitle("Update Failed")
self.setModal(True) self.setModal(True)
self.setMinimumWidth(350) self.setMinimumWidth(350)
layout = QVBoxLayout() layout = QVBoxLayout()
# Header # Header
header = QLabel("⚠️ Update Failed") header = QLabel("⚠️ Update Failed")
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;") header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;")
layout.addWidget(header) layout.addWidget(header)
# Error message # Error message
self.error_text = QTextEdit() self.error_text = QTextEdit()
self.error_text.setText(error_message) self.error_text.setText(error_message)
self.error_text.setReadOnly(True) self.error_text.setReadOnly(True)
self.error_text.setMaximumHeight(100) self.error_text.setMaximumHeight(100)
layout.addWidget(self.error_text) layout.addWidget(self.error_text)
# Info message # 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.setWordWrap(True)
info.setStyleSheet("color: gray; font-size: 11px;") info.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(info) layout.addWidget(info)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
self.retry_btn = QPushButton("Retry") self.retry_btn = QPushButton("Retry")
self.retry_btn.clicked.connect(self._on_retry) self.retry_btn.clicked.connect(self._on_retry)
button_layout.addWidget(self.retry_btn) button_layout.addWidget(self.retry_btn)
self.manual_btn = QPushButton("Download Manually") self.manual_btn = QPushButton("Download Manually")
self.manual_btn.clicked.connect(self._on_manual) self.manual_btn.clicked.connect(self._on_manual)
button_layout.addWidget(self.manual_btn) button_layout.addWidget(self.manual_btn)
self.cancel_btn = QPushButton("Cancel") self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.clicked.connect(self.reject) self.cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_btn) button_layout.addWidget(self.cancel_btn)
layout.addLayout(button_layout) layout.addLayout(button_layout)
self.setLayout(layout) self.setLayout(layout)
def _on_retry(self): def _on_retry(self):
"""Handle retry button click.""" """Handle retry button click."""
self.retry.emit() self.retry.emit()
self.accept() self.accept()
def _on_manual(self): def _on_manual(self):
"""Handle manual download button click.""" """Handle manual download button click."""
self.manual_download.emit() self.manual_download.emit()

View file

@ -76,17 +76,24 @@ class TestUpdateAvailableDialog:
def test_signals_emitted_update_now(self, qapp, qtbot): def test_signals_emitted_update_now(self, qapp, qtbot):
"""Test update now signal is emitted.""" """Test update now signal is emitted."""
dialog = UpdateAvailableDialog("0.0.2", "Changes") dialog = UpdateAvailableDialog("0.0.2", "Changes")
with qtbot.waitSignal(dialog.update_now): with qtbot.waitSignal(dialog.update_now):
dialog.update_now_btn.click() dialog.update_now_btn.click()
def test_signals_emitted_update_later(self, qapp, qtbot): def test_signals_emitted_update_later(self, qapp, qtbot):
"""Test update later signal is emitted.""" """Test update later signal is emitted."""
dialog = UpdateAvailableDialog("0.0.2", "Changes") dialog = UpdateAvailableDialog("0.0.2", "Changes")
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."""
@ -127,7 +134,7 @@ class TestDownloadingDialog:
def test_cancel_signal(self, qapp, qtbot): def test_cancel_signal(self, qapp, qtbot):
"""Test cancel signal is emitted.""" """Test cancel signal is emitted."""
dialog = DownloadingDialog() dialog = DownloadingDialog()
with qtbot.waitSignal(dialog.cancel_download): with qtbot.waitSignal(dialog.cancel_download):
dialog.cancel_btn.click() dialog.cancel_btn.click()
@ -149,7 +156,7 @@ class TestInstallDialog:
def test_install_signal(self, qapp, qtbot): def test_install_signal(self, qapp, qtbot):
"""Test install signal is emitted.""" """Test install signal is emitted."""
dialog = InstallDialog() dialog = InstallDialog()
with qtbot.waitSignal(dialog.install_now): with qtbot.waitSignal(dialog.install_now):
dialog.install_btn.click() dialog.install_btn.click()
@ -204,13 +211,13 @@ class TestErrorDialog:
def test_retry_signal(self, qapp, qtbot): def test_retry_signal(self, qapp, qtbot):
"""Test retry signal is emitted.""" """Test retry signal is emitted."""
dialog = ErrorDialog("Error") dialog = ErrorDialog("Error")
with qtbot.waitSignal(dialog.retry): with qtbot.waitSignal(dialog.retry):
dialog.retry_btn.click() dialog.retry_btn.click()
def test_manual_download_signal(self, qapp, qtbot): def test_manual_download_signal(self, qapp, qtbot):
"""Test manual download signal is emitted.""" """Test manual download signal is emitted."""
dialog = ErrorDialog("Error") dialog = ErrorDialog("Error")
with qtbot.waitSignal(dialog.manual_download): with qtbot.waitSignal(dialog.manual_download):
dialog.manual_btn.click() dialog.manual_btn.click()

View file

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