"""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( ' bool: """Create WiX source file for MSI generation. Creates per-machine installation (Program Files). Installation requires admin rights, but the app does not. """ 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 _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}') 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())