Skip to content
Closed
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
34 changes: 27 additions & 7 deletions src/launchpad/artifacts/apple/zipped_xcarchive.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,23 @@ def get_app_bundle_path(self) -> Path:
if self._app_bundle_path is not None:
return self._app_bundle_path

for path in self._extract_dir.rglob("*.xcarchive/Products/**/*.app"):
if path.is_dir() and "__MACOSX" not in str(path):
logger.debug(f"Found Apple app bundle: {path}")
return path
# Search patterns for different XCArchive formats
search_patterns = [
# Format 1: MyApp.xcarchive/Products/**/*.app
"*.xcarchive/Products/**/*.app",
# Format 2: Products/Applications/*.app (direct extraction)
"Products/Applications/*.app",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something we want to support? I'm a bit confused with these two cases, why wouldn't there be a top-level .xcarchive wrapping these folders?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 I don't think we should support this, or the one below it. We did in emerge, but now we are normalizing all the artifacts (in the CLI) and we should expect everything to have a well defined structure by this point. This will help us reduce a lot of the bugs we saw with bespoke folder structures in emerge

# Format 3: Products/**/*.app (more general)
"Products/**/*.app",
]

for pattern in search_patterns:
logger.debug(f"Searching for app bundle with pattern: {pattern}")
for path in self._extract_dir.rglob(pattern):
if path.is_dir() and "__MACOSX" not in str(path):
logger.debug(f"Found Apple app bundle: {path}")
self._app_bundle_path = path
return path

raise FileNotFoundError(f"No .app bundle found in {self._extract_dir}")

