Skip to content

Commit 5be8835

Browse files
MOB-45836 App Bundle support (#6)
* Added App Bundle support and github workflow update.
1 parent f0c36bf commit 5be8835

4 files changed

Lines changed: 180 additions & 24 deletions

File tree

.github/workflows/build.yml

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ jobs:
1919
- os: ubuntu-24.04
2020
name: linux
2121
arch: amd64
22+
- os: ubuntu-24.04-arm
23+
name: linux
24+
arch: arm64
2225
- os: windows-latest
2326
name: windows
2427
arch: amd64
@@ -114,14 +117,22 @@ jobs:
114117
echo "Copied Windows AMD64 binary"
115118
fi
116119
if [ -d "artifacts/perfecto-mcp-macos-arm64" ]; then
117-
cp artifacts/perfecto-mcp-macos-arm64/* dist/
118-
chmod +x dist/perfecto-mcp-macos-arm64
119-
echo "Copied macOS ARM64 binary"
120+
cp -r artifacts/perfecto-mcp-macos-arm64/* dist/
121+
if [ -d "dist/perfecto-mcp-arm64.app" ]; then
122+
echo "Found macOS ARM64 .app bundle"
123+
else
124+
chmod +x dist/perfecto-mcp-macos-arm64 2>/dev/null || true
125+
echo "Copied macOS ARM64 binary"
126+
fi
120127
fi
121128
if [ -d "artifacts/perfecto-mcp-macos-amd64" ]; then
122-
cp artifacts/perfecto-mcp-macos-amd64/* dist/
123-
chmod +x dist/perfecto-mcp-macos-amd64
124-
echo "Copied macOS AMD64 binary"
129+
cp -r artifacts/perfecto-mcp-macos-amd64/* dist/
130+
if [ -d "dist/perfecto-mcp-amd64.app" ]; then
131+
echo "Found macOS AMD64 .app bundle"
132+
else
133+
chmod +x dist/perfecto-mcp-macos-amd64 2>/dev/null || true
134+
echo "Copied macOS AMD64 binary"
135+
fi
125136
fi
126137
127138
echo "Final dist/ contents:"

build.py

Lines changed: 135 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"""Build script for creating PyInstaller binary."""
33
import os
44
import platform
5+
import shutil
6+
import subprocess
57
import tomllib
68
from datetime import date
79
from pathlib import Path
@@ -11,6 +13,12 @@
1113
sep = os.pathsep
1214

1315

16+
def clean_build():
17+
build_dir = Path('build')
18+
if build_dir.exists():
19+
shutil.rmtree(build_dir)
20+
21+
1422
def build_version_file():
1523
pyproject = Path(__file__).parent / "pyproject.toml"
1624
with open(pyproject, "rb") as f:
@@ -57,38 +65,150 @@ def build_version_file():
5765
f.write(TEMPLATE.strip())
5866

5967

60-
def build():
61-
"""Build the binary using PyInstaller."""
62-
system = platform.system().lower()
63-
suffix = '.exe' if system == 'windows' else ''
64-
arch = platform.machine().lower()
65-
66-
# Map architecture names to Docker-compatible format
68+
def normalize_architecture(arch: str) -> str:
6769
if arch in ['x86_64', 'amd64']:
68-
arch = 'amd64'
70+
return 'amd64'
6971
elif arch in ['aarch64', 'arm64']:
70-
arch = 'arm64'
72+
return 'arm64'
7173
elif arch.startswith('arm'):
72-
arch = 'arm64' # Assume ARM64 for Docker compatibility
74+
return 'arm64'
75+
return arch
7376

74-
system = "macos" if system == 'darwin' else system
75-
name = f'perfecto-mcp-{system}-{arch}{suffix}'
7677

77-
icon = 'app.ics' if system == 'macos' else 'app.ico'
78+
def normalize_system_name(system: str) -> str:
79+
return "macos" if system == 'darwin' else system
80+
81+
82+
def get_binary_name(system: str, arch: str) -> str:
83+
suffix = '.exe' if system == 'windows' else ''
84+
return f'perfecto-mcp-{system}-{arch}{suffix}'
85+
7886

87+
def get_icon_file(system: str) -> str:
88+
return 'app.icns' if system == 'macos' else 'app.ico'
89+
90+
91+
def run_pyinstaller(name: str, icon: str):
7992
PyInstaller.__main__.run([
8093
'main.py',
8194
'--onefile',
8295
'--version-file=version_info.txt',
8396
f'--add-data=pyproject.toml{sep}.',
84-
f'--add-data=resources/app.png{sep}resources',
97+
f'--add-data=resources{sep}resources',
8598
f'--name={name}',
8699
f'--icon={icon}',
87100
'--clean',
88101
'--noconfirm',
89102
])
90103

91104

105+
def build():
106+
clean_build()
107+
108+
system = normalize_system_name(platform.system().lower())
109+
arch = normalize_architecture(platform.machine().lower())
110+
name = get_binary_name(system, arch)
111+
icon = get_icon_file(system)
112+
113+
run_pyinstaller(name, icon)
114+
clean_build()
115+
116+
if system == "macos":
117+
create_app_bundle(name, arch, dist_dir=Path("dist"))
118+
elif system == "linux":
119+
create_sha256_checksum(name, dist_dir=Path("dist"))
120+
121+
122+
def create_app_directory_structure(app_path: Path) -> Path:
123+
macos_path = app_path / "Contents" / "MacOS"
124+
macos_path.mkdir(parents=True, exist_ok=True)
125+
return macos_path
126+
127+
128+
def copy_binary_to_app(binary_path: Path, target_path: Path):
129+
if not binary_path.exists():
130+
raise FileNotFoundError(f"Binary not found: {binary_path}")
131+
shutil.copy2(binary_path, target_path)
132+
os.chmod(target_path, 0o755)
133+
134+
135+
def create_launcher_script(launcher_path: Path):
136+
launcher_content = """#!/bin/bash
137+
set -e
138+
139+
BIN_DIR="$(cd "$(dirname "$0")" && pwd)"
140+
BIN="$BIN_DIR/perfecto-mcp"
141+
142+
if [ -t 1 ]; then
143+
exec "$BIN" "$@"
144+
else
145+
open -a Terminal "$BIN"
146+
fi
147+
"""
148+
with open(launcher_path, "w", encoding="utf-8") as f:
149+
f.write(launcher_content)
150+
os.chmod(launcher_path, 0o755)
151+
152+
153+
def create_info_plist(plist_path: Path):
154+
info_plist_content = """<?xml version="1.0" encoding="UTF-8"?>
155+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
156+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
157+
<plist version="1.0">
158+
<dict>
159+
<key>CFBundleExecutable</key>
160+
<string>launcher.sh</string>
161+
162+
<key>CFBundleIdentifier</key>
163+
<string>com.perfecto.mcp</string>
164+
165+
<key>CFBundleName</key>
166+
<string>Perfecto MCP</string>
167+
168+
<key>CFBundlePackageType</key>
169+
<string>APPL</string>
170+
</dict>
171+
</plist>
172+
"""
173+
with open(plist_path, "w", encoding="utf-8") as f:
174+
f.write(info_plist_content)
175+
176+
177+
def create_app_bundle(binary_name: str, arch: str, dist_dir: Path):
178+
app_name = f"perfecto-mcp-{arch}.app"
179+
app_path = dist_dir / app_name
180+
contents_path = app_path / "Contents"
181+
182+
macos_path = create_app_directory_structure(app_path)
183+
184+
binary_path = dist_dir / binary_name
185+
copy_binary_to_app(binary_path, macos_path / "perfecto-mcp")
186+
187+
create_launcher_script(macos_path / "launcher.sh")
188+
create_info_plist(contents_path / "Info.plist")
189+
190+
binary_path.unlink()
191+
print(f"Created {app_name} in {dist_dir}")
192+
193+
194+
def create_sha256_checksum(binary_name: str, dist_dir: Path):
195+
binary_path = dist_dir / binary_name
196+
checksum_path = dist_dir / f"{binary_name}.sha256"
197+
198+
if not binary_path.exists():
199+
raise FileNotFoundError(f"Binary not found: {binary_path}")
200+
201+
with open(checksum_path, "w") as f:
202+
subprocess.run(
203+
["sha256sum", binary_name],
204+
cwd=dist_dir,
205+
stdout=f,
206+
check=True,
207+
)
208+
209+
print(f"Created {checksum_path.name} in {dist_dir}")
210+
211+
92212
if __name__ == "__main__":
93213
build_version_file()
94-
build()
214+
build()

config/version.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import importlib.metadata
22
import os
3+
import subprocess
34
import sys
45
import tomllib
56
from pathlib import Path
@@ -27,9 +28,26 @@ def get_executable():
2728
else:
2829
return os.path.join(os.path.abspath(Path(__file__).parent.parent), "main.py")
2930

31+
32+
def get_bundle_executable():
33+
executable_path = os.path.realpath(get_executable())
34+
if sys.platform == "darwin":
35+
translocated_path = executable_path
36+
result = subprocess.check_output(
37+
['/usr/bin/security', 'translocate-original-path', translocated_path],
38+
stderr=subprocess.STDOUT
39+
)
40+
original_path = result.decode('utf-8').split('\n')[-2].strip()
41+
42+
return os.path.realpath(os.path.join(original_path, "..", "..", ".."))
43+
else:
44+
return executable_path
45+
46+
3047
def is_uvx():
3148
return "\\uv\\cache\\" in sys.prefix
3249

3350
__version__ = get_version()
3451
__executable__ = get_executable()
52+
__bundle__ = get_bundle_executable()
3553
__uvx__ = is_uvx()

main.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from config.perfecto import SECURITY_TOKEN_FILE_ENV_NAME, SECURITY_TOKEN_ENV_NAME, PERFECTO_CLOUD_NAME_ENV_NAME, \
1111
GITHUB
1212
from config.token import PerfectoToken, PerfectoTokenError
13-
from config.version import __version__, __executable__, __uvx__, get_version
13+
from config.version import __version__, __executable__, __bundle__, __uvx__, get_version
1414
from server import register_tools
1515

1616
PERFECTO_SECURITY_TOKEN_FILE_NAME = "perfecto-security-token.txt"
@@ -38,7 +38,10 @@ def get_token() -> PerfectoToken:
3838
is_docker = os.getenv('MCP_DOCKER', 'false').lower() == 'true'
3939
token = None
4040

41-
local_security_token_file = os.path.join(os.path.dirname(__executable__), PERFECTO_SECURITY_TOKEN_FILE_NAME)
41+
if sys.platform == "darwin" and __bundle__.endswith(".app"):
42+
local_security_token_file = os.path.join(os.path.dirname(__bundle__), PERFECTO_SECURITY_TOKEN_FILE_NAME)
43+
else:
44+
local_security_token_file = os.path.join(os.path.dirname(__executable__), PERFECTO_SECURITY_TOKEN_FILE_NAME)
4245
if not PERFECTO_SECURITY_TOKEN_FILE_PATH and os.path.exists(local_security_token_file):
4346
PERFECTO_SECURITY_TOKEN_FILE_PATH = local_security_token_file
4447

@@ -119,7 +122,11 @@ def main():
119122
else:
120123
perfecto_environment_str = f"{PERFECTO_CLOUD_NAME}"
121124

122-
command = "uvx" if __uvx__ else __executable__
125+
if sys.platform == "darwin" and __bundle__.endswith(".app"):
126+
command_path = os.path.join(__bundle__, "Contents", "MacOS", "perfecto-mcp")
127+
else:
128+
command_path = __executable__
129+
command = "uvx" if __uvx__ else command_path
123130
args = ["--mcp"]
124131
if __uvx__:
125132
args = [

0 commit comments

Comments
 (0)