- Changed installation scope from "perMachine" to "perUser" in the Windows installer configuration. - Updated installation directory from "ProgramFiles64Folder" to "LocalAppDataFolder" for user-specific installations. - Enhanced the configuration saving method to create parent directories if they don't exist. - Improved the main window script loading logic to support multiple installation scenarios (development, PyInstaller, MSI). - Added detailed logging for script loading failures and success messages. - Implemented a new method to reconfigure logging settings at runtime, allowing dynamic updates from the settings dialog. - Enhanced the settings dialog to handle configuration saving, including log level changes and error handling.
571 lines
20 KiB
Python
571 lines
20 KiB
Python
"""Build Windows installer (MSI) using PyInstaller.
|
|
|
|
This script builds the WebDrop Bridge application for Windows using PyInstaller.
|
|
It creates both a standalone executable and optionally an MSI installer.
|
|
|
|
Requirements:
|
|
- PyInstaller 6.0+
|
|
- Python 3.10+
|
|
- For MSI: WiX Toolset (optional, requires separate installation)
|
|
|
|
Usage:
|
|
python build_windows.py [--msi] [--code-sign] [--env-file PATH]
|
|
|
|
Options:
|
|
--msi Create MSI installer (requires WiX Toolset)
|
|
--code-sign Sign executable (requires certificate)
|
|
--env-file PATH Use custom .env file (default: project root .env)
|
|
If not provided, uses .env from project root
|
|
Build fails if .env doesn't exist
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
from typing import Optional
|
|
|
|
# Fix Unicode output on Windows BEFORE any other imports
|
|
if sys.platform == "win32":
|
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
|
import io
|
|
# Reconfigure stdout/stderr for UTF-8 output
|
|
sys.stdout = io.TextIOWrapper(
|
|
sys.stdout.buffer, encoding="utf-8", errors="replace"
|
|
)
|
|
sys.stderr = io.TextIOWrapper(
|
|
sys.stderr.buffer, encoding="utf-8", errors="replace"
|
|
)
|
|
|
|
import subprocess
|
|
import shutil
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
# Import shared version utilities
|
|
from sync_version import get_current_version, do_sync_version
|
|
|
|
|
|
class WindowsBuilder:
|
|
"""Build Windows installer using PyInstaller."""
|
|
|
|
def __init__(self, env_file: Path | None = None):
|
|
"""Initialize builder paths.
|
|
|
|
Args:
|
|
env_file: Path to .env file to bundle. If None, uses project root .env.
|
|
If that doesn't exist, raises error.
|
|
"""
|
|
self.project_root = Path(__file__).parent.parent.parent
|
|
self.build_dir = self.project_root / "build"
|
|
self.dist_dir = self.build_dir / "dist" / "windows"
|
|
self.temp_dir = self.build_dir / "temp" / "windows"
|
|
self.spec_file = self.build_dir / "webdrop_bridge.spec"
|
|
self.version = get_current_version()
|
|
|
|
# Validate and set env file
|
|
if env_file is None:
|
|
env_file = self.project_root / ".env"
|
|
else:
|
|
env_file = Path(env_file).resolve()
|
|
|
|
if not env_file.exists():
|
|
raise FileNotFoundError(
|
|
f"Configuration file not found: {env_file}\n"
|
|
f"Please provide a .env file using --env-file parameter\n"
|
|
f"or ensure .env exists in project root"
|
|
)
|
|
|
|
self.env_file = env_file
|
|
print(f"📋 Using configuration: {self.env_file}")
|
|
|
|
def _get_version(self) -> str:
|
|
"""Get version from __init__.py.
|
|
|
|
Note: This method is deprecated. Use get_current_version() from
|
|
version_utils.py instead.
|
|
"""
|
|
return get_current_version()
|
|
|
|
def clean(self):
|
|
"""Clean previous builds."""
|
|
print("🧹 Cleaning previous builds...")
|
|
for path in [self.dist_dir, self.temp_dir]:
|
|
if path.exists():
|
|
shutil.rmtree(path)
|
|
print(f" Removed {path}")
|
|
|
|
def build_executable(self) -> bool:
|
|
"""Build executable using PyInstaller."""
|
|
print("\n🔨 Building Windows executable with PyInstaller...")
|
|
|
|
self.dist_dir.mkdir(parents=True, exist_ok=True)
|
|
self.temp_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# PyInstaller command using spec file
|
|
# Pass env_file path as environment variable for spec to pick up
|
|
cmd = [
|
|
sys.executable,
|
|
"-m",
|
|
"PyInstaller",
|
|
"--distpath",
|
|
str(self.dist_dir),
|
|
"--workpath",
|
|
str(self.temp_dir),
|
|
str(self.spec_file),
|
|
]
|
|
|
|
print(f" Command: {' '.join(cmd)}")
|
|
|
|
# Set environment variable for spec file to use
|
|
env = os.environ.copy()
|
|
env["WEBDROP_ENV_FILE"] = str(self.env_file)
|
|
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=str(self.project_root),
|
|
text=True,
|
|
env=env
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
print("❌ PyInstaller build failed")
|
|
return False
|
|
|
|
# Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT)
|
|
exe_path = self.dist_dir / "WebDropBridge" / "WebDropBridge.exe"
|
|
if not exe_path.exists():
|
|
print(f"❌ Executable not found at {exe_path}")
|
|
return False
|
|
|
|
print("✅ Executable built successfully")
|
|
print(f"📦 Output: {exe_path}")
|
|
|
|
# Calculate total dist size
|
|
total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") if f.is_file())
|
|
if total_size > 0:
|
|
print(f" Total size: {total_size / 1024 / 1024:.1f} MB")
|
|
|
|
# Generate SHA256 checksum
|
|
self.generate_checksum(exe_path)
|
|
|
|
return True
|
|
|
|
def generate_checksum(self, file_path: Path) -> None:
|
|
"""Generate SHA256 checksum for file."""
|
|
import hashlib
|
|
|
|
print("\n📝 Generating SHA256 checksum...")
|
|
sha256_hash = hashlib.sha256()
|
|
|
|
with open(file_path, "rb") as f:
|
|
for byte_block in iter(lambda: f.read(4096), b""):
|
|
sha256_hash.update(byte_block)
|
|
|
|
checksum = sha256_hash.hexdigest()
|
|
checksum_file = file_path.parent / f"{file_path.name}.sha256"
|
|
|
|
checksum_file.write_text(checksum)
|
|
print(f"✅ Checksum generated")
|
|
print(f" File: {checksum_file}")
|
|
print(f" Hash: {checksum}")
|
|
|
|
def create_msi(self) -> bool:
|
|
"""Create MSI installer using WiX Toolset.
|
|
|
|
This requires WiX Toolset to be installed:
|
|
https://wixtoolset.org/releases/
|
|
"""
|
|
print("\n📦 Creating MSI installer with WiX...")
|
|
|
|
# Check if WiX is installed (try PATH first, then default location)
|
|
heat_exe = shutil.which("heat.exe")
|
|
candle_exe = shutil.which("candle.exe")
|
|
light_exe = shutil.which("light.exe")
|
|
|
|
# Fallback to default WiX installation location
|
|
if not candle_exe:
|
|
default_wix = Path("C:\\Program Files (x86)\\WiX Toolset v3.14\\bin")
|
|
if default_wix.exists():
|
|
heat_exe = str(default_wix / "heat.exe")
|
|
candle_exe = str(default_wix / "candle.exe")
|
|
light_exe = str(default_wix / "light.exe")
|
|
|
|
if not all([heat_exe, candle_exe, light_exe]):
|
|
print("⚠️ WiX Toolset not found in PATH or default location")
|
|
print(" Install from: https://wixtoolset.org/releases/")
|
|
print(" Or use: choco install wixtoolset")
|
|
return False
|
|
|
|
# Create base WiX source file
|
|
if not self._create_wix_source():
|
|
return False
|
|
|
|
# Harvest application files using Heat
|
|
print(f" Harvesting application files...")
|
|
dist_folder = self.dist_dir / "WebDropBridge"
|
|
if not dist_folder.exists():
|
|
print(f"❌ Distribution folder not found: {dist_folder}")
|
|
return False
|
|
|
|
harvest_file = self.build_dir / "WebDropBridge_Files.wxs"
|
|
|
|
# Use Heat to harvest all files
|
|
heat_cmd = [
|
|
str(heat_exe),
|
|
"dir",
|
|
str(dist_folder),
|
|
"-cg", "AppFiles",
|
|
"-dr", "INSTALLFOLDER",
|
|
"-sfrag",
|
|
"-srd",
|
|
"-gg",
|
|
"-o", str(harvest_file),
|
|
]
|
|
|
|
result = subprocess.run(heat_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if result.returncode != 0:
|
|
print("⚠️ Heat harvest warnings (may be non-critical)")
|
|
if result.stderr:
|
|
print(result.stderr[:200]) # Show first 200 chars of errors
|
|
else:
|
|
print(f" ✓ Harvested files")
|
|
|
|
# Post-process harvested file to mark components as 64-bit
|
|
if harvest_file.exists():
|
|
content = harvest_file.read_text()
|
|
# Add Win64="yes" to all Component tags
|
|
content = content.replace(
|
|
'<Component ',
|
|
'<Component Win64="yes" '
|
|
)
|
|
harvest_file.write_text(content)
|
|
print(f" ✓ Marked components as 64-bit")
|
|
|
|
# Compile both WiX files
|
|
wix_obj = self.build_dir / "WebDropBridge.wixobj"
|
|
wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj"
|
|
msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi"
|
|
|
|
# Run candle compiler - make sure to use correct source directory
|
|
candle_cmd = [
|
|
str(candle_exe),
|
|
"-ext", "WixUIExtension",
|
|
f"-dDistDir={self.dist_dir}",
|
|
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
|
|
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
|
|
"-o", str(self.build_dir) + "\\",
|
|
str(self.build_dir / "WebDropBridge.wxs"),
|
|
]
|
|
|
|
if harvest_file.exists():
|
|
candle_cmd.append(str(harvest_file))
|
|
|
|
print(f" Compiling WiX source...")
|
|
result = subprocess.run(candle_cmd, text=True, cwd=str(self.build_dir))
|
|
if result.returncode != 0:
|
|
print("❌ WiX compilation failed")
|
|
return False
|
|
|
|
# Link MSI - include both obj files if harvest was successful
|
|
light_cmd = [
|
|
str(light_exe),
|
|
"-ext", "WixUIExtension",
|
|
"-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
|
|
"-o", str(msi_output),
|
|
str(wix_obj),
|
|
]
|
|
|
|
if wix_files_obj.exists():
|
|
light_cmd.append(str(wix_files_obj))
|
|
|
|
print(f" Linking MSI installer...")
|
|
result = subprocess.run(light_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if result.returncode != 0:
|
|
print("❌ MSI linking failed")
|
|
if result.stdout:
|
|
print(f" Output: {result.stdout[:500]}")
|
|
if result.stderr:
|
|
print(f" Error: {result.stderr[:500]}")
|
|
return False
|
|
|
|
if not msi_output.exists():
|
|
print(f"❌ MSI not found at {msi_output}")
|
|
return False
|
|
|
|
print("✅ MSI installer created successfully")
|
|
print(f"📦 Output: {msi_output}")
|
|
print(f" Size: {msi_output.stat().st_size / 1024 / 1024:.1f} MB")
|
|
|
|
return True
|
|
|
|
def _create_wix_source(self) -> bool:
|
|
"""Create WiX source file for MSI generation."""
|
|
wix_content = f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
|
|
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
|
|
<Product Id="*" Name="WebDrop Bridge" Language="1033" Version="{self.version}"
|
|
Manufacturer="HIM-Tools"
|
|
UpgradeCode="12345678-1234-1234-1234-123456789012">
|
|
|
|
<Package InstallerVersion="200" Compressed="yes" InstallScope="perUser" Platform="x64" />
|
|
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
|
|
|
|
<!-- Required property for WixUI_InstallDir dialog set -->
|
|
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
|
|
|
|
<!-- Application Icon -->
|
|
<Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\\icons\\app.ico" />
|
|
|
|
<!-- Custom branding for InstallDir dialog set -->
|
|
<WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\\icons\\background.bmp" />
|
|
<WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\\icons\\banner.bmp" />
|
|
<WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\\license.rtf" />
|
|
|
|
<!-- Installation UI dialogs -->
|
|
<UIRef Id="WixUI_InstallDir" />
|
|
<UIRef Id="WixUI_ErrorProgressText" />
|
|
|
|
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
|
<ComponentGroupRef Id="AppFiles" />
|
|
<ComponentRef Id="ProgramMenuShortcut" />
|
|
<ComponentRef Id="DesktopShortcut" />
|
|
</Feature>
|
|
|
|
<Directory Id="TARGETDIR" Name="SourceDir">
|
|
<Directory Id="LocalAppDataFolder">
|
|
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
|
|
</Directory>
|
|
<Directory Id="ProgramMenuFolder">
|
|
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
|
|
</Directory>
|
|
<Directory Id="DesktopFolder" />
|
|
</Directory>
|
|
|
|
<DirectoryRef Id="ApplicationProgramsFolder">
|
|
<Component Id="ProgramMenuShortcut" Guid="*">
|
|
<Shortcut Id="ApplicationStartMenuShortcut"
|
|
Name="WebDrop Bridge"
|
|
Description="Web Drag-and-Drop Bridge"
|
|
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
|
Icon="AppIcon.ico"
|
|
IconIndex="0"
|
|
WorkingDirectory="INSTALLFOLDER" />
|
|
<RemoveFolder Id="ApplicationProgramsFolderRemove"
|
|
On="uninstall" />
|
|
<RegistryValue Root="HKCU"
|
|
Key="Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\WebDropBridge"
|
|
Name="installed"
|
|
Type="integer"
|
|
Value="1"
|
|
KeyPath="yes" />
|
|
</Component>
|
|
</DirectoryRef>
|
|
|
|
<DirectoryRef Id="DesktopFolder">
|
|
<Component Id="DesktopShortcut" Guid="*">
|
|
<Shortcut Id="DesktopApplicationShortcut"
|
|
Name="WebDrop Bridge"
|
|
Description="Web Drag-and-Drop Bridge"
|
|
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
|
Icon="AppIcon.ico"
|
|
IconIndex="0"
|
|
WorkingDirectory="INSTALLFOLDER" />
|
|
<RegistryValue Root="HKCU"
|
|
Key="Software\\WebDropBridge"
|
|
Name="DesktopShortcut"
|
|
Type="integer"
|
|
Value="1"
|
|
KeyPath="yes" />
|
|
</Component>
|
|
</DirectoryRef>
|
|
</Product>
|
|
</Wix>
|
|
'''
|
|
|
|
wix_file = self.build_dir / "WebDropBridge.wxs"
|
|
wix_file.write_text(wix_content)
|
|
print(f" Created WiX source: {wix_file}")
|
|
return True
|
|
|
|
def _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: Optional[dict] = None) -> str:
|
|
"""Generate WiX File elements for all files in a folder.
|
|
|
|
Args:
|
|
folder: Root folder to scan
|
|
parent_dir_ref: Parent WiX DirectoryRef ID
|
|
parent_rel_path: Relative path for component structure
|
|
indent: Indentation level
|
|
file_counter: Dictionary to track file IDs for uniqueness
|
|
|
|
Returns:
|
|
WiX XML string with all File elements
|
|
"""
|
|
if file_counter is None:
|
|
file_counter = {}
|
|
|
|
elements = []
|
|
indent_str = " " * indent
|
|
|
|
try:
|
|
# Get all files in current folder
|
|
for item in sorted(folder.iterdir()):
|
|
if item.is_file():
|
|
# Create unique File element ID using hash of full path
|
|
import hashlib
|
|
path_hash = hashlib.md5(str(item).encode()).hexdigest()[:8]
|
|
file_id = f"File_{path_hash}"
|
|
file_path = str(item)
|
|
elements.append(f'{indent_str}<File Id="{file_id}" Source="{file_path}" />')
|
|
elif item.is_dir() and item.name != "__pycache__":
|
|
# Recursively add files from subdirectories
|
|
sub_elements = self._generate_file_elements(
|
|
item, parent_dir_ref,
|
|
f"{parent_rel_path}/{item.name}",
|
|
indent,
|
|
file_counter
|
|
)
|
|
if sub_elements:
|
|
elements.append(sub_elements)
|
|
except PermissionError:
|
|
print(f" ⚠️ Permission denied accessing {folder}")
|
|
|
|
return "\n".join(elements)
|
|
|
|
def _sanitize_id(self, filename: str) -> str:
|
|
"""Sanitize filename to be a valid WiX identifier.
|
|
|
|
Args:
|
|
filename: Filename to sanitize
|
|
|
|
Returns:
|
|
Sanitized identifier
|
|
"""
|
|
# Remove extension and invalid characters
|
|
safe_name = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
# Replace invalid characters with underscores
|
|
safe_name = "".join(c if c.isalnum() or c == "_" else "_" for c in safe_name)
|
|
# Ensure it starts with a letter or underscore
|
|
if safe_name and not (safe_name[0].isalpha() or safe_name[0] == "_"):
|
|
safe_name = f"_{safe_name}"
|
|
# Limit length to avoid WiX ID limits
|
|
return safe_name[:50] if len(safe_name) > 50 else safe_name
|
|
|
|
def sign_executable(self, cert_path: str, password: str) -> bool:
|
|
"""Sign executable with certificate (optional).
|
|
|
|
Args:
|
|
cert_path: Path to code signing certificate
|
|
password: Certificate password
|
|
|
|
Returns:
|
|
True if signing successful
|
|
"""
|
|
print("\n🔐 Signing executable...")
|
|
|
|
signtool = shutil.which("signtool.exe")
|
|
if not signtool:
|
|
print("⚠️ signtool.exe not found (part of Windows SDK)")
|
|
print(" Skipping code signing")
|
|
return True
|
|
|
|
exe_path = self.dist_dir / "WebDropBridge.exe"
|
|
cmd = [
|
|
signtool,
|
|
"sign",
|
|
"/f",
|
|
cert_path,
|
|
"/p",
|
|
password,
|
|
"/t",
|
|
"http://timestamp.comodoca.com/authenticode",
|
|
str(exe_path),
|
|
]
|
|
|
|
result = subprocess.run(
|
|
cmd,
|
|
text=True
|
|
)
|
|
if result.returncode != 0:
|
|
print("❌ Code signing failed")
|
|
return False
|
|
|
|
print("✅ Executable signed successfully")
|
|
return True
|
|
|
|
def build(self, create_msi: bool = False, sign: bool = False) -> bool:
|
|
"""Run complete build process.
|
|
|
|
Args:
|
|
create_msi: Whether to create MSI installer
|
|
sign: Whether to sign executable (requires certificate)
|
|
|
|
Returns:
|
|
True if build successful
|
|
"""
|
|
start_time = datetime.now()
|
|
print("=" * 60)
|
|
print("🚀 WebDrop Bridge Windows Build")
|
|
print("=" * 60)
|
|
|
|
self.clean()
|
|
|
|
if not self.build_executable():
|
|
return False
|
|
|
|
if create_msi:
|
|
if not self.create_msi():
|
|
print("⚠️ MSI creation failed, but executable is available")
|
|
|
|
if sign:
|
|
# Would need certificate path from environment
|
|
cert_path = os.getenv("CODE_SIGN_CERT")
|
|
if cert_path:
|
|
self.sign_executable(cert_path, os.getenv("CODE_SIGN_PASSWORD", ""))
|
|
|
|
elapsed = (datetime.now() - start_time).total_seconds()
|
|
print("\n" + "=" * 60)
|
|
print(f"✅ Build completed in {elapsed:.1f}s")
|
|
print("=" * 60)
|
|
return True
|
|
|
|
|
|
def main() -> int:
|
|
"""Build Windows MSI installer."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Build WebDrop Bridge Windows installer"
|
|
)
|
|
parser.add_argument(
|
|
"--msi",
|
|
action="store_true",
|
|
help="Create MSI installer (requires WiX Toolset)",
|
|
)
|
|
parser.add_argument(
|
|
"--code-sign",
|
|
action="store_true",
|
|
help="Sign executable (requires certificate in CODE_SIGN_CERT env var)",
|
|
)
|
|
parser.add_argument(
|
|
"--env-file",
|
|
type=str,
|
|
default=None,
|
|
help="Path to .env file to bundle (default: project root .env)",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
print("🔄 Syncing version...")
|
|
do_sync_version()
|
|
|
|
try:
|
|
builder = WindowsBuilder(env_file=args.env_file)
|
|
except FileNotFoundError as e:
|
|
print(f"❌ Build failed: {e}")
|
|
return 1
|
|
|
|
success = builder.build(create_msi=args.msi, sign=args.code_sign)
|
|
|
|
return 0 if success else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|