Skip to content

Commit 17d95d4

Browse files
authored
feat(launchpad): Add asset catalog files to zip without adding to folder (#2667)
This improves the app upload experience by making it so assets are written directly to the zip file rather than to the input xcarchive directory. Previously the assets were written to the input folder and then all the files in the folder were zipped. This meant the original location the user passed to the CLI had extra files in it after running the command, which is confusing. There was also a bug that assets were not handled for ipa uploads. IPA files are already zipped, so when we wrote assets to the user's input directory we only did that for xcarchives (Which is a directory not a zip file). So a side effect of not writing images to the users input directory was also to fix this issue for IPAs, and now IPA uploads have assets included too.
1 parent d482c44 commit 17d95d4

File tree

23 files changed

+655
-93
lines changed

23 files changed

+655
-93
lines changed

apple-catalog-parsing/native/swift/AssetCatalogParser/Sources/AssetCatalogParser/AssetCatalogReader.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ import ObjcSupport
77
@_cdecl("swift_inspect_asset_catalog")
88
// Insepects the asset catalog and writes the results to a JSON file
99
// in the xcarchive containing the asset catalog.
10-
public func swift_inspect_asset_catalog(_ path: UnsafePointer<CChar>) {
10+
public func swift_inspect_asset_catalog(_ path: UnsafePointer<CChar>, outputPath: UnsafePointer<CChar>) {
1111
let pathString = String(cString: path)
12+
let outputPathString = String(cString: outputPath)
1213
if #available(macOS 13.0, *) {
1314
let supportedVersions = [13, 14, 15]
1415
let version = ProcessInfo.processInfo.operatingSystemVersion
1516
if supportedVersions.contains(version.majorVersion) {
16-
AssetUtil.disect(file: URL(filePath: pathString))
17+
AssetUtil.disect(file: URL(filePath: pathString), outputURL: URL(filePath: outputPathString))
1718
} else {
1819
print("Skipping asset catalog inspection on unsupported macOS version \(version)")
1920
}
@@ -48,8 +49,8 @@ typealias objectiveCMethodImp = @convention(c) (AnyObject, Selector, UnsafeRawPo
4849
>?
4950

5051
enum AssetUtil {
51-
private static func createResultsPath(assetPath: URL) throws -> URL {
52-
var archiveURL = assetPath
52+
private static func createResultsPath(assetURL: URL, outputURL: URL) throws -> URL {
53+
var archiveURL = assetURL
5354
var tailComponents: [String] = []
5455
while archiveURL.pathExtension != "xcarchive" && archiveURL.pathComponents.count > 1 {
5556
tailComponents.insert(archiveURL.lastPathComponent, at: 0)
@@ -58,19 +59,18 @@ enum AssetUtil {
5859
if archiveURL.pathExtension != "xcarchive" {
5960
throw Error.pathError
6061
}
61-
let parsedRoot = archiveURL.appendingPathComponent("ParsedAssets",
62-
isDirectory: true)
62+
6363
let destDir = tailComponents
6464
.dropLast()
65-
.reduce(parsedRoot) { partial, next in
65+
.reduce(outputURL) { partial, next in
6666
partial.appendingPathComponent(next, isDirectory: true)
6767
}
6868
try! FileManager.default.createDirectory(at: destDir,
6969
withIntermediateDirectories: true)
7070
return destDir
7171
}
7272

73-
@discardableResult static func disect(file: URL) -> [AssetCatalogEntry] {
73+
@discardableResult static func disect(file: URL, outputURL: URL) -> [AssetCatalogEntry] {
7474
var assets: [AssetCatalogEntry] = []
7575
var colorLength: UInt = 0
7676
var colorCount = 0
@@ -154,7 +154,7 @@ enum AssetUtil {
154154
))
155155

156156
let data = try! JSONEncoder().encode(assets)
157-
let folder = try! createResultsPath(assetPath: file)
157+
let folder = try! createResultsPath(assetURL: file, outputURL: outputURL)
158158
let url = folder
159159
.appendingPathComponent("Assets")
160160
.appendingPathExtension("json")

apple-catalog-parsing/native/swift/AssetCatalogParser/Tests/AssetCatalogParserTests/AssetCatalogParserTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ struct AssetCatalogParserTests {
66
@Test func testParseAssets() throws {
77
let archivePath = try #require(Bundle.module.path(forResource: "test", ofType: "xcarchive"))
88
let url = URL(filePath: "\(archivePath)/Products/Applications/DemoApp.app/Assets.car")
9-
let results = AssetUtil.disect(file: url)
9+
let results = AssetUtil.disect(file: url, outputURL: URL(filePath: "\(archivePath)/ParsedAssets"))
1010
#expect(results.count == 2)
1111
}
1212
}

apple-catalog-parsing/src/asset_catalog.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ pub enum Error {
1111
}
1212

1313
extern "C" {
14-
fn swift_inspect_asset_catalog(msg: *const std::os::raw::c_char);
14+
fn swift_inspect_asset_catalog(
15+
catalog_path: *const std::os::raw::c_char,
16+
output_path: *const std::os::raw::c_char,
17+
);
1518
}
1619

1720
/// This calls out to Swift code that uses Apple APIs to convert the contents
@@ -20,17 +23,19 @@ extern "C" {
2023
/// as duplicate image detection, xray, and image optimization insights.
2124
/// The path should be in an xcarchive file, results are written
2225
/// to a JSON file in the xcarchive’s ParsedAssets directory.
23-
pub fn inspect_asset_catalog<P>(path: P) -> Result<(), Error>
26+
pub fn inspect_asset_catalog<P>(catalog_path: P, output_path: P) -> Result<(), Error>
2427
where
2528
P: AsRef<Path>,
2629
{
27-
let c_string = CString::new(path.as_ref().as_os_str().as_bytes())?;
28-
let string_ptr = c_string.as_ptr();
30+
let catalog_c_string = CString::new(catalog_path.as_ref().as_os_str().as_bytes())?;
31+
let output_path_c_string = CString::new(output_path.as_ref().as_os_str().as_bytes())?;
32+
let catalog_string_ptr = catalog_c_string.as_ptr();
33+
let output_string_ptr = output_path_c_string.as_ptr();
2934
unsafe {
3035
// The string pointed to is immutable, in Swift we cannot change it.
3136
// We ensure this by using "UnsafePointer<CChar>" in Swift which is
3237
// immutable (as opposed to "UnsafeMutablePointer<CChar>").
33-
swift_inspect_asset_catalog(string_ptr);
38+
swift_inspect_asset_catalog(catalog_string_ptr, output_string_ptr);
3439
}
3540
Ok(())
3641
}

src/commands/mobile_app/upload.rs

Lines changed: 76 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
use std::borrow::Cow;
2-
#[cfg(not(windows))]
3-
use std::fs;
42
use std::io::Write as _;
5-
#[cfg(not(windows))]
6-
use std::os::unix::fs::PermissionsExt as _;
73
use std::path::Path;
84

95
use anyhow::{anyhow, bail, Context as _, Result};
106
use clap::{Arg, ArgAction, ArgMatches, Command};
117
use indicatif::ProgressStyle;
12-
use itertools::Itertools as _;
138
use log::{debug, info, warn};
149
use symbolic::common::ByteView;
1510
use zip::write::SimpleFileOptions;
@@ -20,14 +15,13 @@ use crate::config::Config;
2015
use crate::utils::args::ArgExt as _;
2116
use crate::utils::chunks::{upload_chunks, Chunk};
2217
use crate::utils::fs::get_sha1_checksums;
23-
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
2418
use crate::utils::fs::TempDir;
2519
use crate::utils::fs::TempFile;
2620
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
2721
use crate::utils::mobile_app::{
2822
handle_asset_catalogs, ipa_to_xcarchive, is_apple_app, is_ipa_file,
2923
};
30-
use crate::utils::mobile_app::{is_aab_file, is_apk_file, is_zip_file};
24+
use crate::utils::mobile_app::{is_aab_file, is_apk_file, is_zip_file, normalize_directory};
3125
use crate::utils::progress::ProgressBar;
3226
use crate::utils::vcs;
3327

@@ -94,19 +88,14 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
9488
let byteview = ByteView::open(path)?;
9589
debug!("Loaded file with {} bytes", byteview.len());
9690

97-
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
98-
if is_apple_app(path) {
99-
handle_asset_catalogs(path);
100-
}
101-
10291
validate_is_mobile_app(path, &byteview)?;
10392

10493
let normalized_zip = if path.is_file() {
10594
debug!("Normalizing file: {}", path.display());
10695
handle_file(path, &byteview)?
10796
} else if path.is_dir() {
10897
debug!("Normalizing directory: {}", path.display());
109-
normalize_directory(path).with_context(|| {
98+
handle_directory(path).with_context(|| {
11099
format!(
111100
"Failed to generate uploadable bundle for directory {}",
112101
path.display()
@@ -196,9 +185,9 @@ fn handle_file(path: &Path, byteview: &ByteView) -> Result<TempFile> {
196185
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
197186
if is_zip_file(byteview) && is_ipa_file(byteview)? {
198187
debug!("Converting IPA file to XCArchive structure");
199-
let temp_dir = TempDir::create()?;
200-
return ipa_to_xcarchive(path, byteview, &temp_dir)
201-
.and_then(|path| normalize_directory(&path))
188+
let archive_temp_dir = TempDir::create()?;
189+
return ipa_to_xcarchive(path, byteview, &archive_temp_dir)
190+
.and_then(|path| handle_directory(&path))
202191
.with_context(|| format!("Failed to process IPA file {}", path.display()));
203192
}
204193

@@ -285,65 +274,13 @@ fn normalize_file(path: &Path, bytes: &[u8]) -> Result<TempFile> {
285274
Ok(temp_file)
286275
}
287276

288-
// For XCArchive directories, we'll zip the entire directory
289-
fn normalize_directory(path: &Path) -> Result<TempFile> {
290-
debug!("Creating normalized zip for directory: {}", path.display());
291-
292-
let temp_file = TempFile::create()?;
293-
let mut zip = ZipWriter::new(temp_file.open()?);
294-
295-
let mut file_count = 0;
296-
297-
// Collect and sort entries for deterministic ordering
298-
// This is important to ensure stable sha1 checksums for the zip file as
299-
// an optimization is used to avoid re-uploading the same chunks if they're already on the server.
300-
let entries = walkdir::WalkDir::new(path)
301-
.follow_links(true)
302-
.into_iter()
303-
.filter_map(Result::ok)
304-
.filter(|entry| entry.path().is_file())
305-
.map(|entry| {
306-
let entry_path = entry.into_path();
307-
let relative_path = entry_path
308-
.strip_prefix(path.parent().ok_or_else(|| {
309-
anyhow!(
310-
"Cannot determine parent directory for path: {}",
311-
path.display()
312-
)
313-
})?)?
314-
.to_owned();
315-
Ok((entry_path, relative_path))
316-
})
317-
.collect::<Result<Vec<_>>>()?
318-
.into_iter()
319-
.sorted_by(|(_, a), (_, b)| a.cmp(b));
320-
321-
// Need to set the last modified time to a fixed value to ensure consistent checksums
322-
// This is important as an optimization to avoid re-uploading the same chunks if they're already on the server
323-
// but the last modified time being different will cause checksums to be different.
324-
let options = SimpleFileOptions::default()
325-
.compression_method(zip::CompressionMethod::Stored)
326-
.last_modified_time(DateTime::default());
327-
328-
for (entry_path, relative_path) in entries {
329-
debug!("Adding file to zip: {}", relative_path.display());
330-
331-
#[cfg(not(windows))]
332-
// On Unix, we need to preserve the file permissions.
333-
let options = options.unix_permissions(fs::metadata(&entry_path)?.permissions().mode());
334-
335-
zip.start_file(relative_path.to_string_lossy(), options)?;
336-
let file_byteview = ByteView::open(&entry_path)?;
337-
zip.write_all(file_byteview.as_slice())?;
338-
file_count += 1;
277+
fn handle_directory(path: &Path) -> Result<TempFile> {
278+
let temp_dir = TempDir::create()?;
279+
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
280+
if is_apple_app(path) {
281+
handle_asset_catalogs(path, temp_dir.path());
339282
}
340-
341-
zip.finish()?;
342-
debug!(
343-
"Successfully created normalized zip for directory with {} files",
344-
file_count
345-
);
346-
Ok(temp_file)
283+
normalize_directory(path, temp_dir.path())
347284
}
348285

349286
/// Returns artifact id if upload was successful.
@@ -456,12 +393,76 @@ mod tests {
456393
fs::create_dir_all(test_dir.join("Products"))?;
457394
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;
458395

459-
let result_zip = normalize_directory(&test_dir)?;
396+
let result_zip = normalize_directory(&test_dir, temp_dir.path())?;
460397
let zip_file = fs::File::open(result_zip.path())?;
461398
let mut archive = ZipArchive::new(zip_file)?;
462399
let file = archive.by_index(0)?;
463400
let file_path = file.name();
464401
assert_eq!(file_path, "MyApp.xcarchive/Products/app.txt");
465402
Ok(())
466403
}
404+
405+
#[test]
406+
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
407+
fn test_xcarchive_upload_includes_parsed_assets() -> Result<()> {
408+
// Test that XCArchive uploads include parsed asset catalogs
409+
let xcarchive_path = Path::new("tests/integration/_fixtures/mobile_app/archive.xcarchive");
410+
411+
// Process the XCArchive directory
412+
let result = handle_directory(xcarchive_path)?;
413+
414+
// Verify the resulting zip contains parsed assets
415+
let zip_file = fs::File::open(result.path())?;
416+
let mut archive = ZipArchive::new(zip_file)?;
417+
418+
let mut has_parsed_assets = false;
419+
for i in 0..archive.len() {
420+
let file = archive.by_index(i)?;
421+
let file_name = file
422+
.enclosed_name()
423+
.ok_or(anyhow!("Failed to get file name"))?;
424+
if file_name.to_string_lossy().contains("ParsedAssets") {
425+
has_parsed_assets = true;
426+
break;
427+
}
428+
}
429+
430+
assert!(
431+
has_parsed_assets,
432+
"XCArchive upload should include parsed asset catalogs"
433+
);
434+
Ok(())
435+
}
436+
437+
#[test]
438+
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
439+
fn test_ipa_upload_includes_parsed_assets() -> Result<()> {
440+
// Test that IPA uploads handle missing asset catalogs gracefully
441+
let ipa_path = Path::new("tests/integration/_fixtures/mobile_app/ipa_with_asset.ipa");
442+
let byteview = ByteView::open(ipa_path)?;
443+
444+
// Process the IPA file - this should work even without asset catalogs
445+
let result = handle_file(ipa_path, &byteview)?;
446+
447+
let zip_file = fs::File::open(result.path())?;
448+
let mut archive = ZipArchive::new(zip_file)?;
449+
450+
let mut has_parsed_assets = false;
451+
for i in 0..archive.len() {
452+
let file = archive.by_index(i)?;
453+
let file_name = file
454+
.enclosed_name()
455+
.ok_or(anyhow!("Failed to get file name"))?;
456+
if file_name.to_string_lossy().contains("ParsedAssets") {
457+
has_parsed_assets = true;
458+
break;
459+
}
460+
}
461+
462+
assert!(
463+
has_parsed_assets,
464+
"XCArchive upload should include parsed asset catalogs"
465+
);
466+
Ok(())
467+
}
467468
}

src/utils/mobile_app/apple.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ use std::io::Cursor;
1212
use walkdir::WalkDir;
1313
use zip::ZipArchive;
1414

15-
pub fn handle_asset_catalogs(path: &Path) {
15+
pub fn handle_asset_catalogs(archive_path: &Path, output_path: &Path) {
1616
// Find all asset catalogs
17-
let cars = find_car_files(path);
17+
let cars = find_car_files(archive_path);
1818
for car in &cars {
19-
if let Err(e) = apple_catalog_parsing::inspect_asset_catalog(car) {
19+
if let Err(e) =
20+
apple_catalog_parsing::inspect_asset_catalog(car, &output_path.to_path_buf())
21+
{
2022
eprintln!("Failed to inspect asset catalog {}: {e}", car.display());
2123
}
2224
}

src/utils/mobile_app/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
44
mod apple;
5+
mod normalize;
56
mod validation;
67

78
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
89
pub use self::apple::{handle_asset_catalogs, ipa_to_xcarchive};
10+
pub use self::normalize::normalize_directory;
911
pub use self::validation::{is_aab_file, is_apk_file, is_zip_file};
1012
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
1113
pub use self::validation::{is_apple_app, is_ipa_file};

0 commit comments

Comments
 (0)