webdrop-bridge/build/scripts/build_windows.py

419 lines
13 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 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'''<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<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="perMachine" />
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
<ComponentRef Id="MainExecutable" />
<ComponentRef Id="ProgramMenuShortcut" />
</Feature>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFilesFolder">
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
</Directory>
<Directory Id="ProgramMenuFolder">
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
</Directory>
</Directory>
<DirectoryRef Id="INSTALLFOLDER">
<Component Id="MainExecutable" Guid="*">
<File Id="WebDropBridgeExe" Source="$(var.DistDir)\\WebDropBridge.exe" KeyPath="yes"/>
</Component>
</DirectoryRef>
<DirectoryRef Id="ApplicationProgramsFolder">
<Component Id="ProgramMenuShortcut" Guid="*">
<Shortcut Id="ApplicationStartMenuShortcut"
Name="WebDrop Bridge"
Description="Web Drag-and-Drop Bridge"
Target="[INSTALLFOLDER]WebDropBridge.exe"
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>
</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 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())