"""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 from dotenv import dotenv_values # Import shared version utilities from brand_config import load_brand_config 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, brand: str | 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.brand = load_brand_config(brand, root=self.project_root) self.build_dir = self.project_root / "build" self.dist_dir = self.build_dir / "dist" / "windows" / self.brand.brand_id self.temp_dir = self.build_dir / "temp" / "windows" / self.brand.brand_id self.spec_file = self.build_dir / "webdrop_bridge.spec" self.wix_template = self.build_dir / "WebDropBridge.wxs" 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}") print(f"๐Ÿท๏ธ Building brand: {self.brand.display_name} ({self.brand.brand_id})") 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}") @staticmethod def _format_env_value(value: str) -> str: """Format env values safely for .env files.""" if any(ch in value for ch in [" ", "#", '"', "'", "\t"]): escaped = value.replace("\\", "\\\\").replace('"', '\\"') return f'"{escaped}"' return value def _create_bundled_env_file(self) -> Path: """Create a bundled .env file with brand-specific runtime defaults.""" values = dotenv_values(self.env_file) overrides = { "APP_NAME": self.brand.display_name, "BRAND_ID": self.brand.brand_id, "APP_CONFIG_DIR_NAME": self.brand.config_dir_name, "UPDATE_CHANNEL": self.brand.update_channel, "TOOLBAR_ICON_HOME": self.brand.toolbar_icon_home, "TOOLBAR_ICON_RELOAD": self.brand.toolbar_icon_reload, "TOOLBAR_ICON_OPEN": self.brand.toolbar_icon_open, "TOOLBAR_ICON_OPENWITH": self.brand.toolbar_icon_openwith, } output_env = self.temp_dir / ".env" output_env.parent.mkdir(parents=True, exist_ok=True) lines: list[str] = [] for key, raw_value in values.items(): if key in overrides: continue if raw_value is None: lines.append(key) else: lines.append(f"{key}={self._format_env_value(str(raw_value))}") lines.append("") lines.append("# Brand-specific defaults added during packaging") for key, value in overrides.items(): lines.append(f"{key}={self._format_env_value(value)}") output_env.write_text("\n".join(lines) + "\n", encoding="utf-8") return output_env 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._create_bundled_env_file()) env["WEBDROP_BRAND_ID"] = self.brand.brand_id env["WEBDROP_APP_DISPLAY_NAME"] = self.brand.display_name env["WEBDROP_ASSET_PREFIX"] = self.brand.asset_prefix env["WEBDROP_EXE_NAME"] = self.brand.exe_name env["WEBDROP_BUNDLE_ID"] = self.brand.bundle_identifier env["WEBDROP_CONFIG_DIR_NAME"] = self.brand.config_dir_name env["WEBDROP_ICON_ICO"] = str(self.brand.icon_ico) env["WEBDROP_ICON_ICNS"] = str(self.brand.icon_icns) env["WEBDROP_VERSION"] = self.version 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 (inside the COLLECT directory) exe_path = self.dist_dir / self.brand.exe_name / f"{self.brand.exe_name}.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(f"{self.brand.exe_name}/**/*") if f.is_file() ) if total_size > 0: print(f" Total size: {total_size / 1024 / 1024:.1f} MB") # Set executable version information (required for MSI updates) self.set_exe_version(exe_path) # 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 set_exe_version(self, exe_path: Path) -> None: """Set executable file version for Windows. This is important for MSI updates: Windows Installer compares file versions to determine if files should be updated during a major upgrade. Args: exe_path: Path to the executable file """ print("\n๐Ÿท๏ธ Setting executable version information...") try: import pefile except ImportError: print("โš ๏ธ pefile not installed - skipping EXE version update") print(" Note: Install with: pip install pefile") print(" EXE version info will be blank (MSI updates may not work correctly)") return try: pe = pefile.PE(str(exe_path)) # Parse version into 4-part format (Major, Minor, Build, Revision) version_parts = self.version.split(".") while len(version_parts) < 4: version_parts.append("0") file_version = tuple(int(v) for v in version_parts[:4]) # Set version resource if it exists if hasattr(pe, "VS_FIXEDFILEINFO"): 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].ProductVersionMS = (file_version[0] << 16) | file_version[1] pe.VS_FIXEDFILEINFO[0].ProductVersionLS = (file_version[2] << 16) | file_version[3] # Write modified PE back to file pe.write(filename=str(exe_path)) print(f"โœ… Version set to {self.version}") else: print("โš ๏ธ No version resource found in EXE") except Exception as e: print(f"โš ๏ธ Could not set EXE version: {e}") print(" MSI updates may not work correctly without file version info") 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 # Ensure toolbar icons are present in bundled resources before harvesting. if not self._ensure_toolbar_icons_in_bundle(): return False # Harvest application files using Heat print(f" Harvesting application files...") dist_folder = self.dist_dir / self.brand.exe_name 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: """Ensure toolbar icon files exist in the bundled app folder. This guarantees WiX Heat harvest includes these icons in the MSI, even if a previous PyInstaller run omitted them. """ src_icons_dir = self.project_root / "resources" / "icons" bundle_icons_dir = self.dist_dir / self.brand.exe_name / "_internal" / "resources" / "icons" required_icons = ["home.ico", "reload.ico", "open.ico", "openwith.ico"] try: bundle_icons_dir.mkdir(parents=True, exist_ok=True) for icon_name in required_icons: src = src_icons_dir / icon_name dst = bundle_icons_dir / icon_name if not src.exists(): print(f"โŒ Required icon not found: {src}") return False if not dst.exists() or src.stat().st_mtime > dst.stat().st_mtime: shutil.copy2(src, dst) print(f" โœ“ Ensured toolbar icon in bundle: {icon_name}") return True except Exception as e: print(f"โŒ Failed to ensure toolbar icons in bundle: {e}") return False def _create_wix_source(self) -> bool: """Create WiX source file for MSI generation. Creates per-machine installation (Program Files). Installation requires admin rights, but the app does not. """ wix_template = self.wix_template.read_text(encoding="utf-8") wix_content = wix_template.format( product_name=self.brand.display_name, product_name_with_version=f"{self.brand.display_name} v{self.version}", version=self.version, manufacturer=self.brand.manufacturer, upgrade_code=self.brand.msi_upgrade_code, asset_prefix=self.brand.asset_prefix, icon_ico=str(self.brand.icon_ico), dialog_bmp=str(self.brand.dialog_bmp), banner_bmp=str(self.brand.banner_bmp), license_rtf=str(self.brand.license_rtf), exe_name=self.brand.exe_name, install_dir_name=self.brand.install_dir_name, shortcut_description=self.brand.shortcut_description, ) wix_file = self.build_dir / "WebDropBridge.generated.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 / self.brand.exe_name / f"{self.brand.exe_name}.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(f"๐Ÿš€ {self.brand.display_name} 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)", ) parser.add_argument( "--brand", type=str, default=None, help="Brand manifest name from build/brands (e.g. agravity)", ) args = parser.parse_args() print("๐Ÿ”„ Syncing version...") do_sync_version() try: builder = WindowsBuilder(env_file=args.env_file, brand=args.brand) 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())