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

@ -27,13 +27,10 @@ from typing import Optional
if sys.platform == "win32": if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8" os.environ["PYTHONIOENCODING"] = "utf-8"
import io import io
# Reconfigure stdout/stderr for UTF-8 output # Reconfigure stdout/stderr for UTF-8 output
sys.stdout = io.TextIOWrapper( sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stdout.buffer, encoding="utf-8", errors="replace" sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
)
sys.stderr = io.TextIOWrapper(
sys.stderr.buffer, encoding="utf-8", errors="replace"
)
import subprocess import subprocess
import shutil import shutil
@ -120,12 +117,7 @@ class WindowsBuilder:
env = os.environ.copy() env = os.environ.copy()
env["WEBDROP_ENV_FILE"] = str(self.env_file) env["WEBDROP_ENV_FILE"] = str(self.env_file)
result = subprocess.run( result = subprocess.run(cmd, cwd=str(self.project_root), text=True, env=env)
cmd,
cwd=str(self.project_root),
text=True,
env=env
)
if result.returncode != 0: if result.returncode != 0:
print("❌ PyInstaller build failed") print("❌ PyInstaller build failed")
@ -141,7 +133,9 @@ class WindowsBuilder:
print(f"📦 Output: {exe_path}") print(f"📦 Output: {exe_path}")
# Calculate total dist size # Calculate total dist size
total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file()) total_size = sum(
f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file()
)
if total_size > 0: if total_size > 0:
print(f" Total size: {total_size / 1024 / 1024:.1f} MB") print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
@ -263,12 +257,15 @@ class WindowsBuilder:
str(heat_exe), str(heat_exe),
"dir", "dir",
str(dist_folder), str(dist_folder),
"-cg", "AppFiles", "-cg",
"-dr", "INSTALLFOLDER", "AppFiles",
"-dr",
"INSTALLFOLDER",
"-sfrag", "-sfrag",
"-srd", "-srd",
"-gg", "-gg",
"-o", str(harvest_file), "-o",
str(harvest_file),
] ]
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
@ -283,10 +280,7 @@ class WindowsBuilder:
if harvest_file.exists(): if harvest_file.exists():
content = harvest_file.read_text() content = harvest_file.read_text()
# Add Win64="yes" to all Component tags # Add Win64="yes" to all Component tags
content = content.replace( content = content.replace("<Component ", '<Component Win64="yes" ')
'<Component ',
'<Component Win64="yes" '
)
harvest_file.write_text(content) harvest_file.write_text(content)
print(f" ✓ Marked components as 64-bit") print(f" ✓ Marked components as 64-bit")
@ -298,12 +292,15 @@ class WindowsBuilder:
# Run candle compiler - make sure to use correct source directory # Run candle compiler - make sure to use correct source directory
candle_cmd = [ candle_cmd = [
str(candle_exe), str(candle_exe),
"-ext", "WixUIExtension", "-ext",
"-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"),
] ]
@ -319,10 +316,14 @@ class WindowsBuilder:
# Link MSI - include both obj files if harvest was successful # Link MSI - include both obj files if harvest was successful
light_cmd = [ light_cmd = [
str(light_exe), str(light_exe),
"-ext", "WixUIExtension", "-ext",
"-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),
] ]
@ -330,7 +331,9 @@ class WindowsBuilder:
light_cmd.append(str(wix_files_obj)) light_cmd.append(str(wix_files_obj))
print(f" Linking MSI installer...") print(f" Linking MSI installer...")
result = subprocess.run(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) result = subprocess.run(
light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
if result.returncode != 0: if result.returncode != 0:
print("❌ MSI linking failed") print("❌ MSI linking failed")
if result.stdout: if result.stdout:
@ -355,7 +358,7 @@ class WindowsBuilder:
Creates per-machine installation (Program Files). Creates per-machine installation (Program Files).
Installation requires admin rights, but the app does not. Installation requires admin rights, but the app does not.
""" """
wix_content = f'''<?xml version="1.0" encoding="UTF-8"?> wix_content = f"""<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi" <Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui" xmlns:ui="http://schemas.microsoft.com/wix/2010/ui"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension"> xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
@ -443,14 +446,21 @@ class WindowsBuilder:
</DirectoryRef> </DirectoryRef>
</Product> </Product>
</Wix> </Wix>
''' """
wix_file = self.build_dir / "WebDropBridge.wxs" wix_file = self.build_dir / "WebDropBridge.wxs"
wix_file.write_text(wix_content) wix_file.write_text(wix_content)
print(f" Created WiX source: {wix_file}") print(f" Created WiX source: {wix_file}")
return True return True
def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str: def _generate_file_elements(
self,
folder: Path,
parent_dir_ref: str,
parent_rel_path: str,
indent: int = 8,
file_counter: Optional[dict] = None,
) -> str:
"""Generate WiX File elements for all files in a folder. """Generate WiX File elements for all files in a folder.
Args: Args:
@ -475,6 +485,7 @@ class WindowsBuilder:
if item.is_file(): if item.is_file():
# Create unique File element ID using hash of full path # Create unique File element ID using hash of full path
import hashlib import hashlib
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8] path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
file_id = f"File_{path_hash}" file_id = f"File_{path_hash}"
file_path = str(item) file_path = str(item)
@ -482,10 +493,7 @@ class WindowsBuilder:
elif item.is_dir() and item.name != "__pycache__": elif item.is_dir() and item.name != "__pycache__":
# Recursively add files from subdirectories # Recursively add files from subdirectories
sub_elements = self._generate_file_elements( sub_elements = self._generate_file_elements(
item, parent_dir_ref, item, parent_dir_ref, f"{parent_rel_path}/{item.name}", indent, file_counter
f"{parent_rel_path}/{item.name}",
indent,
file_counter
) )
if sub_elements: if sub_elements:
elements.append(sub_elements) elements.append(sub_elements)
@ -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",

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

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

View file

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

View file

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