Coverage for collectra/pipelines/app_launcher.py: 90.16%
61 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
1"""Create macOS application bundles for pipeline GUI launchers."""
3import hashlib
4import plistlib
5import platform
6import shlex
7import shutil
8import sys
9import tempfile
10from pathlib import Path
13def install_app(
14 pipeline_path: Path,
15 *,
16 name: str = "Collectra",
17 app_dir: Path | None = None,
18 icon: Path | None = None,
19 inputs: tuple[Path, ...] = (),
20 force: bool = False,
21) -> Path:
22 if sys.platform != "darwin":
23 raise RuntimeError("install-app is only supported on macOS")
24 if not name.strip() or name in {".", ".."} or any(c in name for c in "/:\\"):
25 raise ValueError(
26 "App name must be a nonempty filename without slashes or colons"
27 )
28 pipeline_path = Path(pipeline_path).expanduser().resolve()
29 if not pipeline_path.is_file():
30 raise FileNotFoundError(f"Pipeline file not found: {pipeline_path}")
31 icon = (
32 Path(icon).expanduser().resolve()
33 if icon
34 else Path(__file__).resolve().parents[1] / "gui/assets/CollectraLogo.icns"
35 )
36 if icon.suffix.lower() not in {".icns", ".png"} or not icon.is_file():
37 raise ValueError("App icon must be an existing .icns or PNG file")
38 destination = (app_dir or Path.home() / "Applications").expanduser().resolve()
39 bundle = destination / f"{name}.app"
40 if (bundle.exists() or bundle.is_symlink()) and not force:
41 raise FileExistsError(
42 f"Application already exists: {bundle}; use --force to replace it"
43 )
44 arguments = [
45 sys.executable,
46 "-m",
47 "collectra.main",
48 "--pipeline",
49 str(pipeline_path),
50 "gui",
51 ]
52 arguments.extend(str(Path(value).expanduser().resolve()) for value in inputs)
53 # Finder/LaunchServices can otherwise choose a different slice of a
54 # universal Python executable than the terminal used during installation.
55 architecture = platform.machine()
56 if platform.system() == "Darwin" and architecture in {"arm64", "x86_64"}:
57 arguments = ["/usr/bin/arch", f"-{architecture}", *arguments]
58 identifier = hashlib.sha256(str(pipeline_path).encode()).hexdigest()[:16]
59 destination.mkdir(parents=True, exist_ok=True)
60 # Build first so failed icon conversion leaves the installed app intact.
61 staging = Path(tempfile.mkdtemp(prefix=".collectra-app-", dir=destination))
62 new_bundle = staging / bundle.name
63 backup = staging / "previous"
64 try:
65 contents = new_bundle / "Contents"
66 executable_dir = contents / "MacOS"
67 resources = contents / "Resources"
68 executable_dir.mkdir(parents=True)
69 resources.mkdir()
70 launcher = executable_dir / "collectra"
71 launcher.write_text(
72 "#!/bin/sh\n"
73 f"cd {shlex.quote(str(pipeline_path.parent))} || exit 1\n"
74 f"exec {shlex.join(arguments)}\n",
75 encoding="utf-8",
76 )
77 launcher.chmod(0o755)
78 if icon.suffix.lower() == ".icns":
79 shutil.copyfile(icon, resources / "AppIcon.icns")
80 else:
81 from PIL import Image
83 with Image.open(icon) as image:
84 image.convert("RGBA").save(resources / "AppIcon.icns", format="ICNS")
85 with (contents / "Info.plist").open("wb") as stream:
86 plistlib.dump(
87 {
88 "CFBundleName": name,
89 "CFBundleDisplayName": name,
90 "CFBundleIdentifier": f"org.collectra.pipeline.{identifier}",
91 "CFBundleExecutable": "collectra",
92 "CFBundleIconFile": "AppIcon.icns",
93 "CFBundlePackageType": "APPL",
94 "CFBundleVersion": "1",
95 "CFBundleShortVersionString": "1.0",
96 "NSHighResolutionCapable": True,
97 },
98 stream,
99 )
100 if bundle.exists() or bundle.is_symlink():
101 if not force:
102 raise FileExistsError(
103 f"Application already exists: {bundle}; use --force to replace it"
104 )
105 bundle.rename(backup)
106 try:
107 new_bundle.rename(bundle)
108 except Exception:
109 if backup.exists() or backup.is_symlink():
110 backup.rename(bundle)
111 raise
112 finally:
113 shutil.rmtree(staging)
114 return bundle