Add an option to strip App extensions
Dadoum

Dadoum commited on 2026-07-10 00:55:28
Showing 11 changed files, with 270 additions and 39 deletions.

... ...
@@ -461,7 +461,7 @@ pub(crate) async fn sign_ipa(
461 461
     )]);
462 462
     let mut profile_requests = vec![(effective_bundle_id.clone(), app_id.developer_id)];
463 463
 
464
-    for (nested_bundle, target) in request.app.nested_bundles.iter().zip(targets.nested) {
464
+    for (nested_bundle, target) in request.app.nested_bundles_for_signing().zip(targets.nested) {
465 465
         let (nested_app_id, account_update) = resolve_signing_app_id(
466 466
             request.developer_context.clone(),
467 467
             &request.team_id,
... ...
@@ -521,6 +521,7 @@ pub(crate) async fn sign_ipa(
521 521
             metadata,
522 522
             bundle_id_replacements,
523 523
             icon_override_path: request.app.icon_override_path.as_deref().map(PathBuf::from),
524
+            strip_extensions: request.app.strip_extensions,
524 525
             team_id: request.team_id,
525 526
             provisioning_profiles,
526 527
             private_key_pem: signing_material.private_key_pem,
... ...
@@ -690,8 +691,7 @@ fn nested_app_id_targets(
690 691
     app: &AppOption,
691 692
     root_bundle_identifier: &str,
692 693
 ) -> Vec<SigningAppIdTarget> {
693
-    app.nested_bundles
694
-        .iter()
694
+    app.nested_bundles_for_signing()
695 695
         .map(|nested_bundle| {
696 696
             let desired_identifier = rebased_nested_bundle_identifier(
697 697
                 app.original_bundle_id(),
... ...
@@ -1037,7 +1037,8 @@ pub(crate) async fn download_provisioning_profile(
1037 1037
 mod tests {
1038 1038
     use super::*;
1039 1039
     use crate::app::models::{
1040
-        AppMetadata, EntitlementsSource, NestedBundleOption, SupportedDeviceFamily,
1040
+        AppMetadata, EntitlementsSource, NestedBundleKind, NestedBundleOption,
1041
+        SupportedDeviceFamily,
1041 1042
     };
1042 1043
 
1043 1044
     fn app_id(identifier: &str) -> AppIdOption {
... ...
@@ -1066,8 +1067,10 @@ mod tests {
1066 1067
                 .map(|bundle_id| NestedBundleOption {
1067 1068
                     name: bundle_id.rsplit('.').next().unwrap().to_string(),
1068 1069
                     bundle_id: (*bundle_id).to_string(),
1070
+                    kind: NestedBundleKind::AppExtension,
1069 1071
                 })
1070 1072
                 .collect(),
1073
+            strip_extensions: false,
1071 1074
             path: "/tmp/Example.ipa".to_string(),
1072 1075
             icon_path: None,
1073 1076
             icon_override_path: None,
... ...
@@ -1186,6 +1189,26 @@ mod tests {
1186 1189
         assert_eq!(plan.remaining_after_signing(), Some(3));
1187 1190
     }
1188 1191
 
1192
+    #[test]
1193
+    fn provisioning_plan_omits_stripped_extensions_but_keeps_nested_apps() {
1194
+        let mut app = app(
1195
+            "com.example.app",
1196
+            &["com.example.app.widget", "com.example.app.watch"],
1197
+        );
1198
+        app.nested_bundles[1].kind = NestedBundleKind::App;
1199
+        app.strip_extensions = true;
1200
+
1201
+        let plan = app_id_provisioning_plan(&team(Vec::new(), Some(5)), true, None, &app).unwrap();
1202
+
1203
+        assert_eq!(
1204
+            plan.app_ids
1205
+                .iter()
1206
+                .map(|app_id| app_id.identifier.as_str())
1207
+                .collect::<Vec<_>>(),
1208
+            vec!["com.example.app.TEAM", "com.example.app.TEAM.watch"]
1209
+        );
1210
+    }
1211
+
1189 1212
     #[test]
1190 1213
     fn provisioning_plan_reuses_unchanged_registered_app_ids() {
1191 1214
         let app = app("com.example.app", &["com.example.app.widget"]);
... ...
@@ -316,6 +316,7 @@ mod tests {
316 316
                 vec![SupportedDeviceFamily::IPhone],
317 317
             ),
318 318
             nested_bundles: Vec::new(),
319
+            strip_extensions: false,
319 320
             path: "/tmp/Example.ipa".to_string(),
320 321
             icon_path: None,
321 322
             icon_override_path: None,
... ...
@@ -437,6 +437,7 @@ pub(crate) enum EntitlementsSource {
437 437
 pub(crate) struct AppOption {
438 438
     pub(crate) metadata: AppMetadata,
439 439
     pub(crate) nested_bundles: Vec<NestedBundleOption>,
440
+    pub(crate) strip_extensions: bool,
440 441
     pub(crate) path: String,
441 442
     pub(crate) icon_path: Option<String>,
442 443
     pub(crate) icon_override_path: Option<String>,
... ...
@@ -450,6 +451,13 @@ pub(crate) struct AppOption {
450 451
 pub(crate) struct NestedBundleOption {
451 452
     pub(crate) name: String,
452 453
     pub(crate) bundle_id: String,
454
+    pub(crate) kind: NestedBundleKind,
455
+}
456
+
457
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
458
+pub(crate) enum NestedBundleKind {
459
+    App,
460
+    AppExtension,
453 461
 }
454 462
 
455 463
 impl AppOption {
... ...
@@ -473,6 +481,19 @@ impl AppOption {
473 481
         self.metadata.build.value()
474 482
     }
475 483
 
484
+    pub(crate) fn app_extension_count(&self) -> usize {
485
+        self.nested_bundles
486
+            .iter()
487
+            .filter(|bundle| bundle.kind == NestedBundleKind::AppExtension)
488
+            .count()
489
+    }
490
+
491
+    pub(crate) fn nested_bundles_for_signing(&self) -> impl Iterator<Item = &NestedBundleOption> {
492
+        self.nested_bundles.iter().filter(|bundle| {
493
+            !self.strip_extensions || bundle.kind != NestedBundleKind::AppExtension
494
+        })
495
+    }
496
+
476 497
     pub(crate) fn field(&self, field: AppMetadataField) -> &AppFieldValue {
477 498
         self.metadata.field(field)
478 499
     }
... ...
@@ -89,6 +89,8 @@ pub(crate) struct AppOverridePreferences {
89 89
     pub(crate) supported_devices: Option<String>,
90 90
     pub(crate) icon_path: Option<String>,
91 91
     pub(crate) entitlements: Option<Vec<EntitlementPreferences>>,
92
+    #[serde(default)]
93
+    pub(crate) strip_extensions: bool,
92 94
 }
93 95
 
94 96
 #[derive(Clone, Debug, Deserialize, Serialize)]
... ...
@@ -255,6 +257,7 @@ impl From<&AppOption> for AppOverridePreferences {
255 257
                     .map(EntitlementPreferences::from)
256 258
                     .collect()
257 259
             }),
260
+            strip_extensions: app.strip_extensions,
258 261
         }
259 262
     }
260 263
 }
... ...
@@ -277,6 +280,7 @@ pub(crate) fn apply_app_overrides(app: &mut AppOption, overrides: &AppOverridePr
277 280
             .map(AppEntitlement::from)
278 281
             .collect::<Vec<_>>()
279 282
     });
283
+    app.strip_extensions = overrides.strip_extensions && app.app_extension_count() > 0;
280 284
 }
281 285
 
282 286
 pub(crate) fn load_preferences() -> AppResult<SideloaderPreferences> {
... ...
@@ -299,7 +303,9 @@ pub(crate) fn save_preferences(preferences: &SideloaderPreferences) -> AppResult
299 303
 #[cfg(test)]
300 304
 mod tests {
301 305
     use super::*;
302
-    use crate::app::models::{AppMetadata, EntitlementsSource, PatchOption};
306
+    use crate::app::models::{
307
+        AppMetadata, EntitlementsSource, NestedBundleKind, NestedBundleOption, PatchOption,
308
+    };
303 309
 
304 310
     fn sample_app() -> AppOption {
305 311
         AppOption {
... ...
@@ -312,7 +318,12 @@ mod tests {
312 318
                 "16.0",
313 319
                 vec![SupportedDeviceFamily::IPhone],
314 320
             ),
315
-            nested_bundles: Vec::new(),
321
+            nested_bundles: vec![NestedBundleOption {
322
+                name: "Widget".to_string(),
323
+                bundle_id: "com.example.original.widget".to_string(),
324
+                kind: NestedBundleKind::AppExtension,
325
+            }],
326
+            strip_extensions: false,
316 327
             path: "/tmp/App.ipa".to_string(),
317 328
             icon_path: None,
318 329
             icon_override_path: None,
... ...
@@ -346,6 +357,7 @@ mod tests {
346 357
                     value: "true".to_string(),
347 358
                     values: None,
348 359
                 }]),
360
+                strip_extensions: true,
349 361
             },
350 362
         );
351 363
 
... ...
@@ -360,6 +372,8 @@ mod tests {
360 372
             &[SupportedDeviceFamily::IPhone, SupportedDeviceFamily::IPad]
361 373
         );
362 374
         assert_eq!(app.icon_override_path.as_deref(), Some("/tmp/icon.png"));
375
+        assert!(app.strip_extensions);
376
+        assert!(AppOverridePreferences::from(&app).strip_extensions);
363 377
         assert_eq!(
364 378
             app.entitlement_overrides,
365 379
             Some(vec![AppEntitlement {
... ...
@@ -368,4 +382,11 @@ mod tests {
368 382
             }])
369 383
         );
370 384
     }
385
+
386
+    #[test]
387
+    fn older_app_overrides_default_to_preserving_extensions() {
388
+        let overrides = toml::from_str::<AppOverridePreferences>("").unwrap();
389
+
390
+        assert!(!overrides.strip_extensions);
391
+    }
371 392
 }
... ...
@@ -126,6 +126,7 @@ mod tests {
126 126
                 vec![SupportedDeviceFamily::IPhone],
127 127
             ),
128 128
             nested_bundles: Vec::new(),
129
+            strip_extensions: false,
129 130
             path: path.to_string(),
130 131
             icon_path: None,
131 132
             icon_override_path: None,
... ...
@@ -762,6 +762,7 @@ mod tests {
762 762
                 vec![SupportedDeviceFamily::IPhone],
763 763
             ),
764 764
             nested_bundles: Vec::new(),
765
+            strip_extensions: false,
765 766
             path: "/tmp/Example.ipa".to_string(),
766 767
             icon_path: None,
767 768
             icon_override_path: None,
... ...
@@ -2,8 +2,8 @@ use crate::app::models::{
2 2
     AccountOption, AdiBackendAvailability, AdiBackendDetail, AdiBackendKind, AdiBackendOption,
3 3
     AdiProvisioningState, AdiRepairAction, AppEntitlement, AppIdCapabilityOption, AppIdOption,
4 4
     AppMetadata, AppOption, DeveloperDeviceOption, DevelopmentCertificateOption, DeviceOption,
5
-    EntitlementValue, EntitlementsSource, MachineIdentity, NestedBundleOption, PatchOption,
6
-    SupportedDeviceFamily, TeamOption,
5
+    EntitlementValue, EntitlementsSource, MachineIdentity, NestedBundleKind, NestedBundleOption,
6
+    PatchOption, SupportedDeviceFamily, TeamOption,
7 7
 };
8 8
 use crate::domain::{
9 9
     adi as domain_adi, device as domain_device, identity as domain_identity, ipa as domain_ipa,
... ...
@@ -100,8 +100,13 @@ pub(crate) fn app_option(app: domain_ipa::IpaApp) -> AppOption {
100 100
             .map(|bundle| NestedBundleOption {
101 101
                 name: bundle.name,
102 102
                 bundle_id: bundle.bundle_id,
103
+                kind: match bundle.kind {
104
+                    domain_ipa::NestedBundleKind::App => NestedBundleKind::App,
105
+                    domain_ipa::NestedBundleKind::AppExtension => NestedBundleKind::AppExtension,
106
+                },
103 107
             })
104 108
             .collect(),
109
+        strip_extensions: false,
105 110
         path: app.path,
106 111
         icon_path: app.icon_path,
107 112
         icon_override_path: None,
... ...
@@ -495,6 +500,7 @@ mod tests {
495 500
             nested_bundles: vec![domain_ipa::NestedBundle {
496 501
                 name: "Widget".to_string(),
497 502
                 bundle_id: "com.example.app.Widget".to_string(),
503
+                kind: domain_ipa::NestedBundleKind::AppExtension,
498 504
             }],
499 505
             path: "/tmp/Example.ipa".to_string(),
500 506
             icon_path: Some("/tmp/icon.png".to_string()),
... ...
@@ -512,6 +518,10 @@ mod tests {
512 518
         assert_eq!(option.name(), "Example");
513 519
         assert_eq!(option.bundle_id(), "com.example.app");
514 520
         assert_eq!(option.nested_bundles[0].bundle_id, "com.example.app.Widget");
521
+        assert_eq!(
522
+            option.nested_bundles[0].kind,
523
+            NestedBundleKind::AppExtension
524
+        );
515 525
         assert_eq!(
516 526
             option.supported_devices(),
517 527
             &[SupportedDeviceFamily::IPhone, SupportedDeviceFamily::IPad]
... ...
@@ -5,8 +5,8 @@ use crate::backend::icon::{
5 5
 use crate::backend::paths::app_data_dir;
6 6
 use crate::backend::{runtime as backend_runtime, BackendError, BackendResult};
7 7
 use crate::domain::{
8
-    AppEntitlement, AppMetadata, EntitlementValue, EntitlementsSource, IpaApp, NestedBundle, Patch,
9
-    SupportedDeviceFamily,
8
+    AppEntitlement, AppMetadata, EntitlementValue, EntitlementsSource, IpaApp, NestedBundle,
9
+    NestedBundleKind, Patch, SupportedDeviceFamily,
10 10
 };
11 11
 use apple_codesign::{
12 12
     sign_bundle, BundleSigningSettings, ProvisioningProfile, RustCryptoCmsSigner,
... ...
@@ -42,6 +42,7 @@ pub(crate) struct IpaSigningRequest {
42 42
     pub(crate) metadata: AppMetadata,
43 43
     pub(crate) bundle_id_replacements: BTreeMap<String, String>,
44 44
     pub(crate) icon_override_path: Option<PathBuf>,
45
+    pub(crate) strip_extensions: bool,
45 46
     pub(crate) team_id: String,
46 47
     pub(crate) provisioning_profiles: BTreeMap<String, Vec<u8>>,
47 48
     pub(crate) private_key_pem: Vec<u8>,
... ...
@@ -162,12 +163,13 @@ async fn read_nested_signable_bundles(
162 163
         .enumerate()
163 164
         .filter_map(|(index, entry)| {
164 165
             let path = entry_name(entry)?;
165
-            is_nested_signable_info_plist(path, app_prefix).then(|| (index, path.to_string()))
166
+            nested_signable_bundle_kind(path, app_prefix)
167
+                .map(|kind| (index, path.to_string(), kind))
166 168
         })
167 169
         .collect::<Vec<_>>();
168 170
     let mut bundles = Vec::with_capacity(candidates.len());
169 171
 
170
-    for (index, path) in candidates {
172
+    for (index, path, kind) in candidates {
171 173
         let bytes = read_entry(reader, index, &path).await?;
172 174
         let info = plist::from_bytes::<Dictionary>(&bytes)
173 175
             .map_err(|error| BackendError::Plist(format!("{path}: {error}")))?;
... ...
@@ -179,7 +181,11 @@ async fn read_nested_signable_bundles(
179 181
             .or_else(|| plist_string(&info, "CFBundleName"))
180 182
             .unwrap_or(&bundle_id)
181 183
             .to_string();
182
-        bundles.push(NestedBundle { name, bundle_id });
184
+        bundles.push(NestedBundle {
185
+            name,
186
+            bundle_id,
187
+            kind,
188
+        });
183 189
     }
184 190
 
185 191
     bundles.sort_by(|left, right| left.bundle_id.cmp(&right.bundle_id));
... ...
@@ -187,18 +193,23 @@ async fn read_nested_signable_bundles(
187 193
     Ok(bundles)
188 194
 }
189 195
 
190
-fn is_nested_signable_info_plist(path: &str, app_prefix: &str) -> bool {
191
-    let Some(relative) = path
196
+fn nested_signable_bundle_kind(path: &str, app_prefix: &str) -> Option<NestedBundleKind> {
197
+    let relative = path
192 198
         .strip_prefix(app_prefix)
193
-        .and_then(|path| path.strip_suffix("/Info.plist"))
194
-    else {
195
-        return false;
196
-    };
197
-    !relative.is_empty()
198
-        && relative
199
-            .rsplit('/')
200
-            .next()
201
-            .is_some_and(|folder| folder.ends_with(".appex") || folder.ends_with(".app"))
199
+        .and_then(|path| path.strip_suffix("/Info.plist"))?;
200
+    if relative.is_empty() {
201
+        return None;
202
+    }
203
+    let extension = Path::new(relative)
204
+        .extension()
205
+        .and_then(|extension| extension.to_str())?;
206
+    if extension.eq_ignore_ascii_case("appex") {
207
+        Some(NestedBundleKind::AppExtension)
208
+    } else if extension.eq_ignore_ascii_case("app") {
209
+        Some(NestedBundleKind::App)
210
+    } else {
211
+        None
212
+    }
202 213
 }
203 214
 
204 215
 fn is_app_info_plist(path: &str) -> bool {
... ...
@@ -470,6 +481,9 @@ async fn sign_ipa_async(
470 481
     extract_ipa(&request.source_path, work_dir.path(), &mut progress).await?;
471 482
     let app_path = root_app_bundle(work_dir.path())?;
472 483
     progress(IpaSigningProgress::Patching);
484
+    if request.strip_extensions {
485
+        strip_app_extensions(&app_path)?;
486
+    }
473 487
     patch_app_bundle(
474 488
         &app_path,
475 489
         &request.metadata,
... ...
@@ -523,6 +537,39 @@ async fn sign_ipa_async(
523 537
     Ok(SigningArtifact::Ipa(output_path))
524 538
 }
525 539
 
540
+fn strip_app_extensions(app_path: &Path) -> BackendResult<()> {
541
+    let mut entries = WalkDir::new(app_path).follow_links(false).into_iter();
542
+    let mut extension_paths = Vec::new();
543
+
544
+    while let Some(entry) = entries.next() {
545
+        let entry = entry.map_err(|error| {
546
+            BackendError::Message(format!(
547
+                "Failed to inspect app extensions under {}: {error}",
548
+                app_path.display()
549
+            ))
550
+        })?;
551
+        if entry.file_type().is_dir()
552
+            && entry
553
+                .path()
554
+                .extension()
555
+                .and_then(|extension| extension.to_str())
556
+                .is_some_and(|extension| extension.eq_ignore_ascii_case("appex"))
557
+        {
558
+            extension_paths.push(entry.into_path());
559
+            entries.skip_current_dir();
560
+        }
561
+    }
562
+
563
+    for path in extension_paths {
564
+        fs::remove_dir_all(&path).map_err(|source| BackendError::Io {
565
+            action: "Remove app extension",
566
+            path,
567
+            source,
568
+        })?;
569
+    }
570
+    Ok(())
571
+}
572
+
526 573
 async fn extract_ipa(
527 574
     source_path: &Path,
528 575
     destination: &Path,
... ...
@@ -1474,22 +1521,51 @@ mod tests {
1474 1521
     fn nested_signable_bundle_paths_include_extensions_and_nested_apps() {
1475 1522
         let root = "Payload/App.app/";
1476 1523
 
1477
-        assert!(is_nested_signable_info_plist(
1478
-            "Payload/App.app/PlugIns/Widget.appex/Info.plist",
1479
-            root
1480
-        ));
1481
-        assert!(is_nested_signable_info_plist(
1482
-            "Payload/App.app/Watch/WatchApp.app/Info.plist",
1483
-            root
1484
-        ));
1485
-        assert!(!is_nested_signable_info_plist(
1524
+        assert_eq!(
1525
+            nested_signable_bundle_kind("Payload/App.app/PlugIns/Widget.appex/Info.plist", root),
1526
+            Some(NestedBundleKind::AppExtension)
1527
+        );
1528
+        assert_eq!(
1529
+            nested_signable_bundle_kind("Payload/App.app/Watch/WatchApp.app/Info.plist", root),
1530
+            Some(NestedBundleKind::App)
1531
+        );
1532
+        assert_eq!(
1533
+            nested_signable_bundle_kind(
1486 1534
                 "Payload/App.app/Frameworks/Example.framework/Info.plist",
1487 1535
                 root
1488
-        ));
1489
-        assert!(!is_nested_signable_info_plist(
1490
-            "Payload/App.app/Info.plist",
1491
-            root
1492
-        ));
1536
+            ),
1537
+            None
1538
+        );
1539
+        assert_eq!(
1540
+            nested_signable_bundle_kind("Payload/App.app/Info.plist", root),
1541
+            None
1542
+        );
1543
+    }
1544
+
1545
+    #[test]
1546
+    fn stripping_extensions_preserves_nested_apps_and_frameworks() {
1547
+        let temp = tempfile::tempdir().unwrap();
1548
+        let app_path = temp.path().join("Payload").join("Test.app");
1549
+        let extension_path = app_path.join("PlugIns").join("Widget.appex");
1550
+        let nested_app_path = app_path.join("Watch").join("WatchApp.app");
1551
+        let nested_extension_path = nested_app_path.join("PlugIns").join("Companion.appex");
1552
+        let framework_path = app_path.join("Frameworks").join("Example.framework");
1553
+        for path in [
1554
+            &extension_path,
1555
+            &nested_app_path,
1556
+            &nested_extension_path,
1557
+            &framework_path,
1558
+        ] {
1559
+            fs::create_dir_all(path).unwrap();
1560
+            fs::write(path.join("content"), b"test").unwrap();
1561
+        }
1562
+
1563
+        strip_app_extensions(&app_path).unwrap();
1564
+
1565
+        assert!(!extension_path.exists());
1566
+        assert!(!nested_extension_path.exists());
1567
+        assert!(nested_app_path.exists());
1568
+        assert!(framework_path.exists());
1493 1569
     }
1494 1570
 
1495 1571
     #[test]
... ...
@@ -41,6 +41,13 @@ pub(crate) struct AppMetadata {
41 41
 pub(crate) struct NestedBundle {
42 42
     pub(crate) name: String,
43 43
     pub(crate) bundle_id: String,
44
+    pub(crate) kind: NestedBundleKind,
45
+}
46
+
47
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
48
+pub(crate) enum NestedBundleKind {
49
+    App,
50
+    AppExtension,
44 51
 }
45 52
 
46 53
 #[derive(Clone, Debug, PartialEq)]
... ...
@@ -2883,6 +2883,24 @@ impl SettingsWindow {
2883 2883
         .detach();
2884 2884
     }
2885 2885
 
2886
+    fn set_strip_extensions(
2887
+        &mut self,
2888
+        checked: &bool,
2889
+        window: &mut Window,
2890
+        cx: &mut Context<Self>,
2891
+    ) {
2892
+        window.focus(&self.focus_handle, cx);
2893
+        let Some(mut snapshot) = self.app_settings_snapshot(cx) else {
2894
+            return;
2895
+        };
2896
+        if snapshot.app.app_extension_count() == 0 {
2897
+            return;
2898
+        }
2899
+        snapshot.app.strip_extensions = *checked;
2900
+        self.replace_app_in_parent(snapshot.app_index, snapshot.app, cx);
2901
+        cx.notify();
2902
+    }
2903
+
2886 2904
     fn begin_app_detail_edit(
2887 2905
         &mut self,
2888 2906
         field: AppMetadataField,
... ...
@@ -3430,6 +3448,7 @@ mod tests {
3430 3448
                 vec![SupportedDeviceFamily::IPhone],
3431 3449
             ),
3432 3450
             nested_bundles: Vec::new(),
3451
+            strip_extensions: false,
3433 3452
             path: format!("/tmp/{name}.ipa"),
3434 3453
             icon_path: None,
3435 3454
             icon_override_path: None,
... ...
@@ -1,5 +1,5 @@
1 1
 use super::*;
2
-use gpui_component::{checkbox::Checkbox, Sizable as _};
2
+use gpui_component::{checkbox::Checkbox, Disableable as _, Sizable as _};
3 3
 
4 4
 pub(super) struct AppViewProps<'a> {
5 5
     pub(super) focus_handle: &'a FocusHandle,
... ...
@@ -130,6 +130,7 @@ pub(super) fn render(props: AppViewProps<'_>, cx: &mut Context<SettingsWindow>)
130 130
                         .child(read_only_app_detail_row("IPA", app.path.to_string()))
131 131
                         .child(read_only_app_detail_row("Patches", patch_summary)),
132 132
                 )
133
+                .child(strip_extensions_checkbox(app, cx))
133 134
                 .child(entitlements_table(
134 135
                     app,
135 136
                     team_id,
... ...
@@ -141,6 +142,56 @@ pub(super) fn render(props: AppViewProps<'_>, cx: &mut Context<SettingsWindow>)
141 142
         ))
142 143
 }
143 144
 
145
+fn strip_extensions_checkbox(
146
+    app: &AppOption,
147
+    cx: &mut Context<SettingsWindow>,
148
+) -> impl IntoElement {
149
+    let extension_count = app.app_extension_count();
150
+    let detail = match extension_count {
151
+        0 => "No embedded app extensions were detected in this IPA.".to_string(),
152
+        1 => "Remove the embedded app extension before signing. Its App ID and profile will not be required."
153
+            .to_string(),
154
+        count => format!(
155
+            "Remove {count} embedded app extensions before signing. Their App IDs and profiles will not be required."
156
+        ),
157
+    };
158
+
159
+    Checkbox::new("strip-app-extensions")
160
+        .p_3()
161
+        .rounded_md()
162
+        .border_1()
163
+        .border_color(rgb(0xd8e0df))
164
+        .bg(rgb(0xffffff))
165
+        .hover(|style| style.bg(rgb(0xf6f9f9)))
166
+        .items_center()
167
+        .small()
168
+        .checked(app.strip_extensions)
169
+        .disabled(extension_count == 0)
170
+        .on_click(cx.listener(SettingsWindow::set_strip_extensions))
171
+        .child(
172
+            div()
173
+                .min_w_0()
174
+                .flex()
175
+                .flex_1()
176
+                .flex_col()
177
+                .gap_0p5()
178
+                .child(
179
+                    div()
180
+                        .text_sm()
181
+                        .font_weight(FontWeight::SEMIBOLD)
182
+                        .text_color(rgb(0x24333a))
183
+                        .child("Strip app extensions"),
184
+                )
185
+                .child(
186
+                    div()
187
+                        .text_xs()
188
+                        .text_color(rgb(0x6a7a81))
189
+                        .whitespace_normal()
190
+                        .child(detail),
191
+                ),
192
+        )
193
+}
194
+
144 195
 fn app_settings_error(error: SharedString) -> impl IntoElement {
145 196
     div()
146 197
         .rounded_md()
147 198