Skip to content

Commit 5f58d61

Browse files
committed
Add asset catalog files to zip without adding to folder
1 parent 937ff98 commit 5f58d61

File tree

22 files changed

+565
-49
lines changed

22 files changed

+565
-49
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: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::fs;
44
use std::io::Write as _;
55
#[cfg(not(windows))]
66
use std::os::unix::fs::PermissionsExt as _;
7-
use std::path::Path;
7+
use std::path::{Path, PathBuf};
88

99
use anyhow::{anyhow, bail, Context as _, Result};
1010
use clap::{Arg, ArgAction, ArgMatches, Command};
@@ -13,6 +13,7 @@ use itertools::Itertools as _;
1313
use log::{debug, info, warn};
1414
use sha1_smol::Digest;
1515
use symbolic::common::ByteView;
16+
use walkdir::WalkDir;
1617
use zip::write::SimpleFileOptions;
1718
use zip::{DateTime, ZipWriter};
1819

@@ -21,7 +22,6 @@ use crate::config::Config;
2122
use crate::utils::args::ArgExt as _;
2223
use crate::utils::chunks::{upload_chunks, Chunk, ASSEMBLE_POLL_INTERVAL};
2324
use crate::utils::fs::get_sha1_checksums;
24-
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
2525
use crate::utils::fs::TempDir;
2626
use crate::utils::fs::TempFile;
2727
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
@@ -95,9 +95,10 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
9595
let byteview = ByteView::open(path)?;
9696
debug!("Loaded file with {} bytes", byteview.len());
9797

98+
let temp_dir = TempDir::create()?;
9899
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
99100
if is_apple_app(path) {
100-
handle_asset_catalogs(path);
101+
handle_asset_catalogs(path, temp_dir.path());
101102
}
102103

