"""Build the Tk controller with Windows Python and PyInstaller. Run `uv run --python 3.14 --group bundle python scripts/build_exe.py`. Build output stays under dist/ and temporary files under build/. A Windows executable must be built on Windows; this script never attempts cross-compiling. """ import argparse import importlib.util import subprocess import sys from pathlib import Path def build_arguments(root, onefile=False, console=False): """Include the dynamically selected WinRT backend and its namespace modules.""" root = Path(root).resolve() return [ "--noconfirm", "--clean", "--name", "SoundTowerController", "--console" if console else "--windowed", "--onefile" if onefile else "--onedir", "--distpath", str(root / "dist"), "--workpath", str(root / "build" / "pyinstaller"), "--specpath", str(root / "build"), "--paths", str(root), # Bleak chooses backends dynamically. WinRT's generated namespaces # and extension modules must also be present in the frozen application. "--collect-submodules", "bleak.backends.winrt", "--collect-all", "winrt", "--copy-metadata", "bleak", "--copy-metadata", "winrt-runtime", str(root / "controller" / "__main__.py"), ] def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--onefile", action="store_true", help="Make one self-extracting .exe instead of an application folder", ) parser.add_argument( "--console", action="store_true", help="Keep a console for diagnosing startup errors" ) args = parser.parse_args(argv) if sys.platform != "win32": parser.error( "Build with Windows Python in PowerShell, or Windows uv.exe from WSL. Linux Python cannot build a Windows executable." ) if importlib.util.find_spec("PyInstaller") is None: parser.error( "PyInstaller is missing. Run with: uv run --group bundle python scripts/build_exe.py" ) if importlib.util.find_spec("tkinter") is None: parser.error( "This Python installation lacks Tk. Use --python with a Windows Python installation that includes Tcl/Tk." ) root = Path(__file__).resolve().parents[1] subprocess.run( [sys.executable, "-m", "PyInstaller", *build_arguments(root, args.onefile, args.console)], cwd=root, check=True, ) executable = ( root / "dist" / "SoundTowerController.exe" if args.onefile else root / "dist" / "SoundTowerController" / "SoundTowerController.exe" ) if not executable.is_file(): raise RuntimeError(f"Build finished without the expected executable: {executable}") print(f"Built: {executable}") if not args.onefile: print("Keep the whole SoundTowerController folder together, including _internal.") if __name__ == "__main__": main()