Skip to content

Commit 4384970

Browse files
Added cross-platform build script
Tested on macOS
1 parent 587bbd0 commit 4384970

1 file changed

Lines changed: 189 additions & 0 deletions

File tree

build.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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

Comments
 (0)