Refactor Windows build script for improved readability and consistency

- Cleaned up whitespace and formatting in build_windows.py for better readability.
- Consolidated environment variable setup for stdout and stderr.
- Streamlined subprocess command calls by removing unnecessary line breaks.
- Enhanced error handling and logging for better debugging.
- Updated comments for clarity and consistency.

Update updater.py to improve checksum verification logic

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

Enhance update manager UI with download progress dialog

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

Refactor update manager UI tests for clarity and maintainability

- Removed tests related to the skip version functionality.
- Updated test cases to reflect changes in the update manager UI.
- Ensured all tests are aligned with the new dialog structure and signal emissions.
This commit is contained in:
claudi 2026-02-25 14:38:33 +01:00
parent 88d9f200ab
commit 025e9c888c
10 changed files with 3066 additions and 3088 deletions

File diff suppressed because one or more lines are too long

View file

@ -2,7 +2,7 @@
<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"> xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.6.1" <Product Id="*" Name="WebDrop Bridge" Language="1033" Version="0.6.2"
Manufacturer="HIM-Tools" Manufacturer="HIM-Tools"
UpgradeCode="12345678-1234-1234-1234-123456789012"> UpgradeCode="12345678-1234-1234-1234-123456789012">

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,13 +27,10 @@ from typing import Optional
if sys.platform == "win32": if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8" os.environ["PYTHONIOENCODING"] = "utf-8"
import io import io
# Reconfigure stdout/stderr for UTF-8 output # Reconfigure stdout/stderr for UTF-8 output
sys.stdout = io.TextIOWrapper( sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stdout.buffer, encoding="utf-8", errors="replace" sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
)
sys.stderr = io.TextIOWrapper(
sys.stderr.buffer, encoding="utf-8", errors="replace"
)
import subprocess import subprocess
import shutil import shutil
@ -50,7 +47,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.
@ -61,20 +58,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}")
@ -115,17 +112,12 @@ 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( result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
cmd,
cwd=str(self.project_root),
text=True,
env=env
)
if result.returncode != 0: if result.returncode != 0:
print("❌ PyInstaller build failed") print("❌ PyInstaller build failed")
@ -139,9 +131,11 @@ 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(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file()) total_size = sum(
f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file()
)
if total_size > 0: if total_size > 0:
print(f" Total size: {total_size / 1024 / 1024:.1f} MB") print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
@ -174,15 +168,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:
@ -190,30 +184,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")
@ -255,22 +249,25 @@ 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", "AppFiles", "-cg",
"-dr", "INSTALLFOLDER", "AppFiles",
"-dr",
"INSTALLFOLDER",
"-sfrag", "-sfrag",
"-srd", "-srd",
"-gg", "-gg",
"-o", str(harvest_file), "-o",
str(harvest_file),
] ]
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0: if result.returncode != 0:
print("⚠️ Heat harvest warnings (may be non-critical)") print("⚠️ Heat harvest warnings (may be non-critical)")
@ -278,15 +275,12 @@ 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( content = content.replace("<Component ", '<Component Win64="yes" ')
'<Component ',
'<Component Win64="yes" '
)
harvest_file.write_text(content) harvest_file.write_text(content)
print(f" ✓ Marked components as 64-bit") print(f" ✓ Marked components as 64-bit")
@ -298,15 +292,18 @@ class WindowsBuilder:
# Run candle compiler - make sure to use correct source directory # Run candle compiler - make sure to use correct source directory
candle_cmd = [ candle_cmd = [
str(candle_exe), str(candle_exe),
"-ext", "WixUIExtension", "-ext",
"-ext", "WixUtilExtension", "WixUIExtension",
"-ext",
"WixUtilExtension",
f"-dDistDir={self.dist_dir}", f"-dDistDir={self.dist_dir}",
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
"-o", str(self.build_dir) + "\\", "-o",
str(self.build_dir) + "\\",
str(self.build_dir / "WebDropBridge.wxs"), str(self.build_dir / "WebDropBridge.wxs"),
] ]
if harvest_file.exists(): if harvest_file.exists():
candle_cmd.append(str(harvest_file)) candle_cmd.append(str(harvest_file))
@ -319,18 +316,24 @@ class WindowsBuilder:
# Link MSI - include both obj files if harvest was successful # Link MSI - include both obj files if harvest was successful
light_cmd = [ light_cmd = [
str(light_exe), str(light_exe),
"-ext", "WixUIExtension", "-ext",
"-ext", "WixUtilExtension", "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(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) result = subprocess.run(
light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
if result.returncode != 0: if result.returncode != 0:
print("❌ MSI linking failed") print("❌ MSI linking failed")
if result.stdout: if result.stdout:
@ -351,11 +354,11 @@ 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"> xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
@ -443,38 +446,46 @@ class WindowsBuilder:
</DirectoryRef> </DirectoryRef>
</Product> </Product>
</Wix> </Wix>
''' """
wix_file = self.build_dir / "WebDropBridge.wxs" wix_file = self.build_dir / "WebDropBridge.wxs"
wix_file.write_text(wix_content) wix_file.write_text(wix_content)
print(f" Created WiX source: {wix_file}") print(f" Created WiX source: {wix_file}")
return True return True
def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str: def _generate_file_elements(
self,
folder: Path,
parent_dir_ref: str,
parent_rel_path: str,
indent: int = 8,
file_counter: Optional[dict] = None,
) -> str:
"""Generate WiX File elements for all files in a folder. """Generate WiX File elements for all files in a folder.
Args: Args:
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)
@ -482,24 +493,21 @@ class WindowsBuilder:
elif item.is_dir() and item.name != "__pycache__": elif item.is_dir() and item.name != "__pycache__":
# Recursively add files from subdirectories # Recursively add files from subdirectories
sub_elements = self._generate_file_elements( sub_elements = self._generate_file_elements(
item, parent_dir_ref, item, parent_dir_ref, f"{parent_rel_path}/{item.name}", indent, file_counter
f"{parent_rel_path}/{item.name}",
indent,
file_counter
) )
if sub_elements: if sub_elements:
elements.append(sub_elements) elements.append(sub_elements)
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
""" """
@ -544,10 +552,7 @@ class WindowsBuilder:
str(exe_path), str(exe_path),
] ]
result = subprocess.run( result = subprocess.run(cmd, text=True)
cmd,
text=True
)
if result.returncode != 0: if result.returncode != 0:
print("❌ Code signing failed") print("❌ Code signing failed")
return False return False
@ -594,9 +599,7 @@ class WindowsBuilder:
def main() -> int: def main() -> int:
"""Build Windows MSI installer.""" """Build Windows MSI installer."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(description="Build WebDrop Bridge Windows installer")
description="Build WebDrop Bridge Windows installer"
)
parser.add_argument( parser.add_argument(
"--msi", "--msi",
action="store_true", action="store_true",
@ -613,9 +616,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

@ -330,10 +330,12 @@ class UpdateManager:
Returns: Returns:
True if checksum matches, False otherwise True if checksum matches, False otherwise
""" """
# Find .sha256 file in release assets # Find .sha256 file matching the installer name (e.g. Setup.msi.sha256)
# Fall back to any .sha256 only if no specific match exists
installer_name = file_path.name
checksum_asset = None checksum_asset = None
for asset in release.assets: for asset in release.assets:
if asset["name"].endswith(".sha256"): if asset["name"] == f"{installer_name}.sha256":
checksum_asset = asset checksum_asset = asset
break break

View file

@ -270,6 +270,7 @@ class MainWindow(QMainWindow):
self._background_threads = [] # Keep references to background threads self._background_threads = [] # Keep references to background threads
self._background_workers = {} # Keep references to background workers self._background_workers = {} # Keep references to background workers
self.checking_dialog = None # Track the checking dialog self.checking_dialog = None # Track the checking dialog
self.downloading_dialog = None # Track the download dialog
self._is_manual_check = False # Track if this is a manual check (for UI feedback) self._is_manual_check = False # Track if this is a manual check (for UI feedback)
# Set window properties # Set window properties
@ -1402,6 +1403,12 @@ class MainWindow(QMainWindow):
Args: Args:
release: Release object with update info release: Release object with update info
""" """
# Close checking dialog if open (manual check case)
if hasattr(self, "checking_dialog") and self.checking_dialog:
self.checking_dialog.close()
self.checking_dialog = None
self._is_manual_check = False
# Update status to show update available # Update status to show update available
self.set_update_status(f"Update available: v{release.version}", emoji="") self.set_update_status(f"Update available: v{release.version}", emoji="")
@ -1413,7 +1420,6 @@ class MainWindow(QMainWindow):
# Connect dialog signals # Connect dialog signals
dialog.update_now.connect(lambda: self._on_user_update_now(release)) dialog.update_now.connect(lambda: self._on_user_update_now(release))
dialog.update_later.connect(lambda: self._on_user_update_later()) dialog.update_later.connect(lambda: self._on_user_update_later())
dialog.skip_version.connect(lambda: self._on_user_skip_version(release.version))
# Show dialog (modal) # Show dialog (modal)
dialog.exec() dialog.exec()
@ -1434,21 +1440,6 @@ class MainWindow(QMainWindow):
logger.info("User deferred update") logger.info("User deferred update")
self.set_update_status("Update deferred", emoji="") self.set_update_status("Update deferred", emoji="")
def _on_user_skip_version(self, version: str) -> None:
"""Handle user clicking 'Skip Version' button.
Args:
version: Version to skip
"""
logger.info(f"User skipped version {version}")
# Store skipped version in preferences
skipped_file = Path.home() / ".webdrop-bridge" / "skipped_version.txt"
skipped_file.parent.mkdir(parents=True, exist_ok=True)
skipped_file.write_text(version)
self.set_update_status(f"Skipped v{version}", emoji="")
def _start_update_download(self, release) -> None: def _start_update_download(self, release) -> None:
"""Start downloading the update in background thread. """Start downloading the update in background thread.
@ -1458,6 +1449,12 @@ class MainWindow(QMainWindow):
logger.info(f"Starting download for v{release.version}") logger.info(f"Starting download for v{release.version}")
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️") self.set_update_status(f"Downloading v{release.version}", emoji="⬇️")
# Show download progress dialog
from webdrop_bridge.ui.update_manager_ui import DownloadingDialog
self.downloading_dialog = DownloadingDialog(self)
self.downloading_dialog.show()
# Run download in background thread to avoid blocking UI # Run download in background thread to avoid blocking UI
self._perform_update_async(release) self._perform_update_async(release)
@ -1558,6 +1555,10 @@ class MainWindow(QMainWindow):
""" """
from webdrop_bridge.ui.update_manager_ui import InstallDialog from webdrop_bridge.ui.update_manager_ui import InstallDialog
if hasattr(self, "downloading_dialog") and self.downloading_dialog:
self.downloading_dialog.close()
self.downloading_dialog = None
logger.info(f"Download complete: {installer_path}") logger.info(f"Download complete: {installer_path}")
self.set_update_status("Ready to install", emoji="") self.set_update_status("Ready to install", emoji="")
@ -1572,6 +1573,10 @@ class MainWindow(QMainWindow):
Args: Args:
error: Error message error: Error message
""" """
if hasattr(self, "downloading_dialog") and self.downloading_dialog:
self.downloading_dialog.close()
self.downloading_dialog = None
logger.error(f"Download failed: {error}") logger.error(f"Download failed: {error}")
self.set_update_status(error, emoji="") self.set_update_status(error, emoji="")

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,44 +45,43 @@ 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
@ -93,72 +92,63 @@ 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
""" """
@ -167,40 +157,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
@ -208,20 +198,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()
@ -230,18 +220,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
""" """
@ -249,18 +239,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"
@ -269,22 +259,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()
@ -293,13 +283,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
""" """
@ -307,39 +297,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
@ -348,52 +338,50 @@ 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( info = QLabel("Please try again or visit the website to download the update manually.")
"Please try again or visit the website to download the update manually."
)
info.setWordWrap(True) info.setWordWrap(True)
info.setStyleSheet("color: gray; font-size: 11px;") info.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(info) layout.addWidget(info)
# 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,24 +76,17 @@ 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."""
@ -134,7 +127,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()
@ -156,7 +149,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()
@ -211,13 +204,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,9 +166,7 @@ class TestCheckForUpdates:
@pytest.mark.asyncio @pytest.mark.asyncio
@patch.object(UpdateManager, "_fetch_release") @patch.object(UpdateManager, "_fetch_release")
async def test_check_for_updates_no_update( async def test_check_for_updates_no_update(self, mock_fetch, update_manager):
self, mock_fetch, update_manager
):
"""Test no update available.""" """Test no update available."""
mock_fetch.return_value = { mock_fetch.return_value = {
"tag_name": "v0.0.1", "tag_name": "v0.0.1",
@ -184,9 +182,7 @@ class TestCheckForUpdates:
@pytest.mark.asyncio @pytest.mark.asyncio
@patch.object(UpdateManager, "_fetch_release") @patch.object(UpdateManager, "_fetch_release")
async def test_check_for_updates_uses_cache( async def test_check_for_updates_uses_cache(self, mock_fetch, update_manager, sample_release):
self, mock_fetch, update_manager, sample_release
):
"""Test cache is used on subsequent calls.""" """Test cache is used on subsequent calls."""
mock_fetch.return_value = sample_release mock_fetch.return_value = sample_release
@ -207,9 +203,7 @@ class TestDownloading:
"""Test update downloading.""" """Test update downloading."""
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_update_success( async def test_download_update_success(self, update_manager, tmp_path):
self, update_manager, tmp_path
):
"""Test successful update download.""" """Test successful update download."""
# Create release with .msi asset # Create release with .msi asset
release_data = { release_data = {
@ -237,9 +231,7 @@ class TestDownloading:
@pytest.mark.asyncio @pytest.mark.asyncio
@patch.object(UpdateManager, "_download_file") @patch.object(UpdateManager, "_download_file")
async def test_download_update_no_installer( async def test_download_update_no_installer(self, mock_download, update_manager):
self, mock_download, update_manager
):
"""Test download fails when no installer in release.""" """Test download fails when no installer in release."""
release_data = { release_data = {
"tag_name": "v0.0.2", "tag_name": "v0.0.2",
@ -270,8 +262,8 @@ class TestChecksumVerification:
self, mock_download_checksum, update_manager, sample_release, tmp_path self, mock_download_checksum, update_manager, sample_release, tmp_path
): ):
"""Test successful checksum verification.""" """Test successful checksum verification."""
# Create test file # File must match the asset name so the .sha256 lookup succeeds
test_file = tmp_path / "test.exe" test_file = tmp_path / "WebDropBridge.exe"
test_file.write_bytes(b"test content") test_file.write_bytes(b"test content")
# Calculate actual checksum # Calculate actual checksum
@ -291,7 +283,8 @@ class TestChecksumVerification:
self, mock_download_checksum, update_manager, sample_release, tmp_path self, mock_download_checksum, update_manager, sample_release, tmp_path
): ):
"""Test checksum verification fails on mismatch.""" """Test checksum verification fails on mismatch."""
test_file = tmp_path / "test.exe" # File must match the asset name so the .sha256 lookup succeeds
test_file = tmp_path / "WebDropBridge.exe"
test_file.write_bytes(b"test content") test_file.write_bytes(b"test content")
# Return wrong checksum # Return wrong checksum
@ -303,9 +296,7 @@ class TestChecksumVerification:
assert result is False assert result is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_verify_checksum_no_checksum_file( async def test_verify_checksum_no_checksum_file(self, update_manager, tmp_path):
self, update_manager, tmp_path
):
"""Test verification skipped when no checksum file in release.""" """Test verification skipped when no checksum file in release."""
test_file = tmp_path / "test.exe" test_file = tmp_path / "test.exe"
test_file.write_bytes(b"test content") test_file.write_bytes(b"test content")
@ -336,9 +327,7 @@ class TestInstallation:
@patch("subprocess.Popen") @patch("subprocess.Popen")
@patch("platform.system") @patch("platform.system")
def test_install_update_windows( def test_install_update_windows(self, mock_platform, mock_popen, update_manager, tmp_path):
self, mock_platform, mock_popen, update_manager, tmp_path
):
"""Test installation on Windows.""" """Test installation on Windows."""
mock_platform.return_value = "Windows" mock_platform.return_value = "Windows"
installer = tmp_path / "WebDropBridge.msi" installer = tmp_path / "WebDropBridge.msi"
@ -351,9 +340,7 @@ class TestInstallation:
@patch("subprocess.Popen") @patch("subprocess.Popen")
@patch("platform.system") @patch("platform.system")
def test_install_update_macos( def test_install_update_macos(self, mock_platform, mock_popen, update_manager, tmp_path):
self, mock_platform, mock_popen, update_manager, tmp_path
):
"""Test installation on macOS.""" """Test installation on macOS."""
mock_platform.return_value = "Darwin" mock_platform.return_value = "Darwin"
installer = tmp_path / "WebDropBridge.dmg" installer = tmp_path / "WebDropBridge.dmg"