371 lines
12 KiB
Python
371 lines
12 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),
|
|
encoding="utf-8",
|
|
errors="replace"
|
|
)
|
|
|
|
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 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,
|
|
encoding="utf-8",
|
|
)
|
|
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())
|