diff --git a/src/launchpad/artifacts/apple/zipped_xcarchive.py b/src/launchpad/artifacts/apple/zipped_xcarchive.py index 8f4a709b..1313736c 100644 --- a/src/launchpad/artifacts/apple/zipped_xcarchive.py +++ b/src/launchpad/artifacts/apple/zipped_xcarchive.py @@ -1,5 +1,4 @@ import json -import os import plistlib import shutil import subprocess @@ -72,6 +71,15 @@ def __init__(self, path: Path) -> None: self._dsym_info: dict[str, DsymInfo] | None = None self._binary_uuid_cache: dict[Path, str] = {} self._lief_cache: dict[Path, lief.MachO.FatBinary] = {} + self._is_macos_bundle: bool | None = None + + def _is_macos_app_bundle(self) -> bool: + """Check if this is a macOS app bundle (has Contents/ directory structure).""" + if self._is_macos_bundle is not None: + return self._is_macos_bundle + app_bundle_path = self.get_app_bundle_path() + self._is_macos_bundle = (app_bundle_path / "Contents").is_dir() + return self._is_macos_bundle def get_extract_dir(self) -> Path: return self._extract_dir @@ -82,7 +90,10 @@ def get_plist(self) -> dict[str, Any]: return self._plist app_bundle_path = self.get_app_bundle_path() - plist_path = app_bundle_path / "Info.plist" + # macOS apps have Info.plist in Contents/, iOS apps have it directly in the bundle + plist_path = app_bundle_path / "Contents" / "Info.plist" + if not plist_path.exists(): + plist_path = app_bundle_path / "Info.plist" try: with open(plist_path, "rb") as f: @@ -284,6 +295,8 @@ def get_binary_path(self) -> Path | None: if not executable_name: return None + if self._is_macos_app_bundle(): + return app_bundle_path / "Contents" / "MacOS" / executable_name return app_bundle_path / executable_name @sentry_sdk.trace @@ -400,25 +413,42 @@ def _discover_framework_binaries(self, app_bundle_path: Path) -> List[Path]: for framework_path in app_bundle_path.rglob("*.framework"): if framework_path.is_dir(): framework_name = framework_path.stem - framework_binary_path = framework_path / framework_name - if framework_binary_path.exists(): - framework_binaries.append(framework_binary_path) + # macOS frameworks use Versions/A/ or Versions/B/ structure + versions_path = framework_path / "Versions" + if versions_path.is_dir(): + for version_dir in versions_path.iterdir(): + if version_dir.is_dir() and version_dir.name not in ("Current",): + framework_binary_path = version_dir / framework_name + if framework_binary_path.exists(): + framework_binaries.append(framework_binary_path) else: - logger.warning("Framework binary not found", extra={"path": framework_binary_path}) + # iOS frameworks have binary directly in framework + framework_binary_path = framework_path / framework_name + if framework_binary_path.exists(): + framework_binaries.append(framework_binary_path) + else: + logger.warning("Framework binary not found", extra={"path": framework_binary_path}) return framework_binaries def _discover_extension_binaries(self, app_bundle_path: Path) -> List[Path]: extension_binaries: List[Path] = [] for extension_path in app_bundle_path.rglob("*.appex"): if extension_path.is_dir(): - extension_plist_path = extension_path / "Info.plist" + # macOS extensions have Contents/ structure, iOS extensions don't + contents_path = extension_path / "Contents" + if contents_path.is_dir(): + extension_plist_path = contents_path / "Info.plist" + binary_base_path = contents_path / "MacOS" + else: + extension_plist_path = extension_path / "Info.plist" + binary_base_path = extension_path if extension_plist_path.exists(): try: with open(extension_plist_path, "rb") as f: extension_plist = plistlib.load(f) extension_executable = extension_plist.get("CFBundleExecutable") if extension_executable: - extension_binary_path = extension_path / extension_executable + extension_binary_path = binary_base_path / extension_executable if extension_binary_path.exists(): extension_binaries.append(extension_binary_path) else: @@ -466,7 +496,9 @@ def _get_main_binary_path(self) -> Path: main_executable = self.get_plist().get("CFBundleExecutable") if main_executable is None: raise RuntimeError("CFBundleExecutable not found in Info.plist") - return Path(os.path.join(str(app_bundle_path), main_executable)) + if self._is_macos_app_bundle(): + return app_bundle_path / "Contents" / "MacOS" / main_executable + return app_bundle_path / main_executable def _parse_asset_element(self, item: dict[str, Any], parent_path: Path) -> AssetCatalogElement: """Parse a dictionary item into an AssetCatalogElement.""" diff --git a/src/launchpad/size/analyzers/apple.py b/src/launchpad/size/analyzers/apple.py index ac18f765..c5d40cce 100644 --- a/src/launchpad/size/analyzers/apple.py +++ b/src/launchpad/size/analyzers/apple.py @@ -60,6 +60,37 @@ logger = get_logger(__name__) +def _detect_apple_platform(plist: dict[str, Any]) -> str: + """Detect the primary Apple platform from Info.plist. + + Returns "ios", "macos", "tvos", or "watchos". Defaults to "ios" for backwards compatibility. + Mac Catalyst apps are treated as iOS apps. + """ + dt_platform = plist.get("DTPlatformName", "").lower() + supported = plist.get("CFBundleSupportedPlatforms", []) + + if "maccatalyst" in dt_platform or "iphoneos" in dt_platform or "iphonesimulator" in dt_platform: + return "ios" + if "macosx" in dt_platform: + return "macos" + if "appletvos" in dt_platform or "appletvsimulator" in dt_platform: + return "tvos" + if "watchos" in dt_platform or "watchsimulator" in dt_platform: + return "watchos" + + supported_lower = [p.lower() for p in supported] + if "iphoneos" in supported_lower or "iphonesimulator" in supported_lower: + return "ios" + if "macosx" in supported_lower: + return "macos" + if "appletvos" in supported_lower: + return "tvos" + if "watchos" in supported_lower: + return "watchos" + + return "ios" + + class AppleAppAnalyzer: """Analyzer for Apple app bundles (.xcarchive directories).""" @@ -187,7 +218,7 @@ def analyze(self, artifact: AppleArtifact) -> AppleAnalysisResults: treemap_builder = TreemapBuilder( app_name=app_info.name, - platform="ios", + platform=app_info.platform, binary_analysis_map=binary_analysis_map, hermes_reports=hermes_reports, ) @@ -307,6 +338,7 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo: build_date = self.parse_plist_date(archive_plist.get("CreationDate")) supported_platforms = plist.get("CFBundleSupportedPlatforms", []) + platform = _detect_apple_platform(plist) is_simulator = "iphonesimulator" in supported_platforms or plist.get("DTPlatformName") == "iphonesimulator" is_code_signature_valid = False @@ -341,6 +373,7 @@ def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo: executable=plist.get("CFBundleExecutable", "Unknown"), minimum_os_version=plist.get("MinimumOSVersion", "Unknown"), supported_platforms=supported_platforms, + platform=platform, sdk_version=plist.get("DTSDKName"), build_date=build_date, is_simulator=is_simulator, diff --git a/src/launchpad/size/models/apple.py b/src/launchpad/size/models/apple.py index 48823ed6..6c6a2c57 100644 --- a/src/launchpad/size/models/apple.py +++ b/src/launchpad/size/models/apple.py @@ -49,6 +49,7 @@ class AppleAppInfo(BaseAppInfo): executable: str = Field(..., description="Main executable name") minimum_os_version: str = Field(..., description="Minimum app version") supported_platforms: List[str] = Field(default_factory=list, description="Supported platforms") + platform: str = Field(default="ios", description="Primary platform: ios, macos, tvos, watchos") sdk_version: str | None = Field(None, description="App SDK version used for build") build_date: str | None = Field(None, description="Date when the archive was built (ISO format)") is_simulator: bool = Field(False, description="If the app is a simulator build") diff --git a/src/launchpad/size/treemap/treemap_builder.py b/src/launchpad/size/treemap/treemap_builder.py index 35d27c3e..2b9f4c51 100644 --- a/src/launchpad/size/treemap/treemap_builder.py +++ b/src/launchpad/size/treemap/treemap_builder.py @@ -27,6 +27,9 @@ # Platform-specific filesystem block sizes (in bytes) FILESYSTEM_BLOCK_SIZES = { "ios": APPLE_FILESYSTEM_BLOCK_SIZE, + "macos": APPLE_FILESYSTEM_BLOCK_SIZE, + "tvos": APPLE_FILESYSTEM_BLOCK_SIZE, + "watchos": APPLE_FILESYSTEM_BLOCK_SIZE, "android": 4 * 1024, } @@ -35,7 +38,7 @@ class TreemapBuilder: def __init__( self, app_name: str, - platform: Literal["ios", "android"], + platform: Literal["ios", "macos", "tvos", "watchos", "android"], filesystem_block_size: int | None = None, # Optional presentation tweak: collapse one-child directory chains (off by default) compress_paths: bool = False, diff --git a/tests/unit/artifacts/apple/test_zipped_xcarchive.py b/tests/unit/artifacts/apple/test_zipped_xcarchive.py index c3b1d6f7..23d3d4ee 100644 --- a/tests/unit/artifacts/apple/test_zipped_xcarchive.py +++ b/tests/unit/artifacts/apple/test_zipped_xcarchive.py @@ -1,4 +1,5 @@ import json +import plistlib import tempfile from pathlib import Path @@ -145,3 +146,274 @@ def test_framework_bundle_asset_catalog_parsing(self) -> None: assert not wrong_path.exists(), "Image should NOT exist at top-level" assert "MyFramework.bundle" in str(element.full_path) + + +class TestMacOSBundleDetection: + """Test macOS vs iOS bundle structure detection.""" + + def test_is_macos_app_bundle_with_contents_dir(self) -> None: + """Test that apps with Contents/ directory are detected as macOS.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + xcarchive_dir = tmpdir_path / "Test.xcarchive" + app_path = xcarchive_dir / "Products" / "Applications" / "Test.app" + contents_path = app_path / "Contents" + contents_path.mkdir(parents=True) + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + archive._is_macos_bundle = None + archive._app_bundle_path = app_path + + with patch.object(archive, "get_app_bundle_path", return_value=app_path): + assert archive._is_macos_app_bundle() is True + # Test caching + assert archive._is_macos_bundle is True + assert archive._is_macos_app_bundle() is True + + def test_is_ios_app_bundle_without_contents_dir(self) -> None: + """Test that apps without Contents/ directory are detected as iOS.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + xcarchive_dir = tmpdir_path / "Test.xcarchive" + app_path = xcarchive_dir / "Products" / "Applications" / "Test.app" + app_path.mkdir(parents=True) + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + archive._is_macos_bundle = None + archive._app_bundle_path = app_path + + with patch.object(archive, "get_app_bundle_path", return_value=app_path): + assert archive._is_macos_app_bundle() is False + assert archive._is_macos_bundle is False + + +class TestMacOSPlistPaths: + """Test Info.plist path resolution for macOS vs iOS.""" + + def test_get_plist_macos_contents_info_plist(self) -> None: + """Test that macOS apps read Info.plist from Contents/.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + xcarchive_dir = tmpdir_path / "Test.xcarchive" + app_path = xcarchive_dir / "Products" / "Applications" / "Test.app" + contents_path = app_path / "Contents" + contents_path.mkdir(parents=True) + + plist_path = contents_path / "Info.plist" + plist_data = {"CFBundleExecutable": "TestApp", "CFBundleName": "Test"} + with open(plist_path, "wb") as f: + plistlib.dump(plist_data, f) + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + archive._plist = None + archive._app_bundle_path = app_path + + with patch.object(archive, "get_app_bundle_path", return_value=app_path): + plist = archive.get_plist() + assert plist["CFBundleExecutable"] == "TestApp" + assert plist["CFBundleName"] == "Test" + + def test_get_plist_ios_direct_info_plist(self) -> None: + """Test that iOS apps read Info.plist directly from bundle.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + xcarchive_dir = tmpdir_path / "Test.xcarchive" + app_path = xcarchive_dir / "Products" / "Applications" / "Test.app" + app_path.mkdir(parents=True) + + plist_path = app_path / "Info.plist" + plist_data = {"CFBundleExecutable": "iOSApp", "CFBundleName": "iOS Test"} + with open(plist_path, "wb") as f: + plistlib.dump(plist_data, f) + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + archive._plist = None + archive._app_bundle_path = app_path + + with patch.object(archive, "get_app_bundle_path", return_value=app_path): + plist = archive.get_plist() + assert plist["CFBundleExecutable"] == "iOSApp" + assert plist["CFBundleName"] == "iOS Test" + + +class TestMacOSBinaryPaths: + """Test binary path resolution for macOS vs iOS.""" + + def test_get_binary_path_macos(self) -> None: + """Test binary path for macOS apps (Contents/MacOS/).""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + xcarchive_dir = tmpdir_path / "Test.xcarchive" + app_path = xcarchive_dir / "Products" / "Applications" / "Test.app" + macos_path = app_path / "Contents" / "MacOS" + macos_path.mkdir(parents=True) + binary_path = macos_path / "TestApp" + binary_path.write_bytes(b"fake binary") + + plist_path = app_path / "Contents" / "Info.plist" + plist_data = {"CFBundleExecutable": "TestApp"} + with open(plist_path, "wb") as f: + plistlib.dump(plist_data, f) + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + archive._plist = None + archive._is_macos_bundle = None + archive._app_bundle_path = app_path + + with patch.object(archive, "get_app_bundle_path", return_value=app_path): + result = archive.get_binary_path() + assert result == binary_path + assert "Contents/MacOS/TestApp" in str(result) + + def test_get_binary_path_ios(self) -> None: + """Test binary path for iOS apps (directly in bundle).""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + xcarchive_dir = tmpdir_path / "Test.xcarchive" + app_path = xcarchive_dir / "Products" / "Applications" / "Test.app" + app_path.mkdir(parents=True) + binary_path = app_path / "iOSApp" + binary_path.write_bytes(b"fake binary") + + plist_path = app_path / "Info.plist" + plist_data = {"CFBundleExecutable": "iOSApp"} + with open(plist_path, "wb") as f: + plistlib.dump(plist_data, f) + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + archive._plist = None + archive._is_macos_bundle = None + archive._app_bundle_path = app_path + + with patch.object(archive, "get_app_bundle_path", return_value=app_path): + result = archive.get_binary_path() + assert result == binary_path + assert str(result).endswith("Test.app/iOSApp") + + +class TestMacOSFrameworkDiscovery: + """Test framework binary discovery for macOS vs iOS.""" + + def test_discover_macos_framework_with_versions(self) -> None: + """Test macOS frameworks with Versions/A/ structure.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + app_path = tmpdir_path / "Test.app" + frameworks_path = app_path / "Contents" / "Frameworks" + framework_path = frameworks_path / "MyFramework.framework" + versions_path = framework_path / "Versions" / "A" + versions_path.mkdir(parents=True) + + binary_path = versions_path / "MyFramework" + binary_path.write_bytes(b"fake framework binary") + + (framework_path / "Versions" / "Current").mkdir(parents=True) + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + + binaries = archive._discover_framework_binaries(app_path) + assert len(binaries) == 1 + assert binaries[0] == binary_path + assert "Versions/A/MyFramework" in str(binaries[0]) + + def test_discover_ios_framework_direct(self) -> None: + """Test iOS frameworks with binary directly in framework.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + app_path = tmpdir_path / "Test.app" + frameworks_path = app_path / "Frameworks" + framework_path = frameworks_path / "MyFramework.framework" + framework_path.mkdir(parents=True) + + binary_path = framework_path / "MyFramework" + binary_path.write_bytes(b"fake framework binary") + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + + binaries = archive._discover_framework_binaries(app_path) + assert len(binaries) == 1 + assert binaries[0] == binary_path + assert str(binaries[0]).endswith("MyFramework.framework/MyFramework") + + +class TestMacOSExtensionDiscovery: + """Test extension binary discovery for macOS vs iOS.""" + + def test_discover_macos_extension_with_contents(self) -> None: + """Test macOS extensions with Contents/MacOS/ structure.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + app_path = tmpdir_path / "Test.app" + plugins_path = app_path / "Contents" / "PlugIns" + extension_path = plugins_path / "MyExtension.appex" + macos_path = extension_path / "Contents" / "MacOS" + macos_path.mkdir(parents=True) + + binary_path = macos_path / "MyExtension" + binary_path.write_bytes(b"fake extension binary") + + plist_path = extension_path / "Contents" / "Info.plist" + plist_data = {"CFBundleExecutable": "MyExtension"} + with open(plist_path, "wb") as f: + plistlib.dump(plist_data, f) + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + + binaries = archive._discover_extension_binaries(app_path) + assert len(binaries) == 1 + assert binaries[0] == binary_path + assert "Contents/MacOS/MyExtension" in str(binaries[0]) + + def test_discover_ios_extension_direct(self) -> None: + """Test iOS extensions with binary directly in appex.""" + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + app_path = tmpdir_path / "Test.app" + plugins_path = app_path / "PlugIns" + extension_path = plugins_path / "MyExtension.appex" + extension_path.mkdir(parents=True) + + binary_path = extension_path / "MyExtension" + binary_path.write_bytes(b"fake extension binary") + + plist_path = extension_path / "Info.plist" + plist_data = {"CFBundleExecutable": "MyExtension"} + with open(plist_path, "wb") as f: + plistlib.dump(plist_data, f) + + with patch.object(ZippedXCArchive, "__init__", lambda self, path: None): + archive = ZippedXCArchive(Path("dummy")) + archive._extract_dir = tmpdir_path + + binaries = archive._discover_extension_binaries(app_path) + assert len(binaries) == 1 + assert binaries[0] == binary_path + assert str(binaries[0]).endswith("MyExtension.appex/MyExtension") diff --git a/tests/unit/test_apple_basic_info.py b/tests/unit/test_apple_basic_info.py index 8584ee24..b1db9001 100644 --- a/tests/unit/test_apple_basic_info.py +++ b/tests/unit/test_apple_basic_info.py @@ -1,7 +1,9 @@ from pathlib import Path +import pytest + from launchpad.artifacts.apple.zipped_xcarchive import ZippedXCArchive -from launchpad.size.analyzers.apple import AppleAppAnalyzer +from launchpad.size.analyzers.apple import AppleAppAnalyzer, _detect_apple_platform class TestAppleBasicInfo: @@ -19,6 +21,7 @@ def test_basic_info(self, hackernews_xcarchive: Path) -> None: assert basic_info.executable == "HackerNews" assert basic_info.minimum_os_version == "17.5" assert basic_info.supported_platforms == ["iPhoneOS"] + assert basic_info.platform == "ios" assert basic_info.sdk_version == "iphoneos18.4" assert basic_info.build_date is not None assert basic_info.build_date == "2025-05-19T16:15:12" @@ -31,3 +34,56 @@ def test_basic_info(self, hackernews_xcarchive: Path) -> None: assert basic_info.profile_expiration_date == "2025-12-02T18:15:00" assert basic_info.certificate_expiration_date is not None assert basic_info.certificate_expiration_date == "2025-01-01T17:56:11+00:00" + + +class TestDetectApplePlatform: + """Test Apple platform detection from Info.plist.""" + + @pytest.mark.parametrize( + "plist,expected", + [ + # iOS device + ({"DTPlatformName": "iphoneos18.0", "CFBundleSupportedPlatforms": ["iPhoneOS"]}, "ios"), + # iOS simulator + ({"DTPlatformName": "iphonesimulator", "CFBundleSupportedPlatforms": ["iphonesimulator"]}, "ios"), + # Mac Catalyst (treated as iOS) + ({"DTPlatformName": "maccatalyst", "CFBundleSupportedPlatforms": ["iPhoneOS", "MacOSX"]}, "ios"), + # macOS native + ({"DTPlatformName": "macosx14.0", "CFBundleSupportedPlatforms": ["MacOSX"]}, "macos"), + # tvOS + ({"DTPlatformName": "appletvos18.0", "CFBundleSupportedPlatforms": ["AppleTVOS"]}, "tvos"), + # tvOS simulator + ({"DTPlatformName": "appletvsimulator", "CFBundleSupportedPlatforms": ["AppleTVSimulator"]}, "tvos"), + # watchOS + ({"DTPlatformName": "watchos11.0", "CFBundleSupportedPlatforms": ["WatchOS"]}, "watchos"), + # watchOS simulator + ({"DTPlatformName": "watchsimulator", "CFBundleSupportedPlatforms": ["WatchSimulator"]}, "watchos"), + ], + ) + def test_detect_platform_from_dt_platform_name(self, plist: dict, expected: str) -> None: + """Test platform detection using DTPlatformName (primary method).""" + assert _detect_apple_platform(plist) == expected + + @pytest.mark.parametrize( + "plist,expected", + [ + # Fallback to CFBundleSupportedPlatforms when DTPlatformName is missing + ({"CFBundleSupportedPlatforms": ["iPhoneOS"]}, "ios"), + ({"CFBundleSupportedPlatforms": ["iphonesimulator"]}, "ios"), + ({"CFBundleSupportedPlatforms": ["MacOSX"]}, "macos"), + ({"CFBundleSupportedPlatforms": ["AppleTVOS"]}, "tvos"), + ({"CFBundleSupportedPlatforms": ["WatchOS"]}, "watchos"), + ], + ) + def test_detect_platform_fallback_to_supported_platforms(self, plist: dict, expected: str) -> None: + """Test platform detection falls back to CFBundleSupportedPlatforms.""" + assert _detect_apple_platform(plist) == expected + + def test_detect_platform_empty_plist_defaults_to_ios(self) -> None: + """Test that empty plist defaults to iOS for backwards compatibility.""" + assert _detect_apple_platform({}) == "ios" + + def test_detect_platform_unknown_values_default_to_ios(self) -> None: + """Test that unknown platform values default to iOS.""" + plist = {"DTPlatformName": "unknownplatform", "CFBundleSupportedPlatforms": ["UnknownPlatform"]} + assert _detect_apple_platform(plist) == "ios"