webdrop-bridge/build/scripts/build_windows.py
claudi ef96184dc3
Some checks are pending
Tests & Quality Checks / Test on Python 3.11 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-1 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.10 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.11-2 (push) Waiting to run
Tests & Quality Checks / Test on Python 3.12-2 (push) Waiting to run
Tests & Quality Checks / Build Artifacts (push) Blocked by required conditions
Tests & Quality Checks / Build Artifacts-1 (push) Blocked by required conditions
fix: Rename WiX object file and correct source directory path for Heat-generated files
2026-03-12 11:23:58 +01:00

664 lines
24 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
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("<Component ", '<Component Win64="yes" ')
harvest_file.write_text(content)
print(f" ✓ Marked components as 64-bit")
# Compile both WiX files
wix_obj = self.build_dir / "WebDropBridge.generated.wixobj"
wix_files_obj = self.build_dir / "WebDropBridge_Files.wixobj"
msi_output = self.dist_dir / self.brand.windows_installer_name(self.version)
# Run candle compiler - make sure to use correct source directory
candle_cmd = [
str(candle_exe),
"-ext",
"WixUIExtension",
"-ext",
"WixUtilExtension",
f"-dDistDir={self.dist_dir}",
f"-dSourceDir={self.dist_dir / self.brand.exe_name}", # Set SourceDir for Heat-generated files
f"-dResourcesDir={self.project_root}\\resources", # Set ResourcesDir for branding assets
"-o",
str(self.build_dir) + "\\",
str(self.build_dir / "WebDropBridge.generated.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),
"-ext",
"WixUIExtension",
"-ext",
"WixUtilExtension",
"-b",
str(self.dist_dir / self.brand.exe_name), # 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, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
if result.returncode != 0:
print("❌ MSI linking failed")
if result.stdout:
print(f" Output: {result.stdout[:500]}")
if result.stderr:
print(f" Error: {result.stderr[:500]}")
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")
self.generate_checksum(msi_output)
return True
def _ensure_toolbar_icons_in_bundle(self) -> 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}<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 / 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())