webdrop-bridge/build/scripts/build_windows.py

353 lines
11 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]
"""
import sys
import subprocess
import os
import shutil
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):
"""Initialize builder paths."""
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()
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
cmd = [
sys.executable,
"-m",
"PyInstaller",
"--distpath",
str(self.dist_dir),
"--workpath",
str(self.temp_dir),
str(self.spec_file),
]
print(f" Command: {' '.join(cmd)}")
result = subprocess.run(cmd, cwd=str(self.project_root))
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)
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)
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)
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 sync_version() -> None:
"""Sync version from __init__.py to all dependent files."""
script_path = Path(__file__).parent.parent.parent / "scripts" / "sync_version.py"
result = subprocess.run(
[sys.executable, str(script_path)],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"❌ Version sync failed: {result.stderr}")
sys.exit(1)
print(result.stdout)
def main() -> int:
"""Build Windows MSI installer."""
print("🔄 Syncing version...")
sync_version()
builder = WindowsBuilder()
success = builder.build(create_msi=True, sign=False)
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())