Expand Down Expand Up @@ -232,10 +245,17 @@ def get_asset_catalog_details(self, relative_path: Path) -> List[AssetCatalogEle
try:
app_bundle_path = self.get_app_bundle_path()
json_name = relative_path.with_suffix(".json")
xcarchive_dir = list(self._extract_dir.glob("*.xcarchive"))[0]
app_bundle_path = app_bundle_path.relative_to(xcarchive_dir)

file_path = xcarchive_dir / "ParsedAssets" / app_bundle_path / json_name
# Handle different XCArchive formats
xcarchive_dirs = list(self._extract_dir.glob("*.xcarchive"))
if xcarchive_dirs:
# Format 1: MyApp.xcarchive/Products/Applications/...
xcarchive_dir = xcarchive_dirs[0]
app_bundle_path = app_bundle_path.relative_to(xcarchive_dir)
file_path = xcarchive_dir / "ParsedAssets" / app_bundle_path / json_name
else:
# Format 2: Products/Applications/... (direct extraction)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, I don't think we should support this case

file_path = self._extract_dir / "ParsedAssets" / app_bundle_path / json_name

if not file_path.exists():
logger.warning(f"Assets.json not found at {file_path}")
Expand Down
70 changes: 44 additions & 26 deletions src/launchpad/artifacts/artifact_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,40 +34,58 @@ def from_path(path: Path) -> Artifact:

# Check if it's a zip file by looking at magic bytes
if content.startswith(b"PK\x03\x04"):
# Check if zip contains a single APK (ZippedAPK)
with ZipFile(BytesIO(content)) as zip_file:
# Check if zip contains a Info.plist in the root of the .xcarchive folder (ZippedXCArchive)
plist_files = [f for f in zip_file.namelist() if f.endswith(".xcarchive/Info.plist")]
if plist_files:
return ZippedXCArchive(path)

apk_files = [f for f in zip_file.namelist() if f.endswith(".apk")]
if len(apk_files) == 1:
return ZippedAPK(path)

aab_files = [f for f in zip_file.namelist() if f.endswith(".aab")]
if len(aab_files) == 1:
return ZippedAAB(path)

# Check if zip contains base/manifest/AndroidManifest.xml (AAB)
manifest_files = [f for f in zip_file.namelist() if f.endswith("base/manifest/AndroidManifest.xml")]
if manifest_files:
return AAB(path)
try:
with ZipFile(BytesIO(content)) as zip_file:
filenames = zip_file.namelist()

# Check if zip contains AndroidManifest.xml (APK)
manifest_files = [f for f in zip_file.namelist() if f.endswith("AndroidManifest.xml")]
if manifest_files:
return APK(path)
# Check for XCArchive (iOS)
if ArtifactFactory._is_xcarchive(filenames):
return ZippedXCArchive(path)

# Check for single APK or AAB files (zipped artifacts)
apk_files = [f for f in filenames if f.endswith(".apk")]
if len(apk_files) == 1:
return ZippedAPK(path)

aab_files = [f for f in filenames if f.endswith(".aab")]
if len(aab_files) == 1:
return ZippedAAB(path)

# Check for AAB (base/manifest structure)
if any(f.endswith("base/manifest/AndroidManifest.xml") for f in filenames):
return AAB(path)

# Check for APK (AndroidManifest.xml)
if any(f.endswith("AndroidManifest.xml") for f in filenames):
return APK(path)

# Check if it's a direct APK or AAB by looking for AndroidManifest.xml in specific locations
except Exception:
pass

# Fallback: try direct APK/AAB detection regardless of magic bytes
try:
with ZipFile(BytesIO(content)) as zip_file:
if any(f.endswith("base/manifest/AndroidManifest.xml") for f in zip_file.namelist()):
filenames = zip_file.namelist()

if any(f.endswith("base/manifest/AndroidManifest.xml") for f in filenames):
return AAB(path)

if any(f.endswith("AndroidManifest.xml") for f in zip_file.namelist()):
if any(f.endswith("AndroidManifest.xml") for f in filenames):
return APK(path)
except Exception:
pass

raise ValueError("Input is not a supported artifact")

@staticmethod
def _is_xcarchive(filenames: list[str]) -> bool:
"""Check if filenames indicate an XCArchive structure."""
# Method 1: .xcarchive/Info.plist pattern
if any(f.endswith(".xcarchive/Info.plist") for f in filenames):
return True

# Method 2: Root Info.plist + Products/Applications structure
has_root_info_plist = "Info.plist" in filenames
has_products_apps = any(f.startswith("Products/Applications/") for f in filenames)

return has_root_info_plist and has_products_apps
49 changes: 49 additions & 0 deletions tests/unit/artifacts/test_artifact_factory.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import zipfile

from pathlib import Path

import pytest
Expand Down Expand Up @@ -77,3 +79,50 @@ def test_factory_raises_value_error_for_invalid_file(tmp_path: Path) -> None:

with pytest.raises(ValueError, match="Input is not a supported artifact"):
ArtifactFactory.from_path(invalid_file)


def test_factory_rejects_empty_zip(tmp_path: Path) -> None:
"""Test that factory rejects completely empty zip files."""
empty_zip = tmp_path / "empty.zip"
with zipfile.ZipFile(empty_zip, "w"):
pass # Create empty zip

with pytest.raises(ValueError, match="Input is not a supported artifact"):
ArtifactFactory.from_path(empty_zip)


def test_factory_rejects_zip_with_only_empty_folders(tmp_path: Path) -> None:
"""Test that factory rejects zip files with only empty directories."""
zip_with_folders = tmp_path / "empty_folders.zip"
with zipfile.ZipFile(zip_with_folders, "w") as zf:
# Add empty directories
zf.writestr("Products/", "")
zf.writestr("Applications/", "")
zf.writestr("dSYMs/", "")

with pytest.raises(ValueError, match="Input is not a supported artifact"):
ArtifactFactory.from_path(zip_with_folders)


def test_factory_rejects_xcarchive_missing_info_plist(tmp_path: Path) -> None:
"""Test that factory rejects XCArchive-like structure missing Info.plist."""
malformed_xcarchive = tmp_path / "no_info_plist.zip"
with zipfile.ZipFile(malformed_xcarchive, "w") as zf:
# Has Products/Applications structure but missing Info.plist
zf.writestr("Products/Applications/MyApp.app/MyApp", "fake binary")
zf.writestr("Products/Applications/MyApp.app/some_file.txt", "content")

with pytest.raises(ValueError, match="Input is not a supported artifact"):
ArtifactFactory.from_path(malformed_xcarchive)


def test_factory_rejects_xcarchive_missing_products_structure(tmp_path: Path) -> None:
"""Test that factory rejects zip with Info.plist but no Products/Applications structure."""
malformed_xcarchive = tmp_path / "no_products.zip"
with zipfile.ZipFile(malformed_xcarchive, "w") as zf:
# Has Info.plist but wrong structure
zf.writestr("Info.plist", "<?xml version='1.0'?><plist></plist>")
zf.writestr("SomeOtherFolder/MyApp.app/MyApp", "fake binary")

with pytest.raises(ValueError, match="Input is not a supported artifact"):
ArtifactFactory.from_path(malformed_xcarchive)