@@ -4,7 +4,7 @@ use std::fs;
44use std:: io:: Write as _;
55#[ cfg( not( windows) ) ]
66use std:: os:: unix:: fs:: PermissionsExt as _;
7- use std:: path:: Path ;
7+ use std:: path:: { Path , PathBuf } ;
88
99use anyhow:: { anyhow, bail, Context as _, Result } ;
1010use clap:: { Arg , ArgAction , ArgMatches , Command } ;
@@ -13,6 +13,7 @@ use itertools::Itertools as _;
1313use log:: { debug, info, warn} ;
1414use sha1_smol:: Digest ;
1515use symbolic:: common:: ByteView ;
16+ use walkdir:: WalkDir ;
1617use zip:: write:: SimpleFileOptions ;
1718use zip:: { DateTime , ZipWriter } ;
1819
@@ -21,7 +22,6 @@ use crate::config::Config;
2122use crate :: utils:: args:: ArgExt as _;
2223use crate :: utils:: chunks:: { upload_chunks, Chunk , ASSEMBLE_POLL_INTERVAL } ;
2324use crate :: utils:: fs:: get_sha1_checksums;
24- #[ cfg( all( target_os = "macos" , target_arch = "aarch64" ) ) ]
2525use crate :: utils:: fs:: TempDir ;
2626use crate :: utils:: fs:: TempFile ;
2727#[ cfg( all( target_os = "macos" , target_arch = "aarch64" ) ) ]
@@ -95,19 +95,14 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
9595 let byteview = ByteView :: open ( path) ?;
9696 debug ! ( "Loaded file with {} bytes" , byteview. len( ) ) ;
9797
98- #[ cfg( all( target_os = "macos" , target_arch = "aarch64" ) ) ]
99- if is_apple_app ( path) {
100- handle_asset_catalogs ( path) ;
101- }
102-
10398 validate_is_mobile_app ( path, & byteview) ?;
10499
105100 let normalized_zip = if path. is_file ( ) {
106101 debug ! ( "Normalizing file: {}" , path. display( ) ) ;
107102 handle_file ( path, & byteview) ?
108103 } else if path. is_dir ( ) {
109104 debug ! ( "Normalizing directory: {}" , path. display( ) ) ;
110- normalize_directory ( path) . with_context ( || {
105+ handle_directory ( path) . with_context ( || {
111106 format ! (
112107 "Failed to generate uploadable bundle for directory {}" ,
113108 path. display( )
@@ -187,9 +182,9 @@ fn handle_file(path: &Path, byteview: &ByteView) -> Result<TempFile> {
187182 #[ cfg( all( target_os = "macos" , target_arch = "aarch64" ) ) ]
188183 if is_zip_file ( byteview) && is_ipa_file ( byteview) ? {
189184 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) )
185+ let archive_temp_dir = TempDir :: create ( ) ?;
186+ return ipa_to_xcarchive ( path, byteview, & archive_temp_dir )
187+ . and_then ( |path| handle_directory ( & path) )
193188 . with_context ( || format ! ( "Failed to process IPA file {}" , path. display( ) ) ) ;
194189 }
195190
@@ -276,38 +271,45 @@ fn normalize_file(path: &Path, bytes: &[u8]) -> Result<TempFile> {
276271 Ok ( temp_file)
277272}
278273
274+ fn sort_entries ( path : & Path ) -> Result < std:: vec:: IntoIter < ( PathBuf , PathBuf ) > > {
275+ Ok ( WalkDir :: new ( path)
276+ . follow_links ( true )
277+ . into_iter ( )
278+ . filter_map ( Result :: ok)
279+ . filter ( |entry| entry. path ( ) . is_file ( ) )
280+ . map ( |entry| {
281+ let entry_path = entry. into_path ( ) ;
282+ let relative_path = entry_path. strip_prefix ( path) ?. to_owned ( ) ;
283+ Ok ( ( entry_path, relative_path) )
284+ } )
285+ . collect :: < Result < Vec < _ > > > ( ) ?
286+ . into_iter ( )
287+ . sorted_by ( |( _, a) , ( _, b) | a. cmp ( b) ) )
288+ }
289+
290+ fn handle_directory ( path : & Path ) -> Result < TempFile > {
291+ let temp_dir = TempDir :: create ( ) ?;
292+ #[ cfg( all( target_os = "macos" , target_arch = "aarch64" ) ) ]
293+ if is_apple_app ( path) {
294+ handle_asset_catalogs ( path, temp_dir. path ( ) ) ;
295+ }
296+ normalize_directory ( path, temp_dir. path ( ) )
297+ }
298+
279299// For XCArchive directories, we'll zip the entire directory
280- fn normalize_directory ( path : & Path ) -> Result < TempFile > {
300+ fn normalize_directory ( path : & Path , parsed_assets_path : & Path ) -> Result < TempFile > {
281301 debug ! ( "Creating normalized zip for directory: {}" , path. display( ) ) ;
282302
283303 let temp_file = TempFile :: create ( ) ?;
284304 let mut zip = ZipWriter :: new ( temp_file. open ( ) ?) ;
285305
286306 let mut file_count = 0 ;
307+ let directory_name = path. file_name ( ) . expect ( "Failed to get basename" ) ;
287308
288309 // Collect and sort entries for deterministic ordering
289310 // This is important to ensure stable sha1 checksums for the zip file as
290311 // 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) ) ;
312+ let entries = sort_entries ( path) ?;
311313
312314 // Need to set the last modified time to a fixed value to ensure consistent checksums
313315 // This is important as an optimization to avoid re-uploading the same chunks if they're already on the server
@@ -317,18 +319,47 @@ fn normalize_directory(path: &Path) -> Result<TempFile> {
317319 . last_modified_time ( DateTime :: default ( ) ) ;
318320
319321 for ( entry_path, relative_path) in entries {
320- debug ! ( "Adding file to zip: {}" , relative_path. display( ) ) ;
322+ let zip_path = format ! (
323+ "{}/{}" ,
324+ directory_name. to_string_lossy( ) ,
325+ relative_path. to_string_lossy( )
326+ ) ;
327+ debug ! ( "Adding file to zip: {}" , zip_path) ;
321328
322329 #[ cfg( not( windows) ) ]
323330 // On Unix, we need to preserve the file permissions.
324331 let options = options. unix_permissions ( fs:: metadata ( & entry_path) ?. permissions ( ) . mode ( ) ) ;
325332
326- zip. start_file ( relative_path . to_string_lossy ( ) , options) ?;
333+ zip. start_file ( zip_path , options) ?;
327334 let file_byteview = ByteView :: open ( & entry_path) ?;
328335 zip. write_all ( file_byteview. as_slice ( ) ) ?;
329336 file_count += 1 ;
330337 }
331338
339+ // Add parsed assets to the zip in a "ParsedAssets" directory
340+ if parsed_assets_path. exists ( ) {
341+ debug ! (
342+ "Adding parsed assets from: {}" ,
343+ parsed_assets_path. display( )
344+ ) ;
345+
346+ let parsed_assets_entries = sort_entries ( parsed_assets_path) ?;
347+
348+ for ( entry_path, relative_path) in parsed_assets_entries {
349+ let zip_path = format ! (
350+ "{}/ParsedAssets/{}" ,
351+ directory_name. to_string_lossy( ) ,
352+ relative_path. to_string_lossy( )
353+ ) ;
354+ debug ! ( "Adding parsed asset to zip: {}" , zip_path) ;
355+
356+ zip. start_file ( zip_path, options) ?;
357+ let file_byteview = ByteView :: open ( & entry_path) ?;
358+ zip. write_all ( file_byteview. as_slice ( ) ) ?;
359+ file_count += 1 ;
360+ }
361+ }
362+
332363 zip. finish ( ) ?;
333364 debug ! (
334365 "Successfully created normalized zip for directory with {} files" ,
@@ -470,12 +501,66 @@ mod tests {
470501 fs:: create_dir_all ( test_dir. join ( "Products" ) ) ?;
471502 fs:: write ( test_dir. join ( "Products" ) . join ( "app.txt" ) , "test content" ) ?;
472503
473- let result_zip = normalize_directory ( & test_dir) ?;
504+ let result_zip = normalize_directory ( & test_dir, temp_dir . path ( ) ) ?;
474505 let zip_file = fs:: File :: open ( result_zip. path ( ) ) ?;
475506 let mut archive = ZipArchive :: new ( zip_file) ?;
476507 let file = archive. by_index ( 0 ) ?;
477508 let file_path = file. name ( ) ;
478509 assert_eq ! ( file_path, "MyApp.xcarchive/Products/app.txt" ) ;
479510 Ok ( ( ) )
480511 }
512+
513+ #[ test]
514+ #[ cfg( all( target_os = "macos" , target_arch = "aarch64" ) ) ]
515+ fn test_xcarchive_upload_includes_parsed_assets ( ) -> Result < ( ) > {
516+ // Test that XCArchive uploads include parsed asset catalogs
517+ let xcarchive_path = Path :: new ( "tests/integration/_fixtures/mobile_app/archive.xcarchive" ) ;
518+
519+ // Process the XCArchive directory
520+ let result = handle_directory ( xcarchive_path) ?;
521+
522+ // Verify the resulting zip contains parsed assets
523+ let zip_file = fs:: File :: open ( result. path ( ) ) ?;
524+ let mut archive = ZipArchive :: new ( zip_file) ?;
525+
526+ let mut has_parsed_assets = false ;
527+ for i in 0 ..archive. len ( ) {
528+ let file = archive. by_index ( i) ?;
529+ let file_name = file. enclosed_name ( ) . ok_or ( anyhow ! ( "Failed to get file name" ) ) ?;
530+ if file_name. to_string_lossy ( ) . contains ( "ParsedAssets" ) {
531+ has_parsed_assets = true ;
532+ break ;
533+ }
534+ }
535+
536+ assert ! ( has_parsed_assets, "XCArchive upload should include parsed asset catalogs" ) ;
537+ Ok ( ( ) )
538+ }
539+
540+ #[ test]
541+ #[ cfg( all( target_os = "macos" , target_arch = "aarch64" ) ) ]
542+ fn test_ipa_upload_includes_parsed_assets ( ) -> Result < ( ) > {
543+ // Test that IPA uploads handle missing asset catalogs gracefully
544+ let ipa_path = Path :: new ( "tests/integration/_fixtures/mobile_app/ipa_with_asset.ipa" ) ;
545+ let byteview = ByteView :: open ( ipa_path) ?;
546+
547+ // Process the IPA file - this should work even without asset catalogs
548+ let result = handle_file ( ipa_path, & byteview) ?;
549+
550+ let zip_file = fs:: File :: open ( result. path ( ) ) ?;
551+ let mut archive = ZipArchive :: new ( zip_file) ?;
552+
553+ let mut has_parsed_assets = false ;
554+ for i in 0 ..archive. len ( ) {
555+ let file = archive. by_index ( i) ?;
556+ let file_name = file. enclosed_name ( ) . ok_or ( anyhow ! ( "Failed to get file name" ) ) ?;
557+ if file_name. to_string_lossy ( ) . contains ( "ParsedAssets" ) {
558+ has_parsed_assets = true ;
559+ break ;
560+ }
561+ }
562+
563+ assert ! ( has_parsed_assets, "XCArchive upload should include parsed asset catalogs" ) ;
564+ Ok ( ( ) )
565+ }
481566}
0 commit comments