- Updated `brand_config.py` to include `WEBDROP_UPDATE_CHANNEL` in the environment variables. - Enhanced `build_macos.sh` to create a bundled `.env` file with brand-specific defaults, including the update channel. - Implemented a method in `build_windows.py` to create a bundled `.env` file for Windows builds, incorporating brand-specific runtime defaults. - Modified `config.py` to ensure the application can locate the `.env` file in various installation scenarios. - Added unit tests in `test_config.py` to verify the loading of the bootstrap `.env` from the PyInstaller runtime directory. - Generated new WiX object and script files for the Windows installer, including application shortcuts and registry entries.
659 lines
23 KiB
Python
659 lines
23 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,
|
|
}
|
|
|
|
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.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,
|
|
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())
|