- Implemented `set_exe_version` method in `WindowsBuilder` to set the version information for the generated executable. - This ensures proper MSI updates by comparing file versions. - Added error handling for missing `pefile` dependency and version resource. - Updated `requirements-dev.txt` to include `pefile` as a dependency for building.
624 lines
22 KiB
Python
624 lines
22 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
|
|
|
|
# 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
|
|
|
|
# Check if executable exists (now in WebDropBridge/WebDropBridge.exe due to COLLECT)
|
|
exe_path = self.dist_dir / "WebDropBridge" / "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}")
|
|
|
|
# Calculate total dist size
|
|
total_size = sum(f.stat().st_size for f in self.dist_dir.glob("WebDropBridge/**/*") 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
|
|
|
|
# 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")
|
|
|
|
# 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 / f"WebDropBridge-{self.version}-Setup.msi"
|
|
|
|
# Run candle compiler - make sure to use correct source directory
|
|
candle_cmd = [
|
|
str(candle_exe),
|
|
"-ext", "WixUIExtension",
|
|
f"-dDistDir={self.dist_dir}",
|
|
f"-dSourceDir={self.dist_dir}\\WebDropBridge", # 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.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",
|
|
"-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, 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")
|
|
|
|
return True
|
|
|
|
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_content = f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
|
|
xmlns:ui="http://schemas.microsoft.com/wix/2010/ui">
|
|
<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" />
|
|
|
|
<!-- Required property for WixUI_InstallDir dialog set -->
|
|
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />
|
|
|
|
<!-- Application Icon -->
|
|
<Icon Id="AppIcon.ico" SourceFile="$(var.ResourcesDir)\\icons\\app.ico" />
|
|
|
|
<!-- Custom branding for InstallDir dialog set -->
|
|
<WixVariable Id="WixUIDialogBmp" Value="$(var.ResourcesDir)\\icons\\background.bmp" />
|
|
<WixVariable Id="WixUIBannerBmp" Value="$(var.ResourcesDir)\\icons\\banner.bmp" />
|
|
<WixVariable Id="WixUILicenseRtf" Value="$(var.ResourcesDir)\\license.rtf" />
|
|
|
|
<!-- Installation UI dialogs -->
|
|
<UIRef Id="WixUI_InstallDir" />
|
|
<UIRef Id="WixUI_ErrorProgressText" />
|
|
|
|
<Feature Id="ProductFeature" Title="WebDrop Bridge" Level="1">
|
|
<ComponentGroupRef Id="AppFiles" />
|
|
<ComponentRef Id="ProgramMenuShortcut" />
|
|
<ComponentRef Id="DesktopShortcut" />
|
|
</Feature>
|
|
|
|
<Directory Id="TARGETDIR" Name="SourceDir">
|
|
<Directory Id="ProgramFiles64Folder">
|
|
<Directory Id="INSTALLFOLDER" Name="WebDrop Bridge" />
|
|
</Directory>
|
|
<Directory Id="ProgramMenuFolder">
|
|
<Directory Id="ApplicationProgramsFolder" Name="WebDrop Bridge"/>
|
|
</Directory>
|
|
<Directory Id="DesktopFolder" />
|
|
</Directory>
|
|
|
|
<DirectoryRef Id="ApplicationProgramsFolder">
|
|
<Component Id="ProgramMenuShortcut" Guid="*">
|
|
<Shortcut Id="ApplicationStartMenuShortcut"
|
|
Name="WebDrop Bridge"
|
|
Description="Web Drag-and-Drop Bridge"
|
|
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
|
Icon="AppIcon.ico"
|
|
IconIndex="0"
|
|
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>
|
|
|
|
<DirectoryRef Id="DesktopFolder">
|
|
<Component Id="DesktopShortcut" Guid="*">
|
|
<Shortcut Id="DesktopApplicationShortcut"
|
|
Name="WebDrop Bridge"
|
|
Description="Web Drag-and-Drop Bridge"
|
|
Target="[INSTALLFOLDER]WebDropBridge.exe"
|
|
Icon="AppIcon.ico"
|
|
IconIndex="0"
|
|
WorkingDirectory="INSTALLFOLDER" />
|
|
<RegistryValue Root="HKCU"
|
|
Key="Software\\WebDropBridge"
|
|
Name="DesktopShortcut"
|
|
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: 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 / "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())
|