1+ #!/usr/bin/env python3
2+ """Cross-platform build script for TMS Express.
3+
4+ This script can be executed locally or within a CI environment to build
5+ and package TMS Express for macOS, Linux and Windows.
6+ """
7+
8+ from __future__ import annotations
9+
10+ import argparse
11+ import platform
12+ import shutil
13+ import subprocess
14+ from dataclasses import dataclass
15+ from pathlib import Path
16+
17+
18+ @dataclass
19+ class BuildConfig :
20+ """Configuration for a build invocation."""
21+
22+ root : Path = Path (__file__ ).parent .resolve ()
23+ build_dir : Path = root / "build"
24+ dist_dir : Path = root / "dist"
25+ build_type : str = "Release"
26+
27+
28+ class BuildError (RuntimeError ):
29+ """Raised when a build step fails."""
30+
31+
32+ def run (cmd : str ) -> None :
33+ """Run a shell command and raise if it fails."""
34+
35+ print (f"[RUN] { cmd } " )
36+ subprocess .check_call (cmd , shell = True )
37+
38+
39+ class Builder :
40+ """Platform aware build helper."""
41+
42+ def __init__ (self , cfg : BuildConfig ) -> None :
43+ self .cfg = cfg
44+ self .system = platform .system ()
45+
46+ # ------------------------------------------------------------------
47+ # Dependency Installation
48+ # ------------------------------------------------------------------
49+ def install_dependencies (self ) -> None :
50+ if self .system == "Darwin" :
51+ self ._install_macos ()
52+ elif self .system == "Linux" :
53+ self ._install_linux ()
54+ elif self .system == "Windows" :
55+ self ._install_windows ()
56+ else :
57+ raise BuildError (f"Unsupported system: { self .system } " )
58+
59+ def _install_macos (self ) -> None :
60+ run ("brew install cmake libsndfile pkg-config qt" )
61+ # The multimedia module ships with qt on macOS
62+
63+ def _install_linux (self ) -> None :
64+ run ("sudo apt-get update" )
65+ run (
66+ "sudo apt-get install -y cmake libsndfile1-dev pkg-config "
67+ "qt6-base-dev qt6-multimedia-dev libgl1-mesa-dev"
68+ )
69+
70+ def _install_windows (self ) -> None :
71+ run ("choco install -y cmake" )
72+ run ("choco install -y libsndfile" )
73+ run ("choco install -y pkgconfiglite" )
74+ run ("choco install -y qt6" )
75+
76+ # ------------------------------------------------------------------
77+ # Build Steps
78+ # ------------------------------------------------------------------
79+ def build (self ) -> None :
80+ cfg = self .cfg
81+ run (
82+ f"cmake -B { cfg .build_dir } -DCMAKE_BUILD_TYPE={ cfg .build_type } "
83+ "-DTMSEXPRESS_BUILD_TESTS=OFF "
84+ "-DCMAKE_POLICY_VERSION_MINIMUM=3.5"
85+ )
86+ run (f"cmake --build { cfg .build_dir } --config { cfg .build_type } " )
87+
88+ # ------------------------------------------------------------------
89+ # Packaging
90+ # ------------------------------------------------------------------
91+ def package (self ) -> Path :
92+ self .cfg .dist_dir .mkdir (parents = True , exist_ok = True )
93+ if self .system == "Darwin" :
94+ return self ._package_macos ()
95+ if self .system == "Linux" :
96+ return self ._package_linux ()
97+ if self .system == "Windows" :
98+ return self ._package_windows ()
99+ raise BuildError (f"Unsupported system: { self .system } " )
100+
101+ def _package_macos (self ) -> Path :
102+ """Bundle the build as a .app and zip it."""
103+ cfg = self .cfg
104+ app_dir = cfg .dist_dir / "TMSExpress.app" / "Contents" / "MacOS"
105+ app_dir .mkdir (parents = True , exist_ok = True )
106+ shutil .copy (cfg .build_dir / "tmsexpress" , app_dir )
107+
108+ # Ensure the executable has an rpath that points to the location
109+ # where macdeployqt will place the Qt frameworks. Without this,
110+ # macdeployqt fails to locate the required frameworks and prints
111+ # numerous "Cannot resolve rpath" errors.
112+ exe = app_dir / "tmsexpress"
113+ run (f"install_name_tool -add_rpath @executable_path/../Frameworks { exe } " )
114+
115+ run (f"macdeployqt { cfg .dist_dir / 'TMSExpress.app' } -verbose=1" )
116+ run (f"codesign --deep --force --sign - { cfg .dist_dir / 'TMSExpress.app' } " )
117+
118+ archive = shutil .make_archive (str (cfg .dist_dir / "tmsexpress-macos" ), "zip" , cfg .dist_dir , "TMSExpress.app" )
119+ return Path (archive )
120+
121+ def _package_linux (self ) -> Path :
122+ """Bundle the build as an AppImage."""
123+ cfg = self .cfg
124+ app_dir = cfg .dist_dir / "AppDir"
125+ app_dir .mkdir (parents = True , exist_ok = True )
126+ shutil .copy (cfg .build_dir / "tmsexpress" , app_dir / "tmsexpress" )
127+ run (
128+ f"linuxdeployqt { app_dir / 'tmsexpress' } -appimage -verbose=1 "
129+ f"-qmldir={ cfg .root / 'src' } "
130+ )
131+ appimage = cfg .dist_dir / "tmsexpress-x86_64.AppImage"
132+ return appimage
133+
134+ def _package_windows (self ) -> Path :
135+ """Bundle the build for Windows."""
136+ cfg = self .cfg
137+ dist = cfg .dist_dir / "tmsexpress"
138+ dist .mkdir (parents = True , exist_ok = True )
139+ exe = cfg .build_dir / cfg .build_type / "tmsexpress.exe"
140+ if not exe .exists ():
141+ # msbuild layout
142+ exe = cfg .build_dir / "tmsexpress.exe"
143+ shutil .copy (exe , dist )
144+ run (f"windeployqt { dist / 'tmsexpress.exe' } " )
145+ archive = shutil .make_archive (str (cfg .dist_dir / "tmsexpress-windows" ), "zip" , dist )
146+ return Path (archive )
147+
148+
149+ # ----------------------------------------------------------------------
150+ # CLI Entrypoint
151+ # ----------------------------------------------------------------------
152+
153+ def main () -> None :
154+ parser = argparse .ArgumentParser (
155+ description = "Build and package TMS Express"
156+ )
157+ parser .add_argument (
158+ "--skip-deps" ,
159+ action = "store_true" ,
160+ help = "Skip installing dependencies" ,
161+ )
162+ parser .add_argument (
163+ "--skip-build" ,
164+ action = "store_true" ,
165+ help = "Skip project compilation" ,
166+ )
167+ parser .add_argument (
168+ "--skip-package" ,
169+ action = "store_true" ,
170+ help = "Skip packaging step" ,
171+ )
172+ args = parser .parse_args ()
173+
174+ cfg = BuildConfig ()
175+ builder = Builder (cfg )
176+
177+ if not args .skip_deps :
178+ builder .install_dependencies ()
179+ if not args .skip_build :
180+ builder .build ()
181+ archive = None
182+ if not args .skip_package :
183+ archive = builder .package ()
184+ if archive :
185+ print (f"Created artifact: { archive } " )
186+
187+
188+ if __name__ == "__main__" :
189+ main ()
0 commit comments