"""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 # 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 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, text=True ) 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, text=True ) 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, 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())