"""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 subprocess import os import shutil import argparse from pathlib import Path from datetime import datetime # Import shared version utilities from version_utils import get_current_version # Fix Unicode output on Windows if sys.platform == "win32": import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8") 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), encoding="utf-8", errors="replace", env=env ) if result.returncode != 0: print("โŒ PyInstaller build failed") return False exe_path = self.dist_dir / "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}") print(f" Size: {exe_path.stat().st_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 WiX source file if not self._create_wix_source(): return False # Compile and link wix_obj = self.build_dir / "WebDropBridge.wixobj" msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi" # Run candle (compiler) - pass preprocessor variables candle_cmd = [ str(candle_exe), f"-dDistDir={self.dist_dir}", "-o", str(wix_obj), str(self.build_dir / "WebDropBridge.wxs"), ] print(f" Compiling WiX source...") result = subprocess.run( candle_cmd, encoding="utf-8", errors="replace" ) if result.returncode != 0: print("โŒ WiX compilation failed") return False # Run light (linker) light_cmd = [ str(light_exe), "-o", str(msi_output), str(wix_obj), ] print(f" Linking MSI installer...") result = subprocess.run( light_cmd, encoding="utf-8", errors="replace" ) if result.returncode != 0: print("โŒ MSI linking failed") 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''' ''' wix_file = self.build_dir / "WebDropBridge.wxs" wix_file.write_text(wix_content) print(f" Created WiX source: {wix_file}") return True 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, encoding="utf-8", errors="replace" ) 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...") 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())