Compare commits

..

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

17 changed files with 3640 additions and 3729 deletions

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@ Requirements:
Usage:
python build_windows.py [--msi] [--code-sign] [--env-file PATH]
Options:
--msi Create MSI installer (requires WiX Toolset)
--code-sign Sign executable (requires certificate)
@ -27,10 +27,13 @@ from typing import Optional
if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
import io
# Reconfigure stdout/stderr for UTF-8 output
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
sys.stdout = io.TextIOWrapper(
sys.stdout.buffer, encoding="utf-8", errors="replace"
)
sys.stderr = io.TextIOWrapper(
sys.stderr.buffer, encoding="utf-8", errors="replace"
)
import subprocess
import shutil
@ -47,7 +50,7 @@ class WindowsBuilder:
def __init__(self, env_file: Path | None = None):
"""Initialize builder paths.
Args:
env_file: Path to .env file to bundle. If None, uses project root .env.
If that doesn't exist, raises error.
@ -58,20 +61,20 @@ class WindowsBuilder:
self.temp_dir = self.build_dir / "temp" / "windows"
self.spec_file = self.build_dir / "webdrop_bridge.spec"
self.version = get_current_version()
# Validate and set env file
if env_file is None:
env_file = self.project_root / ".env"
else:
env_file = Path(env_file).resolve()
if not env_file.exists():
raise FileNotFoundError(
f"Configuration file not found: {env_file}\n"
f"Please provide a .env file using --env-file parameter\n"
f"or ensure .env exists in project root"
)
self.env_file = env_file
print(f"📋 Using configuration: {self.env_file}")
@ -112,12 +115,17 @@ class WindowsBuilder:
]
print(f" Command: {' '.join(cmd)}")
# Set environment variable for spec file to use
env = os.environ.copy()
env["WEBDROP_ENV_FILE"] = str(self.env_file)
result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
result = subprocess.run(
cmd,
cwd=str(self.project_root),
text=True,
env=env
)
if result.returncode != 0:
print("❌ PyInstaller build failed")
@ -131,11 +139,9 @@ class WindowsBuilder:
print("✅ Executable built successfully")
print(f"📦 Output: {exe_path}")
# Calculate total dist size
total_size = sum(
f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file()
)
total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file())
if total_size > 0:
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
@ -168,15 +174,15 @@ class WindowsBuilder:
def set_exe_version(self, exe_path: Path) -> None:
"""Set executable file version for Windows.
This is important for MSI updates: Windows Installer compares file versions
to determine if files should be updated during a major upgrade.
Args:
exe_path: Path to the executable file
"""
print("\n🏷️ Setting executable version information...")
try:
import pefile
except ImportError:
@ -184,30 +190,30 @@ class WindowsBuilder:
print(" Note: Install with: pip install pefile")
print(" EXE version info will be blank (MSI updates may not work correctly)")
return
try:
pe = pefile.PE(str(exe_path))
# Parse version into 4-part format (Major, Minor, Build, Revision)
version_parts = self.version.split(".")
while len(version_parts) < 4:
version_parts.append("0")
file_version = tuple(int(v) for v in version_parts[:4])
# Set version resource if it exists
if hasattr(pe, "VS_FIXEDFILEINFO"):
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].ProductVersionMS = (file_version[0] << 16) | file_version[1]
pe.VS_FIXEDFILEINFO[0].ProductVersionLS = (file_version[2] << 16) | file_version[3]
# Write modified PE back to file
pe.write(filename=str(exe_path))
print(f"✅ Version set to {self.version}")
else:
print("⚠️ No version resource found in EXE")
except Exception as e:
print(f"⚠️ Could not set EXE version: {e}")
print(" MSI updates may not work correctly without file version info")
@ -249,25 +255,22 @@ class WindowsBuilder:
if not dist_folder.exists():
print(f"❌ Distribution folder not found: {dist_folder}")
return False
harvest_file = self.build_dir / "WebDropBridge_Files.wxs"
# Use Heat to harvest all files
heat_cmd = [
str(heat_exe),
"dir",
str(dist_folder),
"-cg",
"AppFiles",
"-dr",
"INSTALLFOLDER",
"-cg", "AppFiles",
"-dr", "INSTALLFOLDER",
"-sfrag",
"-srd",
"-gg",
"-o",
str(harvest_file),
"-o", str(harvest_file),
]
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
print("⚠️ Heat harvest warnings (may be non-critical)")
@ -275,12 +278,15 @@ class WindowsBuilder:
print(result.stderr[:200]) # Show first 200 chars of errors
else:
print(f" ✓ Harvested files")
# Post-process harvested file to mark components as 64-bit
if harvest_file.exists():
content = harvest_file.read_text()
# Add Win64="yes" to all Component tags
content = content.replace("<Component ", '<Component Win64="yes" ')
content = content.replace(
'<Component ',
'<Component Win64="yes" '
)
harvest_file.write_text(content)
print(f" ✓ Marked components as 64-bit")
@ -292,18 +298,14 @@ class WindowsBuilder:
# Run candle compiler - make sure to use correct source directory
candle_cmd = [
str(candle_exe),
"-ext",
"WixUIExtension",
"-ext",
"WixUtilExtension",
"-ext", "WixUIExtension",
f"-dDistDir={self.dist_dir}",
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
"-o",
str(self.build_dir) + "\\",
"-o", str(self.build_dir) + "\\",
str(self.build_dir / "WebDropBridge.wxs"),
]
if harvest_file.exists():
candle_cmd.append(str(harvest_file))
@ -316,24 +318,17 @@ class WindowsBuilder:
# Link MSI - include both obj files if harvest was successful
light_cmd = [
str(light_exe),
"-ext",
"WixUIExtension",
"-ext",
"WixUtilExtension",
"-b",
str(self.dist_dir / "WebDropBridge"), # Base path for source files
"-o",
str(msi_output),
"-ext", "WixUIExtension",
"-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
"-o", str(msi_output),
str(wix_obj),
]
if wix_files_obj.exists():
light_cmd.append(str(wix_files_obj))
print(f" Linking MSI installer...")
result = subprocess.run(
light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
result = subprocess.run(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0:
print("❌ MSI linking failed")
if result.stdout:
@ -354,14 +349,13 @@ class WindowsBuilder:
def _create_wix_source(self) -> bool:
"""Create WiX source file for MSI generation.
Creates per-machine installation (Program Files).
Installation requires admin rights, but the app does not.
"""
wix_content = f"""<?xml version="1.0" encoding="UTF-8"?>
wix_content = f'''<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
Manufacturer="HIM-Tools"
UpgradeCode="12345678-1234-1234-1234-123456789012">
@ -384,13 +378,6 @@ class WindowsBuilder:
<UIRef Id="WixUI_InstallDir" />
<UIRef Id="WixUI_ErrorProgressText" />
<!-- Close running application before installation -->
<util:CloseApplication
Target="WebDropBridge.exe"
CloseMessage="yes"
RebootPrompt="no"
ElevatedCloseMessage="no" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<ComponentGroupRef Id="AppFiles" />
<ComponentRef Id="ProgramMenuShortcut" />
@ -446,46 +433,38 @@ class WindowsBuilder:
</DirectoryRef>
</Product>
</Wix>
"""
'''
wix_file = self.build_dir / "WebDropBridge.wxs"
wix_file.write_text(wix_content)
print(f" Created WiX source: {wix_file}")
return True
def _generate_file_elements(
self,
folder: Path,
parent_dir_ref: str,
parent_rel_path: str,
indent: int = 8,
file_counter: Optional[dict] = None,
) -> str:
def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str:
"""Generate WiX File elements for all files in a folder.
Args:
folder: Root folder to scan
parent_dir_ref: Parent WiX DirectoryRef ID
parent_rel_path: Relative path for component structure
indent: Indentation level
file_counter: Dictionary to track file IDs for uniqueness
Returns:
WiX XML string with all File elements
"""
if file_counter is None:
file_counter = {}
elements = []
indent_str = " " * indent
try:
# Get all files in current folder
for item in sorted(folder.iterdir()):
if item.is_file():
# Create unique File element ID using hash of full path
import hashlib
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
file_id = f"File_{path_hash}"
file_path = str(item)
@ -493,21 +472,24 @@ class WindowsBuilder:
elif item.is_dir() and item.name != "__pycache__":
# Recursively add files from subdirectories
sub_elements = self._generate_file_elements(
item, parent_dir_ref, f"{parent_rel_path}/{item.name}", indent, file_counter
item, parent_dir_ref,
f"{parent_rel_path}/{item.name}",
indent,
file_counter
)
if sub_elements:
elements.append(sub_elements)
except PermissionError:
print(f" ⚠️ Permission denied accessing {folder}")
return "\n".join(elements)
def _sanitize_id(self, filename: str) -> str:
"""Sanitize filename to be a valid WiX identifier.
Args:
filename: Filename to sanitize
Returns:
Sanitized identifier
"""
@ -552,7 +534,10 @@ class WindowsBuilder:
str(exe_path),
]
result = subprocess.run(cmd, text=True)
result = subprocess.run(
cmd,
text=True
)
if result.returncode != 0:
print("❌ Code signing failed")
return False
@ -599,7 +584,9 @@ class WindowsBuilder:
def main() -> int:
"""Build Windows MSI installer."""
parser = argparse.ArgumentParser(description="Build WebDrop Bridge Windows installer")
parser = argparse.ArgumentParser(
description="Build WebDrop Bridge Windows installer"
)
parser.add_argument(
"--msi",
action="store_true",
@ -616,9 +603,9 @@ def main() -> int:
default=None,
help="Path to .env file to bundle (default: project root .env)",
)
args = parser.parse_args()
print("🔄 Syncing version...")
do_sync_version()

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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