103104
validate_is_mobile_app(path, &byteview)?;
@@ -107,7 +108,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
107108
handle_file(path, &byteview)?
108109
} else if path.is_dir() {
109110
debug!("Normalizing directory: {}", path.display());
110-
normalize_directory(path).with_context(|| {
111+
normalize_directory(path, temp_dir.path()).with_context(|| {
111112
format!(
112113
"Failed to generate uploadable bundle for directory {}",
113114
path.display()
@@ -187,9 +188,10 @@ fn handle_file(path: &Path, byteview: &ByteView) -> Result<TempFile> {
187188
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
188189
if is_zip_file(byteview) && is_ipa_file(byteview)? {
189190
debug!("Converting IPA file to XCArchive structure");
190-
let temp_dir = TempDir::create()?;
191-
return ipa_to_xcarchive(path, byteview, &temp_dir)
192-
.and_then(|path| normalize_directory(&path))
191+
let archive_temp_dir = TempDir::create()?;
192+
let assets_temp_dir = TempDir::create()?;
193+
return ipa_to_xcarchive(path, byteview, &archive_temp_dir)
194+
.and_then(|path| normalize_directory(&path, assets_temp_dir.path()))
193195
.with_context(|| format!("Failed to process IPA file {}", path.display()));
194196
}
195197

@@ -276,38 +278,36 @@ fn normalize_file(path: &Path, bytes: &[u8]) -> Result<TempFile> {
276278
Ok(temp_file)
277279
}
278280

281+
fn sort_entries(path: &Path) -> Result<std::vec::IntoIter<(PathBuf, PathBuf)>> {
282+
Ok(WalkDir::new(path)
283+
.follow_links(true)
284+
.into_iter()
285+
.filter_map(Result::ok)
286+
.filter(|entry| entry.path().is_file())
287+
.map(|entry| {
288+
let entry_path = entry.into_path();
289+
let relative_path = entry_path.strip_prefix(path)?.to_owned();
290+
Ok((entry_path, relative_path))
291+
})
292+
.collect::<Result<Vec<_>>>()?
293+
.into_iter()
294+
.sorted_by(|(_, a), (_, b)| a.cmp(b)))
295+
}
296+
279297
// For XCArchive directories, we'll zip the entire directory
280-
fn normalize_directory(path: &Path) -> Result<TempFile> {
298+
fn normalize_directory(path: &Path, parsed_assets_path: &Path) -> Result<TempFile> {
281299
debug!("Creating normalized zip for directory: {}", path.display());
282300

283301
let temp_file = TempFile::create()?;
284302
let mut zip = ZipWriter::new(temp_file.open()?);
285303

286304
let mut file_count = 0;
305+
let directory_name = path.file_name().expect("Failed to get basename");
287306

288307
// Collect and sort entries for deterministic ordering
289308
// This is important to ensure stable sha1 checksums for the zip file as
290309
// an optimization is used to avoid re-uploading the same chunks if they're already on the server.
291-
let entries = walkdir::WalkDir::new(path)
292-
.follow_links(true)
293-
.into_iter()
294-
.filter_map(Result::ok)
295-
.filter(|entry| entry.path().is_file())
296-
.map(|entry| {
297-
let entry_path = entry.into_path();
298-
let relative_path = entry_path
299-
.strip_prefix(path.parent().ok_or_else(|| {
300-
anyhow!(
301-
"Cannot determine parent directory for path: {}",
302-
path.display()
303-
)
304-
})?)?
305-
.to_owned();
306-
Ok((entry_path, relative_path))
307-
})
308-
.collect::<Result<Vec<_>>>()?
309-
.into_iter()
310-
.sorted_by(|(_, a), (_, b)| a.cmp(b));
310+
let entries = sort_entries(path)?;
311311

312312
// Need to set the last modified time to a fixed value to ensure consistent checksums
313313
// This is important as an optimization to avoid re-uploading the same chunks if they're already on the server
@@ -317,18 +317,47 @@ fn normalize_directory(path: &Path) -> Result<TempFile> {
317317
.last_modified_time(DateTime::default());
318318

319319
for (entry_path, relative_path) in entries {
320-
debug!("Adding file to zip: {}", relative_path.display());
320+
let zip_path = format!(
321+
"{}/{}",
322+
directory_name.to_string_lossy(),
323+
relative_path.to_string_lossy()
324+
);
325+
debug!("Adding file to zip: {}", zip_path);
321326

322327
#[cfg(not(windows))]
323328
// On Unix, we need to preserve the file permissions.
324329
let options = options.unix_permissions(fs::metadata(&entry_path)?.permissions().mode());
325330

326-
zip.start_file(relative_path.to_string_lossy(), options)?;
331+
zip.start_file(zip_path, options)?;
327332
let file_byteview = ByteView::open(&entry_path)?;
328333
zip.write_all(file_byteview.as_slice())?;
329334
file_count += 1;
330335
}
331336

337+
// Add parsed assets to the zip in a "ParsedAssets" directory
338+
if parsed_assets_path.exists() {
339+
debug!(
340+
"Adding parsed assets from: {}",
341+
parsed_assets_path.display()
342+
);
343+
344+
let parsed_assets_entries = sort_entries(parsed_assets_path)?;
345+
346+
for (entry_path, relative_path) in parsed_assets_entries {
347+
let zip_path = format!(
348+
"{}/ParsedAssets/{}",
349+
directory_name.to_string_lossy(),
350+
relative_path.to_string_lossy()
351+
);
352+
debug!("Adding parsed asset to zip: {}", zip_path);
353+
354+
zip.start_file(zip_path, options)?;
355+
let file_byteview = ByteView::open(&entry_path)?;
356+
zip.write_all(file_byteview.as_slice())?;
357+
file_count += 1;
358+
}
359+
}
360+
332361
zip.finish()?;
333362
debug!(
334363
"Successfully created normalized zip for directory with {} files",
@@ -470,7 +499,7 @@ mod tests {
470499
fs::create_dir_all(test_dir.join("Products"))?;
471500
fs::write(test_dir.join("Products").join("app.txt"), "test content")?;
472501

473-
let result_zip = normalize_directory(&test_dir)?;
502+
let result_zip = normalize_directory(&test_dir, temp_dir.path())?;
474503
let zip_file = fs::File::open(result_zip.path())?;
475504
let mut archive = ZipArchive::new(zip_file)?;
476505
let file = archive.by_index(0)?;

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
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
```
2+
$ sentry-cli mobile-app upload tests/integration/_fixtures/mobile_app/archive.xcarchive
3+
? success
4+
[..]WARN[..]EXPERIMENTAL: The mobile-app subcommand is experimental. The command is subject to breaking changes and may be removed without notice in any release.
5+
Nothing to upload, all files are on the server
6+
Successfully uploaded 1 file to Sentry
7+
- tests/integration/_fixtures/mobile_app/archive.xcarchive
8+
9+
```
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>ApplicationProperties</key>
6+
<dict>
7+
<key>ApplicationPath</key>
8+
<string>Applications/DemoApp.app</string>
9+
<key>Architectures</key>
10+
<array>
11+
<string>arm64</string>
12+
</array>
13+
<key>CFBundleIdentifier</key>
14+
<string>com.emerge.DemoApp</string>
15+
<key>CFBundleShortVersionString</key>
16+
<string>1.0</string>
17+
<key>CFBundleVersion</key>
18+
<string>1</string>
19+
<key>SigningIdentity</key>
20+
<string>Apple Development: Noah Martin (5TV9RX846L)</string>
21+
<key>Team</key>
22+
<string>62J2XHNK9T</string>
23+
</dict>
24+
<key>ArchiveVersion</key>
25+
<integer>2</integer>
26+
<key>CreationDate</key>
27+
<date>2025-07-31T20:04:22Z</date>
28+
<key>Name</key>
29+
<string>DemoApp</string>
30+
<key>SchemeName</key>
31+
<string>DemoApp</string>
32+
</dict>
33+
</plist>
9.8 KB
Loading
Loading

0 commit comments

Comments
 (0)