Skip to content

Commit 1e67c3f

Browse files
noahsmartincursoragentszokeasaurusrex
authored
feat(mobileapp): Update mobile app command for ipa uploads (#2619)
Add support for uploading iOS .ipa files to the `mobile-app` command by converting them to xcarchive format. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com>
1 parent e663a52 commit 1e67c3f

File tree

10 files changed

+311
-14
lines changed

10 files changed

+311
-14
lines changed

src/commands/mobile_app/upload.rs

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,28 @@ use crate::config::Config;
2121
use crate::utils::args::ArgExt as _;
2222
use crate::utils::chunks::{upload_chunks, Chunk, ASSEMBLE_POLL_INTERVAL};
2323
use crate::utils::fs::get_sha1_checksums;
24+
#[cfg(target_os = "macos")]
25+
use crate::utils::fs::TempDir;
2426
use crate::utils::fs::TempFile;
2527
#[cfg(target_os = "macos")]
26-
use crate::utils::mobile_app::handle_asset_catalogs;
28+
use crate::utils::mobile_app::{handle_asset_catalogs, ipa_to_xcarchive, is_ipa_file};
2729
use crate::utils::mobile_app::{is_aab_file, is_apk_file, is_apple_app, is_zip_file};
2830
use crate::utils::progress::ProgressBar;
2931
use crate::utils::vcs;
3032

3133
pub fn make_command(command: Command) -> Command {
34+
#[cfg(target_os = "macos")]
35+
const HELP_TEXT: &str = "The path to the mobile app files to upload. Supported files include Apk, Aab, XCArchive, and IPA.";
36+
#[cfg(not(target_os = "macos"))]
37+
const HELP_TEXT: &str = "The path to the mobile app files to upload. Supported files include Apk, Aab, and XCArchive.";
3238
command
3339
.about("[EXPERIMENTAL] Upload mobile app files to a project.")
3440
.org_arg()
3541
.project_arg(false)
3642
.arg(
3743
Arg::new("paths")
3844
.value_name("PATH")
39-
.help("The path to the mobile app files to upload. Supported files include Apk, Aab or XCArchive.")
45+
.help(HELP_TEXT)
4046
.num_args(1..)
4147
.action(ArgAction::Append)
4248
.required(true),
@@ -95,12 +101,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
95101

96102
let normalized_zip = if path.is_file() {
97103
debug!("Normalizing file: {}", path.display());
98-
normalize_file(path, &byteview).with_context(|| {
99-
format!(
100-
"Failed to generate uploadable bundle for file {}",
101-
path.display()
102-
)
103-
})?
104+
handle_file(path, &byteview)?
104105
} else if path.is_dir() {
105106
debug!("Normalizing directory: {}", path.display());
106107
normalize_directory(path).with_context(|| {
@@ -178,6 +179,25 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
178179
Ok(())
179180
}
180181

182+
fn handle_file(path: &Path, byteview: &ByteView) -> Result<TempFile> {
183+
// Handle IPA files by converting them to XCArchive
184+
#[cfg(target_os = "macos")]
185+
if is_zip_file(byteview) && is_ipa_file(byteview)? {
186+
debug!("Converting IPA file to XCArchive structure");
187+
let temp_dir = TempDir::create()?;
188+
return ipa_to_xcarchive(path, byteview, &temp_dir)
189+
.and_then(|path| normalize_directory(&path))
190+
.with_context(|| format!("Failed to process IPA file {}", path.display()));
191+
}
192+
193+
normalize_file(path, byteview).with_context(|| {
194+
format!(
195+
"Failed to generate uploadable bundle for file {}",
196+
path.display()
197+
)
198+
})
199+
}
200+
181201
fn validate_is_mobile_app(path: &Path, bytes: &[u8]) -> Result<()> {
182202
debug!("Validating mobile app format for: {}", path.display());
183203

@@ -186,9 +206,13 @@ fn validate_is_mobile_app(path: &Path, bytes: &[u8]) -> Result<()> {
186206
return Ok(());
187207
}
188208

189-
// Check if the file is a zip file (then AAB or APK)
209+
// Check if the file is a zip file (then AAB, APK, or IPA)
190210
if is_zip_file(bytes) {
211+
#[cfg(target_os = "macos")]
212+
debug!("File is a zip, checking for AAB/APK/IPA format");
213+
#[cfg(not(target_os = "macos"))]
191214
debug!("File is a zip, checking for AAB/APK format");
215+
192216
if is_aab_file(bytes)? {
193217
debug!("Detected AAB file");
194218
return Ok(());
@@ -198,11 +222,22 @@ fn validate_is_mobile_app(path: &Path, bytes: &[u8]) -> Result<()> {
198222
debug!("Detected APK file");
199223
return Ok(());
200224
}
225+
226+
#[cfg(target_os = "macos")]
227+
if is_ipa_file(bytes)? {
228+
debug!("Detected IPA file");
229+
return Ok(());
230+
}
201231
}
202232

203233
debug!("File format validation failed");
234+
#[cfg(target_os = "macos")]
235+
let format_list = "APK, AAB, XCArchive, or IPA";
236+
#[cfg(not(target_os = "macos"))]
237+
let format_list = "APK, AAB, or XCArchive";
238+
204239
Err(anyhow!(
205-
"File is not a recognized mobile app format (APK, AAB, or XCArchive): {}",
240+
"File is not a recognized mobile app format ({format_list}): {}",
206241
path.display()
207242
))
208243
}

src/utils/mobile_app/apple.rs

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
use std::path::{Path, PathBuf};
1+
use anyhow::{anyhow, Result};
2+
use log::debug;
3+
use regex::Regex;
4+
use std::{
5+
path::{Path, PathBuf},
6+
sync::LazyLock,
7+
};
28

9+
use crate::utils::fs::TempDir;
310
use apple_catalog_parsing;
11+
use std::io::Cursor;
412
use walkdir::WalkDir;
13+
use zip::ZipArchive;
514

615
pub fn handle_asset_catalogs(path: &Path) {
716
// Find all asset catalogs
@@ -27,3 +36,123 @@ fn find_car_files(root: &Path) -> Vec<PathBuf> {
2736
.map(|e| e.into_path())
2837
.collect()
2938
}
39+
40+
/// Converts an IPA file to an XCArchive directory structure. The provided IPA must be a valid IPA file.
41+
///
42+
/// # Format Overview
43+
///
44+
/// ## IPA (iOS App Store Package)
45+
/// An IPA file is a compressed archive containing an iOS app ready for distribution.
46+
/// It has the following structure:
47+
/// ```
48+
/// MyApp.ipa
49+
/// └── Payload/
50+
/// └── MyApp.app/
51+
/// ├── Info.plist
52+
/// ├── MyApp (executable)
53+
/// ├── Assets.car
54+
/// └── ... (other app resources)
55+
/// ```
56+
///
57+
/// ## XCArchive (Xcode Archive)
58+
/// An XCArchive is a directory structure created by Xcode when archiving an app for distribution.
59+
/// It has the following structure:
60+
/// ```
61+
/// MyApp.xcarchive/
62+
/// ├── Info.plist
63+
/// ├── Products/
64+
/// │ └── Applications/
65+
/// │ └── MyApp.app/
66+
/// │ ├── Info.plist
67+
/// │ ├── MyApp (executable)
68+
/// │ ├── Assets.car
69+
/// │ └── ... (other app resources)
70+
/// └── ... (other archive metadata)
71+
/// ```
72+
pub fn ipa_to_xcarchive(ipa_path: &Path, ipa_bytes: &[u8], temp_dir: &TempDir) -> Result<PathBuf> {
73+
debug!(
74+
"Converting IPA to XCArchive structure: {}",
75+
ipa_path.display()
76+
);
77+
78+
let xcarchive_dir = temp_dir.path().join("archive.xcarchive");
79+
let products_dir = xcarchive_dir.join("Products");
80+
let applications_dir = products_dir.join("Applications");
81+
82+
debug!("Creating XCArchive directory structure");
83+
std::fs::create_dir_all(&applications_dir)?;
84+
85+
// Extract IPA file
86+
let cursor = Cursor::new(ipa_bytes);
87+
let mut ipa_archive = ZipArchive::new(cursor)?;
88+
89+
let app_name = extract_app_name_from_ipa(&ipa_archive)?.to_owned();
90+
91+
// Extract all files from the archive
92+
for i in 0..ipa_archive.len() {
93+
let mut file = ipa_archive.by_index(i)?;
94+
95+
if let Some(name) = file.enclosed_name() {
96+
if let Ok(stripped) = name.strip_prefix("Payload/") {
97+
if !file.is_dir() {
98+
// Create the file path in the XCArchive structure
99+
let target_path = applications_dir.join(stripped);
100+
101+
// Create parent directories if necessary
102+
if let Some(parent) = target_path.parent() {
103+
std::fs::create_dir_all(parent)?;
104+
}
105+
106+
// Extract file
107+
let mut target_file = std::fs::File::create(&target_path)?;
108+
std::io::copy(&mut file, &mut target_file)?;
109+
}
110+
}
111+
}
112+
}
113+
114+
// Create Info.plist for XCArchive
115+
let info_plist_path = xcarchive_dir.join("Info.plist");
116+
117+
let info_plist_content = format!(
118+
r#"<?xml version="1.0" encoding="UTF-8"?>
119+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
120+
<plist version="1.0">
121+
<dict>
122+
<key>ApplicationProperties</key>
123+
<dict>
124+
<key>ApplicationPath</key>
125+
<string>Applications/{app_name}.app</string>
126+
</dict>
127+
<key>ArchiveVersion</key>
128+
<integer>1</integer>
129+
</dict>
130+
</plist>"#
131+
);
132+
133+
std::fs::write(&info_plist_path, info_plist_content)?;
134+
135+
debug!(
136+
"Created XCArchive Info.plist at: {}",
137+
info_plist_path.display()
138+
);
139+
Ok(xcarchive_dir)
140+
}
141+
142+
static PATTERN: LazyLock<Regex> =
143+
LazyLock::new(|| Regex::new(r"^Payload/([^/]+)\.app/Info\.plist$").expect("regex is valid"));
144+
145+
fn extract_app_name_from_ipa<'a>(archive: &'a ZipArchive<Cursor<&[u8]>>) -> Result<&'a str> {
146+
let matches = archive
147+
.file_names()
148+
.filter_map(|name| PATTERN.captures(name))
149+
.map(|c| c.get(1).expect("group 1 must be present").as_str())
150+
.take(2) // If there are ≥2 matches, we already know the IPA is invalid
151+
.collect::<Vec<_>>();
152+
153+
if let &[app_name] = matches.as_slice() {
154+
Ok(app_name)
155+
} else {
156+
Err(anyhow!("IPA did not contain exactly one .app."))
157+
}
158+
}

src/utils/mobile_app/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ mod apple;
55
mod validation;
66

77
#[cfg(target_os = "macos")]
8-
pub use self::apple::handle_asset_catalogs;
8+
pub use self::apple::{handle_asset_catalogs, ipa_to_xcarchive};
9+
#[cfg(target_os = "macos")]
10+
pub use self::validation::is_ipa_file;
911
pub use self::validation::{is_aab_file, is_apk_file, is_apple_app, is_zip_file};

src/utils/mobile_app/validation.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ pub fn is_aab_file(bytes: &[u8]) -> Result<bool> {
3737
Ok(has_bundle_config && has_base_manifest)
3838
}
3939

40+
#[cfg(target_os = "macos")]
41+
pub fn is_ipa_file(bytes: &[u8]) -> Result<bool> {
42+
let cursor = std::io::Cursor::new(bytes);
43+
let archive = zip::ZipArchive::new(cursor)?;
44+
45+
let is_ipa = archive.file_names().any(|name| {
46+
name.starts_with("Payload/")
47+
&& name.ends_with(".app/Info.plist")
48+
&& name.matches('/').count() == 2
49+
});
50+
51+
Ok(is_ipa)
52+
}
53+
4054
pub fn is_xcarchive_directory<P>(path: P) -> bool
4155
where
4256
P: AsRef<Path>,
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
```
2+
$ sentry-cli mobile-app upload --help
3+
? success
4+
[EXPERIMENTAL] Upload mobile app files to a project.
5+
6+
Usage: sentry-cli[EXE] mobile-app upload [OPTIONS] <PATH>...
7+
8+
Arguments:
9+
<PATH>... The path to the mobile app files to upload. Supported files include Apk, Aab,
10+
XCArchive, and IPA.
11+
12+
Options:
13+
-o, --org <ORG>
14+
The organization ID or slug.
15+
--header <KEY:VALUE>
16+
Custom headers that should be attached to all requests
17+
in key:value format.
18+
-p, --project <PROJECT>
19+
The project ID or slug.
20+
--auth-token <AUTH_TOKEN>
21+
Use the given Sentry auth token.
22+
--sha <sha>
23+
The git commit sha to use for the upload. If not provided, the current commit sha will be
24+
used.
25+
--build-configuration <build_configuration>
26+
The build configuration to use for the upload. If not provided, the current version will
27+
be used.
28+
--log-level <LOG_LEVEL>
29+
Set the log output verbosity. [possible values: trace, debug, info, warn, error]
30+
--quiet
31+
Do not print any output while preserving correct exit code. This flag is currently
32+
implemented only for selected subcommands. [aliases: silent]
33+
-h, --help
34+
Print help
35+
36+
```

tests/integration/_cases/mobile_app/mobile_app-upload-help.trycmd renamed to tests/integration/_cases/mobile_app/mobile_app-upload-help-not-macos.trycmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ $ sentry-cli mobile-app upload --help
66
Usage: sentry-cli[EXE] mobile-app upload [OPTIONS] <PATH>...
77

88
Arguments:
9-
<PATH>... The path to the mobile app files to upload. Supported files include Apk, Aab or
9+
<PATH>... The path to the mobile app files to upload. Supported files include Apk, Aab, and
1010
XCArchive.
1111

1212
Options:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
```
2+
$ sentry-cli mobile-app upload tests/integration/_fixtures/mobile_app/ipa.ipa --sha test_sha
3+
? success
4+
Successfully uploaded 1 file to Sentry
5+
- tests/integration/_fixtures/mobile_app/ipa.ipa
6+
7+
```
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
invalid ipa content
24.2 KB
Binary file not shown.

0 commit comments

Comments
 (0)