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

1"""Create macOS application bundles for pipeline GUI launchers.""" 

2 

3import hashlib 

4import plistlib 

5import platform 

6import shlex 

7import shutil 

8import sys 

9import tempfile 

10from pathlib import Path 

11 

12 

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 

82 

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