Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions src/launchpad/artifacts/apple/zipped_xcarchive.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import json
import os
import plistlib
import shutil
import subprocess
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
35 changes: 34 additions & 1 deletion src/launchpad/size/analyzers/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""

Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/launchpad/size/models/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion src/launchpad/size/treemap/treemap_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -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,
Expand Down
Loading
Loading