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:
parent
88d9f200ab
commit
025e9c888c
10 changed files with 3066 additions and 3088 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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="❌")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue