This commit introduces a new test script, `test_msi.py`, which automates the process of creating an MSI installer. The script utilizes the `WindowsBuilder` class to generate the installer and checks for its successful creation, providing feedback on the result and the file size.
509 lines
17 KiB
Python
509 lines
17 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 os
|
|
|
|
# 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
|
|
|
|
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 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")
|
|
|
|
# Compile both WiX files
|
|
wix_obj = self.build_dir / "WebDropBridge.wixobj"
|
|
wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj"
|
|
msi_output = self.dist_dir / f"WebDropBridge-{self.version}-Setup.msi"
|
|
|
|
# Run candle compiler - make sure to use correct source directory
|
|
candle_cmd = [
|
|
str(candle_exe),
|
|
f"-dDistDir={self.dist_dir}",
|
|
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # Set SourceDir for Heat-generated files
|
|
"-o", str(self.build_dir) + "\\",
|
|
str(self.build_dir / "WebDropBridge.wxs"),
|
|
]
|
|
|
|
if harvest_file.exists():
|
|
candle_cmd.append(str(harvest_file))
|
|
|
|
print(f" Compiling WiX source...")
|
|
result = subprocess.run(candle_cmd, text=True, cwd=str(self.build_dir))
|
|
if result.returncode != 0:
|
|
print("❌ WiX compilation failed")
|
|
return False
|
|
|
|
# Link MSI - include both obj files if harvest was successful
|
|
light_cmd = [
|
|
str(light_exe),
|
|
"-b", str(self.dist_dir / "WebDropBridge"), # Base path for source files
|
|
"-o", str(msi_output),
|
|
str(wix_obj),
|
|
]
|
|
|
|
if wix_files_obj.exists():
|
|
light_cmd.append(str(wix_files_obj))
|
|
|
|
print(f" Linking MSI installer...")
|
|
result = subprocess.run(light_cmd, text=True)
|
|
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" Platform="x64" />
|
|
<Media Id="1" Cabinet="WebDropBridge.cab" EmbedCab="yes" />
|
|
|
|
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
|
<ComponentGroupRef Id="AppFiles" />
|
|
<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="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 _generate_file_elements(self, folder: Path, parent_dir_ref: str, parent_rel_path: str, indent: int = 8, file_counter: 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}<File Id="{file_id}" Source="{file_path}" />')
|
|
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())
|