Implement Sign + Sideload in super-sideloader
Dadoum

Dadoum commited on 2026-07-09 23:34:01
Showing 40 changed files, with 8859 additions and 273 deletions.

... ...
@@ -3544,7 +3544,9 @@ version = "0.1.64"
3544 3544
 source = "registry+https://github.com/rust-lang/crates.io-index"
3545 3545
 checksum = "48e95040e91503e7425835843e5e985ab1ea440db9b965972021e7b845c2a6ae"
3546 3546
 dependencies = [
3547
+ "async_zip",
3547 3548
  "base64",
3549
+ "chrono",
3548 3550
  "futures",
3549 3551
  "libc",
3550 3552
  "plist",
... ...
@@ -7084,12 +7086,15 @@ dependencies = [
7084 7086
  "adi",
7085 7087
  "adid-proxy",
7086 7088
  "android-coreadi",
7089
+ "apple-codesign",
7087 7090
  "async_zip",
7088 7091
  "chrono",
7089 7092
  "core-foundation 0.10.0",
7093
+ "crc32fast",
7090 7094
  "directories",
7091 7095
  "dlopen2",
7092 7096
  "env_logger",
7097
+ "flate2",
7093 7098
  "futures",
7094 7099
  "futures-lite 2.6.1",
7095 7100
  "gpui",
... ...
@@ -7097,20 +7102,24 @@ dependencies = [
7097 7102
  "gpui_platform",
7098 7103
  "grandslam",
7099 7104
  "idevice",
7105
+ "image",
7100 7106
  "keyring",
7101 7107
  "libc",
7102 7108
  "library-coreadi",
7103 7109
  "log",
7104 7110
  "ouroboros",
7111
+ "pem-rfc7468",
7105 7112
  "plist",
7106 7113
  "rand 0.10.2",
7107 7114
  "reqwest",
7108 7115
  "serde",
7109 7116
  "sha1 0.11.0",
7117
+ "tempfile",
7110 7118
  "thiserror 2.0.18",
7111 7119
  "tokio",
7112 7120
  "toml 0.8.23",
7113 7121
  "uuid",
7122
+ "walkdir",
7114 7123
  "xcode",
7115 7124
 ]
7116 7125
 
... ...
@@ -5,10 +5,17 @@ edition = "2024"
5 5
 
6 6
 [dependencies]
7 7
 plist.workspace = true
8
-
9
-tokio.workspace = true
10
-lief = "0.17"
8
+thiserror.workspace = true
11 9
 sha1.workspace = true
12 10
 sha2.workspace = true
13
-futures = "0.3.31"
14
-log = "0.4.28"
11
+byteorder = "1.5"
12
+cms = { version = "0.2.3", features = ["builder", "rsa", "sha2"] }
13
+const-oid = { version = "0.9.6", features = ["db"] }
14
+der = { version = "0.7.10", features = ["alloc", "derive", "std"] }
15
+memmap2 = "0.9"
16
+pem-rfc7468 = { version = "0.7", features = ["alloc"] }
17
+rayon = "1.12"
18
+rsa = { version = "0.9.9", features = ["sha2"] }
19
+sha2_010 = { package = "sha2", version = "0.10" }
20
+spki = "0.7.3"
21
+x509-cert = "0.2.5"
... ...
@@ -0,0 +1,684 @@
1
+use crate::error::{CodeSignError, Result};
2
+use crate::file_bytes::read_file_bytes;
3
+use crate::macho::{DEFAULT_CMS_BLOB_RESERVATION, MachOSigningConfig, sign_macho_file};
4
+use crate::signature::CmsSigner;
5
+use plist::{Dictionary, Value};
6
+use rayon::prelude::*;
7
+use sha1::Digest as _;
8
+use std::collections::BTreeMap;
9
+use std::fs;
10
+use std::path::{Path, PathBuf};
11
+
12
+const FAIRPLAY_DIR: &str = "SC_Info";
13
+const CODE_SIGNATURE_DIR: &str = "_CodeSignature";
14
+const CODE_RESOURCES_FILE: &str = "CodeResources";
15
+
16
+pub struct BundleSigningSettings<'a> {
17
+    pub team_id: String,
18
+    pub main_entitlements: Dictionary,
19
+    pub entitlements_by_bundle_id: BTreeMap<String, Dictionary>,
20
+    pub cms_signer: Option<&'a dyn CmsSigner>,
21
+    pub embedded_mobileprovision: Option<&'a [u8]>,
22
+    pub embedded_mobileprovisions_by_bundle_id: BTreeMap<String, &'a [u8]>,
23
+    pub cms_blob_reservation: usize,
24
+}
25
+
26
+impl<'a> BundleSigningSettings<'a> {
27
+    pub fn new(
28
+        team_id: impl Into<String>,
29
+        main_entitlements: Dictionary,
30
+        cms_signer: Option<&'a dyn CmsSigner>,
31
+    ) -> Self {
32
+        Self {
33
+            team_id: team_id.into(),
34
+            main_entitlements,
35
+            entitlements_by_bundle_id: BTreeMap::new(),
36
+            cms_signer,
37
+            embedded_mobileprovision: None,
38
+            embedded_mobileprovisions_by_bundle_id: BTreeMap::new(),
39
+            cms_blob_reservation: DEFAULT_CMS_BLOB_RESERVATION,
40
+        }
41
+    }
42
+}
43
+
44
+#[derive(Debug)]
45
+pub struct Bundle {
46
+    path: PathBuf,
47
+    app_info: Dictionary,
48
+}
49
+
50
+impl Bundle {
51
+    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
52
+        let path = path.into();
53
+        let info_path = path.join("Info.plist");
54
+        if !info_path.exists() {
55
+            return Err(CodeSignError::MissingInfoPlist(info_path));
56
+        }
57
+
58
+        let file =
59
+            fs::File::open(&info_path).map_err(|source| CodeSignError::io(&info_path, source))?;
60
+        let app_info = match Value::from_reader(file)? {
61
+            Value::Dictionary(dict) => dict,
62
+            _ => {
63
+                return Err(CodeSignError::macho(
64
+                    &info_path,
65
+                    "Info.plist is not a dictionary",
66
+                ));
67
+            }
68
+        };
69
+
70
+        Ok(Self { path, app_info })
71
+    }
72
+
73
+    pub fn path(&self) -> &Path {
74
+        &self.path
75
+    }
76
+
77
+    pub fn bundle_identifier(&self) -> Result<&str> {
78
+        self.info_string("CFBundleIdentifier")
79
+    }
80
+
81
+    pub fn executable_name(&self) -> Result<&str> {
82
+        self.info_string("CFBundleExecutable")
83
+    }
84
+
85
+    pub fn sign(&mut self, settings: &BundleSigningSettings<'_>) -> Result<CodeResourcesMaps> {
86
+        self.sign_inner(settings, true)
87
+    }
88
+
89
+    fn sign_inner(
90
+        &mut self,
91
+        settings: &BundleSigningSettings<'_>,
92
+        is_root: bool,
93
+    ) -> Result<CodeResourcesMaps> {
94
+        remove_fairplay_dir(&self.path)?;
95
+
96
+        let bundle_id = self.bundle_identifier()?.to_string();
97
+        let executable = self.executable_name()?.to_string();
98
+        let sub_bundles = self.sub_bundles()?;
99
+        let sub_bundles_for_hashing = sub_bundles.clone();
100
+
101
+        let code_signature_dir = self.path.join(CODE_SIGNATURE_DIR);
102
+        fs::create_dir_all(&code_signature_dir)
103
+            .map_err(|source| CodeSignError::io(&code_signature_dir, source))?;
104
+
105
+        let code_resources_path = code_signature_dir.join(CODE_RESOURCES_FILE);
106
+        if code_resources_path.exists() {
107
+            fs::remove_file(&code_resources_path)
108
+                .map_err(|source| CodeSignError::io(&code_resources_path, source))?;
109
+        }
110
+
111
+        let info_data = encode_binary_plist(&self.app_info)?;
112
+        let info_path = self.path.join("Info.plist");
113
+        fs::write(&info_path, &info_data)
114
+            .map_err(|source| CodeSignError::io(&info_path, source))?;
115
+        let mobileprovision = settings
116
+            .embedded_mobileprovisions_by_bundle_id
117
+            .get(&bundle_id)
118
+            .copied()
119
+            .or_else(|| {
120
+                if is_root {
121
+                    settings.embedded_mobileprovision
122
+                } else {
123
+                    None
124
+                }
125
+            });
126
+        if let Some(mobileprovision) = mobileprovision {
127
+            let profile_path = self.path.join("embedded.mobileprovision");
128
+            fs::write(&profile_path, mobileprovision)
129
+                .map_err(|source| CodeSignError::io(&profile_path, source))?;
130
+        }
131
+
132
+        let (sub_results, bundle_files) = rayon::join(
133
+            || sign_sub_bundles(&self.path, sub_bundles, settings),
134
+            || collect_own_bundle_files(&self.path, &executable, &sub_bundles_for_hashing),
135
+        );
136
+        let sub_results = sub_results?;
137
+        let bundle_files = bundle_files?;
138
+        sign_libraries(&bundle_files.libraries, settings)?;
139
+        let resource_hashes = hash_resource_files(&self.path, &bundle_files.resource_files)?;
140
+
141
+        let mut maps = CodeResourcesMaps::default();
142
+
143
+        for sub in &sub_results {
144
+            merge_sub_bundle_resources(&mut maps, &self.path, sub)?;
145
+        }
146
+
147
+        for hash in resource_hashes {
148
+            maps.insert_hash(hash);
149
+        }
150
+
151
+        let code_resources = encode_code_resources(&maps)?;
152
+        fs::write(&code_resources_path, &code_resources)
153
+            .map_err(|source| CodeSignError::io(&code_resources_path, source))?;
154
+
155
+        let empty_entitlements = Dictionary::new();
156
+        let bundle_entitlements = settings
157
+            .entitlements_by_bundle_id
158
+            .get(&bundle_id)
159
+            .or_else(|| is_root.then_some(&settings.main_entitlements))
160
+            .unwrap_or(&empty_entitlements);
161
+
162
+        let mut config = MachOSigningConfig::new(
163
+            &bundle_id,
164
+            &settings.team_id,
165
+            bundle_entitlements,
166
+            settings.cms_signer,
167
+        );
168
+        config.cms_blob_reservation = settings.cms_blob_reservation;
169
+        config.info_plist = Some(&info_data);
170
+        config.code_resources = Some(&code_resources);
171
+        sign_macho_file(&self.path.join(&executable), &config)?;
172
+
173
+        Ok(maps)
174
+    }
175
+
176
+    fn info_string(&self, key: &'static str) -> Result<&str> {
177
+        self.app_info
178
+            .get(key)
179
+            .and_then(Value::as_string)
180
+            .ok_or(CodeSignError::MissingInfoString(key))
181
+    }
182
+
183
+    fn sub_bundles(&self) -> Result<Vec<PathBuf>> {
184
+        let mut bundles = Vec::new();
185
+        for folder in ["Frameworks", "PlugIns"] {
186
+            let dir = self.path.join(folder);
187
+            if !dir.exists() {
188
+                continue;
189
+            }
190
+
191
+            for entry in fs::read_dir(&dir).map_err(|source| CodeSignError::io(&dir, source))? {
192
+                let entry = entry.map_err(|source| CodeSignError::io(&dir, source))?;
193
+                let path = entry.path();
194
+                if entry
195
+                    .file_type()
196
+                    .map_err(|source| CodeSignError::io(&path, source))?
197
+                    .is_dir()
198
+                    && path.join("Info.plist").exists()
199
+                {
200
+                    bundles.push(path);
201
+                }
202
+            }
203
+        }
204
+        bundles.sort();
205
+        Ok(bundles)
206
+    }
207
+}
208
+
209
+#[derive(Debug)]
210
+struct SubBundleResult {
211
+    path: PathBuf,
212
+    relative_root: String,
213
+    executable: String,
214
+    resources: CodeResourcesMaps,
215
+}
216
+
217
+#[derive(Clone, Debug)]
218
+struct FileHash {
219
+    relative_path: String,
220
+    sha1: [u8; 20],
221
+    sha256: [u8; 32],
222
+    optional: bool,
223
+}
224
+
225
+#[derive(Debug)]
226
+struct BundleFiles {
227
+    resource_files: Vec<PathBuf>,
228
+    libraries: Vec<PathBuf>,
229
+}
230
+
231
+fn merge_sub_bundle_resources(
232
+    maps: &mut CodeResourcesMaps,
233
+    parent_path: &Path,
234
+    sub: &SubBundleResult,
235
+) -> Result<()> {
236
+    maps.merge_rerooted(&sub.relative_root, &sub.resources);
237
+    maps.insert_hash(hash_bundle_file(
238
+        parent_path,
239
+        &sub.path.join(&sub.executable),
240
+    )?);
241
+    maps.insert_hash(hash_bundle_file(
242
+        parent_path,
243
+        &sub.path.join(CODE_SIGNATURE_DIR).join(CODE_RESOURCES_FILE),
244
+    )?);
245
+    Ok(())
246
+}
247
+
248
+#[derive(Clone, Debug, Default)]
249
+pub struct CodeResourcesMaps {
250
+    pub files: Dictionary,
251
+    pub files2: Dictionary,
252
+    hashes: Vec<FileHash>,
253
+}
254
+
255
+impl CodeResourcesMaps {
256
+    fn insert_hash(&mut self, hash: FileHash) {
257
+        self.hashes.push(hash.clone());
258
+        let sha1 = hash.sha1.to_vec();
259
+        if !omit_from_files(&hash.relative_path) {
260
+            if hash.optional {
261
+                let mut file = Dictionary::new();
262
+                file.insert("hash".to_string(), Value::Data(sha1.clone()));
263
+                file.insert("optional".to_string(), Value::Boolean(true));
264
+                self.files
265
+                    .insert(hash.relative_path.clone(), Value::Dictionary(file));
266
+            } else {
267
+                self.files
268
+                    .insert(hash.relative_path.clone(), Value::Data(sha1.clone()));
269
+            }
270
+        }
271
+
272
+        if !omit_from_files2(&hash.relative_path) {
273
+            let mut file2 = Dictionary::new();
274
+            file2.insert("hash".to_string(), Value::Data(sha1));
275
+            file2.insert("hash2".to_string(), Value::Data(hash.sha256.to_vec()));
276
+            if hash.optional {
277
+                file2.insert("optional".to_string(), Value::Boolean(true));
278
+            }
279
+            self.files2
280
+                .insert(hash.relative_path, Value::Dictionary(file2));
281
+        }
282
+    }
283
+
284
+    fn merge_rerooted(&mut self, root: &str, other: &Self) {
285
+        for hash in &other.hashes {
286
+            let mut hash = hash.clone();
287
+            hash.relative_path = join_relative(root, &hash.relative_path);
288
+            self.insert_hash(hash);
289
+        }
290
+    }
291
+}
292
+
293
+fn omit_from_files(relative_path: &str) -> bool {
294
+    relative_path.ends_with(".lproj/locversion.plist")
295
+}
296
+
297
+fn omit_from_files2(relative_path: &str) -> bool {
298
+    omit_from_files(relative_path)
299
+        || relative_path == "Info.plist"
300
+        || relative_path == "PkgInfo"
301
+        || relative_path == ".DS_Store"
302
+        || relative_path.ends_with("/.DS_Store")
303
+}
304
+
305
+pub fn sign_bundle(path: impl Into<PathBuf>, settings: &BundleSigningSettings<'_>) -> Result<()> {
306
+    let mut bundle = Bundle::open(path)?;
307
+    bundle.sign(settings)?;
308
+    Ok(())
309
+}
310
+
311
+fn sign_sub_bundles(
312
+    root_path: &Path,
313
+    sub_bundles: Vec<PathBuf>,
314
+    settings: &BundleSigningSettings<'_>,
315
+) -> Result<Vec<SubBundleResult>> {
316
+    sub_bundles
317
+        .into_par_iter()
318
+        .map(|path| {
319
+            let mut bundle = Bundle::open(&path)?;
320
+            let result = bundle.sign_inner(settings, false)?;
321
+            let relative_root = relative_path_string(root_path, &path)?;
322
+            let executable = bundle.executable_name()?.to_string();
323
+            Ok(SubBundleResult {
324
+                path,
325
+                relative_root,
326
+                executable,
327
+                resources: result,
328
+            })
329
+        })
330
+        .collect()
331
+}
332
+
333
+fn collect_own_bundle_files(
334
+    bundle_path: &Path,
335
+    executable: &str,
336
+    sub_bundles: &[PathBuf],
337
+) -> Result<BundleFiles> {
338
+    let mut resource_files = Vec::new();
339
+    let mut libraries = Vec::new();
340
+
341
+    walk_files_pruned(
342
+        bundle_path,
343
+        &mut |dir| !sub_bundles.iter().any(|sub_bundle| sub_bundle == dir),
344
+        &mut |path| {
345
+            let relative = relative_path_string(bundle_path, path)?;
346
+            if path.extension().and_then(|value| value.to_str()) == Some("dylib") {
347
+                libraries.push(path.to_path_buf());
348
+            }
349
+            if should_hash_resource(&relative, executable) {
350
+                resource_files.push(path.to_path_buf());
351
+            }
352
+            Ok(())
353
+        },
354
+    )?;
355
+
356
+    resource_files.sort();
357
+    libraries.sort();
358
+
359
+    Ok(BundleFiles {
360
+        resource_files,
361
+        libraries,
362
+    })
363
+}
364
+
365
+fn sign_libraries(libraries: &[PathBuf], settings: &BundleSigningSettings<'_>) -> Result<()> {
366
+    libraries
367
+        .par_iter()
368
+        .map(|path| {
369
+            let identifier = path
370
+                .file_name()
371
+                .and_then(|value| value.to_str())
372
+                .ok_or_else(|| CodeSignError::InvalidBundlePath(path.clone()))?;
373
+            let entitlements = Dictionary::new();
374
+            let mut config = MachOSigningConfig::new(
375
+                identifier,
376
+                &settings.team_id,
377
+                &entitlements,
378
+                settings.cms_signer,
379
+            );
380
+            config.cms_blob_reservation = settings.cms_blob_reservation;
381
+            sign_macho_file(path, &config)
382
+        })
383
+        .collect::<Result<Vec<_>>>()?;
384
+    Ok(())
385
+}
386
+
387
+fn hash_resource_files(bundle_path: &Path, resource_files: &[PathBuf]) -> Result<Vec<FileHash>> {
388
+    resource_files
389
+        .par_iter()
390
+        .map(|path| hash_bundle_file(bundle_path, path))
391
+        .collect()
392
+}
393
+
394
+fn remove_fairplay_dir(bundle_path: &Path) -> Result<()> {
395
+    let fairplay_path = bundle_path.join(FAIRPLAY_DIR);
396
+    if fairplay_path.exists() {
397
+        fs::remove_dir_all(&fairplay_path)
398
+            .map_err(|source| CodeSignError::io(&fairplay_path, source))?;
399
+    }
400
+    Ok(())
401
+}
402
+
403
+fn walk_files_pruned(
404
+    root: &Path,
405
+    should_descend: &mut impl FnMut(&Path) -> bool,
406
+    visitor: &mut impl FnMut(&Path) -> Result<()>,
407
+) -> Result<()> {
408
+    let mut stack = vec![root.to_path_buf()];
409
+    while let Some(dir) = stack.pop() {
410
+        for entry in fs::read_dir(&dir).map_err(|source| CodeSignError::io(&dir, source))? {
411
+            let entry = entry.map_err(|source| CodeSignError::io(&dir, source))?;
412
+            let path = entry.path();
413
+            let file_type = entry
414
+                .file_type()
415
+                .map_err(|source| CodeSignError::io(&path, source))?;
416
+            if file_type.is_dir() && should_descend(&path) {
417
+                stack.push(path);
418
+            } else if file_type.is_file() {
419
+                visitor(&path)?;
420
+            }
421
+        }
422
+    }
423
+    Ok(())
424
+}
425
+
426
+fn should_hash_resource(relative: &str, executable: &str) -> bool {
427
+    if relative == executable || relative.starts_with(FAIRPLAY_DIR) {
428
+        return false;
429
+    }
430
+
431
+    for nested_root in ["Frameworks/", "PlugIns/"] {
432
+        if let Some(rest) = relative.strip_prefix(nested_root)
433
+            && rest.contains('/')
434
+        {
435
+            return false;
436
+        }
437
+    }
438
+
439
+    true
440
+}
441
+
442
+fn hash_bundle_file(bundle_path: &Path, path: &Path) -> Result<FileHash> {
443
+    let relative_path = relative_path_string(bundle_path, path)?;
444
+    let data = read_file_bytes(path)?;
445
+    let (sha1, sha256) = hash_file_data(data.as_slice());
446
+    Ok(FileHash {
447
+        optional: relative_path.contains(".lproj"),
448
+        relative_path,
449
+        sha1,
450
+        sha256,
451
+    })
452
+}
453
+
454
+fn hash_file_data(data: &[u8]) -> ([u8; 20], [u8; 32]) {
455
+    if data.len() >= 1024 * 1024 {
456
+        rayon::join(
457
+            || sha1::Sha1::digest(data).into(),
458
+            || sha2::Sha256::digest(data).into(),
459
+        )
460
+    } else {
461
+        (
462
+            sha1::Sha1::digest(data).into(),
463
+            sha2::Sha256::digest(data).into(),
464
+        )
465
+    }
466
+}
467
+
468
+fn encode_code_resources(maps: &CodeResourcesMaps) -> Result<Vec<u8>> {
469
+    let mut code_resources = Dictionary::new();
470
+    code_resources.insert("files".to_string(), Value::Dictionary(maps.files.clone()));
471
+    code_resources.insert("files2".to_string(), Value::Dictionary(maps.files2.clone()));
472
+    code_resources.insert("rules".to_string(), rules());
473
+    code_resources.insert("rules2".to_string(), rules2());
474
+
475
+    let mut data = Vec::new();
476
+    plist::to_writer_xml(&mut data, &Value::Dictionary(code_resources))?;
477
+    Ok(data)
478
+}
479
+
480
+fn rules() -> Value {
481
+    let mut rules = Dictionary::new();
482
+    rules.insert("^.*".to_string(), Value::Boolean(true));
483
+    rules.insert(
484
+        "^.*\\.lproj/".to_string(),
485
+        weighted_rule([("optional", Value::Boolean(true))], 1000.0),
486
+    );
487
+    rules.insert(
488
+        "^.*\\.lproj/locversion.plist$".to_string(),
489
+        weighted_rule([("omit", Value::Boolean(true))], 1100.0),
490
+    );
491
+    rules.insert("^Base\\.lproj/".to_string(), weight_only_rule(1010.0));
492
+    rules.insert("^version.plist$".to_string(), Value::Boolean(true));
493
+    Value::Dictionary(rules)
494
+}
495
+
496
+fn rules2() -> Value {
497
+    let mut rules = Dictionary::new();
498
+    rules.insert(".*\\.dSYM($|/)".to_string(), weight_only_rule(11.0));
499
+    rules.insert(
500
+        "^(.*/)?\\.DS_Store$".to_string(),
501
+        weighted_rule([("omit", Value::Boolean(true))], 2000.0),
502
+    );
503
+    rules.insert("^.*".to_string(), Value::Boolean(true));
504
+    rules.insert(
505
+        "^.*\\.lproj/".to_string(),
506
+        weighted_rule([("optional", Value::Boolean(true))], 1000.0),
507
+    );
508
+    rules.insert(
509
+        "^.*\\.lproj/locversion.plist$".to_string(),
510
+        weighted_rule([("omit", Value::Boolean(true))], 1100.0),
511
+    );
512
+    rules.insert("^Base\\.lproj/".to_string(), weight_only_rule(1010.0));
513
+    rules.insert(
514
+        "^Info\\.plist$".to_string(),
515
+        weighted_rule([("omit", Value::Boolean(true))], 20.0),
516
+    );
517
+    rules.insert(
518
+        "^PkgInfo$".to_string(),
519
+        weighted_rule([("omit", Value::Boolean(true))], 20.0),
520
+    );
521
+    rules.insert(
522
+        "^embedded\\.provisionprofile$".to_string(),
523
+        weight_only_rule(20.0),
524
+    );
525
+    rules.insert("^version\\.plist$".to_string(), weight_only_rule(20.0));
526
+    Value::Dictionary(rules)
527
+}
528
+
529
+fn weighted_rule<const N: usize>(entries: [(&str, Value); N], weight: f64) -> Value {
530
+    let mut dict = Dictionary::new();
531
+    for (key, value) in entries {
532
+        dict.insert(key.to_string(), value);
533
+    }
534
+    dict.insert("weight".to_string(), Value::Real(weight));
535
+    Value::Dictionary(dict)
536
+}
537
+
538
+fn weight_only_rule(weight: f64) -> Value {
539
+    weighted_rule::<0>([], weight)
540
+}
541
+
542
+fn encode_binary_plist(dict: &Dictionary) -> Result<Vec<u8>> {
543
+    let mut data = Vec::new();
544
+    plist::to_writer_binary(&mut data, &Value::Dictionary(dict.clone()))?;
545
+    Ok(data)
546
+}
547
+
548
+fn relative_path_string(root: &Path, path: &Path) -> Result<String> {
549
+    let relative = path
550
+        .strip_prefix(root)
551
+        .map_err(|_| CodeSignError::InvalidBundlePath(path.to_path_buf()))?;
552
+    path_to_forward_slashes(relative)
553
+}
554
+
555
+fn path_to_forward_slashes(path: &Path) -> Result<String> {
556
+    let mut parts = Vec::new();
557
+    for component in path.components() {
558
+        parts.push(
559
+            component
560
+                .as_os_str()
561
+                .to_str()
562
+                .ok_or_else(|| CodeSignError::InvalidBundlePath(path.to_path_buf()))?,
563
+        );
564
+    }
565
+    Ok(parts.join("/"))
566
+}
567
+
568
+fn join_relative(root: &str, child: &str) -> String {
569
+    if root.is_empty() {
570
+        child.to_string()
571
+    } else if child.is_empty() {
572
+        root.to_string()
573
+    } else {
574
+        format!("{root}/{child}")
575
+    }
576
+}
577
+
578
+#[cfg(test)]
579
+mod tests {
580
+    use super::*;
581
+
582
+    fn test_hash(relative_path: &str) -> FileHash {
583
+        FileHash {
584
+            relative_path: relative_path.to_string(),
585
+            sha1: [1; 20],
586
+            sha256: [2; 32],
587
+            optional: false,
588
+        }
589
+    }
590
+
591
+    #[test]
592
+    fn files2_omits_only_the_current_bundles_info_and_pkginfo() {
593
+        let mut child = CodeResourcesMaps::default();
594
+        child.insert_hash(test_hash("Info.plist"));
595
+        child.insert_hash(test_hash("PkgInfo"));
596
+
597
+        assert!(child.files.contains_key("Info.plist"));
598
+        assert!(!child.files2.contains_key("Info.plist"));
599
+        assert!(!child.files2.contains_key("PkgInfo"));
600
+
601
+        let mut parent = CodeResourcesMaps::default();
602
+        parent.merge_rerooted("PlugIns/Widget.appex", &child);
603
+
604
+        assert!(
605
+            parent
606
+                .files2
607
+                .contains_key("PlugIns/Widget.appex/Info.plist")
608
+        );
609
+        assert!(parent.files2.contains_key("PlugIns/Widget.appex/PkgInfo"));
610
+    }
611
+
612
+    #[test]
613
+    fn direct_dylibs_are_hashed_from_their_final_signed_contents() {
614
+        let test_root = std::env::temp_dir().join(format!(
615
+            "apple-codesign-dylib-resources-{}-{}",
616
+            std::process::id(),
617
+            std::time::SystemTime::now()
618
+                .duration_since(std::time::UNIX_EPOCH)
619
+                .unwrap()
620
+                .as_nanos()
621
+        ));
622
+        let dylib = test_root.join("Frameworks/Patch.dylib");
623
+        fs::create_dir_all(dylib.parent().unwrap()).unwrap();
624
+        fs::write(&dylib, b"unsigned").unwrap();
625
+
626
+        let bundle_files = collect_own_bundle_files(&test_root, "Main", &[]).unwrap();
627
+        assert_eq!(bundle_files.libraries, vec![dylib.clone()]);
628
+        assert_eq!(bundle_files.resource_files, vec![dylib.clone()]);
629
+
630
+        fs::write(&dylib, b"signed").unwrap();
631
+        let hashes = hash_resource_files(&test_root, &bundle_files.resource_files).unwrap();
632
+        let expected_sha256: [u8; 32] = sha2::Sha256::digest(b"signed").into();
633
+        assert_eq!(hashes[0].sha256, expected_sha256);
634
+
635
+        fs::remove_dir_all(test_root).unwrap();
636
+    }
637
+
638
+    #[test]
639
+    fn parent_seals_nested_bundle_code_resources() {
640
+        let test_root = std::env::temp_dir().join(format!(
641
+            "apple-codesign-nested-resources-{}-{}",
642
+            std::process::id(),
643
+            std::time::SystemTime::now()
644
+                .duration_since(std::time::UNIX_EPOCH)
645
+                .unwrap()
646
+                .as_nanos()
647
+        ));
648
+        let child_path = test_root.join("Frameworks/Example.framework");
649
+        let signature_path = child_path.join(CODE_SIGNATURE_DIR);
650
+        fs::create_dir_all(&signature_path).unwrap();
651
+        fs::write(child_path.join("Example"), b"executable").unwrap();
652
+        fs::write(signature_path.join(CODE_RESOURCES_FILE), b"resources").unwrap();
653
+
654
+        let mut child_resources = CodeResourcesMaps::default();
655
+        child_resources.insert_hash(test_hash("Resource.txt"));
656
+        let child = SubBundleResult {
657
+            path: child_path,
658
+            relative_root: "Frameworks/Example.framework".to_string(),
659
+            executable: "Example".to_string(),
660
+            resources: child_resources,
661
+        };
662
+        let mut parent = CodeResourcesMaps::default();
663
+
664
+        merge_sub_bundle_resources(&mut parent, &test_root, &child).unwrap();
665
+
666
+        assert!(
667
+            parent
668
+                .files2
669
+                .contains_key("Frameworks/Example.framework/Resource.txt")
670
+        );
671
+        assert!(
672
+            parent
673
+                .files2
674
+                .contains_key("Frameworks/Example.framework/Example")
675
+        );
676
+        assert!(
677
+            parent
678
+                .files2
679
+                .contains_key("Frameworks/Example.framework/_CodeSignature/CodeResources")
680
+        );
681
+
682
+        fs::remove_dir_all(test_root).unwrap();
683
+    }
684
+}
... ...
@@ -0,0 +1,72 @@
1
+use std::path::PathBuf;
2
+
3
+#[derive(Debug, thiserror::Error)]
4
+pub enum CodeSignError {
5
+    #[error("I/O error for {path}: {source}")]
6
+    Io {
7
+        path: PathBuf,
8
+        #[source]
9
+        source: std::io::Error,
10
+    },
11
+
12
+    #[error("plist error: {0}")]
13
+    Plist(#[from] plist::Error),
14
+
15
+    #[error("bundle is missing Info.plist: {0}")]
16
+    MissingInfoPlist(PathBuf),
17
+
18
+    #[error("Info.plist is missing string key {0}")]
19
+    MissingInfoString(&'static str),
20
+
21
+    #[error("invalid UTF-8 path inside bundle: {0}")]
22
+    InvalidBundlePath(PathBuf),
23
+
24
+    #[error("argument error: {0}")]
25
+    Argument(String),
26
+
27
+    #[error("Mach-O error in {path}: {message}")]
28
+    MachO { path: PathBuf, message: String },
29
+
30
+    #[error(
31
+        "Mach-O in {path} has no safe header padding for a new LC_CODE_SIGNATURE load command; requested signature reservation is {signature_len} bytes"
32
+    )]
33
+    NeedsCodeSignatureAllocation { path: PathBuf, signature_len: usize },
34
+
35
+    #[error("unsupported entitlement value for key {0}")]
36
+    UnsupportedEntitlement(String),
37
+
38
+    #[error("CMS signing error: {0}")]
39
+    Cms(String),
40
+
41
+    #[error("provisioning profile error: {0}")]
42
+    ProvisioningProfile(String),
43
+
44
+    #[error("CMS signature blob is {actual} bytes, exceeding the {reserved} byte reservation")]
45
+    SignatureTooLarge { actual: usize, reserved: usize },
46
+}
47
+
48
+impl CodeSignError {
49
+    pub(crate) fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
50
+        Self::Io {
51
+            path: path.into(),
52
+            source,
53
+        }
54
+    }
55
+
56
+    pub(crate) fn macho(path: impl Into<PathBuf>, message: impl Into<String>) -> Self {
57
+        Self::MachO {
58
+            path: path.into(),
59
+            message: message.into(),
60
+        }
61
+    }
62
+
63
+    pub(crate) fn cms(message: impl Into<String>) -> Self {
64
+        Self::Cms(message.into())
65
+    }
66
+
67
+    pub(crate) fn provisioning_profile(message: impl Into<String>) -> Self {
68
+        Self::ProvisioningProfile(message.into())
69
+    }
70
+}
71
+
72
+pub type Result<T> = std::result::Result<T, CodeSignError>;
... ...
@@ -0,0 +1,44 @@
1
+use crate::error::{CodeSignError, Result};
2
+use memmap2::{Mmap, MmapOptions};
3
+use std::fs;
4
+use std::path::Path;
5
+
6
+pub(crate) enum FileBytes {
7
+    Mapped(Mmap),
8
+    Owned(Vec<u8>),
9
+}
10
+
11
+impl FileBytes {
12
+    pub(crate) fn as_slice(&self) -> &[u8] {
13
+        match self {
14
+            Self::Mapped(map) => map,
15
+            Self::Owned(bytes) => bytes,
16
+        }
17
+    }
18
+}
19
+
20
+pub(crate) fn read_file_bytes(path: &Path) -> Result<FileBytes> {
21
+    let file = fs::File::open(path).map_err(|source| CodeSignError::io(path, source))?;
22
+    let len = file
23
+        .metadata()
24
+        .map_err(|source| CodeSignError::io(path, source))?
25
+        .len();
26
+
27
+    if len == 0 {
28
+        return Ok(FileBytes::Owned(Vec::new()));
29
+    }
30
+
31
+    // SAFETY: every mapped read in this crate is created from a file opened for
32
+    // read-only use and is only exposed as an immutable byte slice. During our
33
+    // own signing flow, files are mapped after any planned in-process rewrites
34
+    // for that file and before we write the signed replacement. Concurrent
35
+    // external mutation of the bundle while signing is outside the supported
36
+    // contract, matching the same precondition as the D MmFile implementation.
37
+    let mapped = unsafe { MmapOptions::new().map(&file) };
38
+    match mapped {
39
+        Ok(map) => Ok(FileBytes::Mapped(map)),
40
+        Err(_) => fs::read(path)
41
+            .map(FileBytes::Owned)
42
+            .map_err(|source| CodeSignError::io(path, source)),
43
+    }
44
+}
... ...
@@ -0,0 +1,16 @@
1
+pub mod bundle;
2
+pub mod error;
3
+mod file_bytes;
4
+pub mod macho;
5
+pub mod provisioning;
6
+pub mod rustcrypto_cms;
7
+pub mod signature;
8
+
9
+pub use bundle::{Bundle, BundleSigningSettings, CodeResourcesMaps, sign_bundle};
10
+pub use error::{CodeSignError, Result};
11
+pub use macho::{
12
+    DEFAULT_CMS_BLOB_RESERVATION, MachOSigningConfig, sign_macho_data, sign_macho_file,
13
+};
14
+pub use provisioning::ProvisioningProfile;
15
+pub use rustcrypto_cms::RustCryptoCmsSigner;
16
+pub use signature::{CmsSigner, CmsSigningRequest, EncodedCodeDirectory, HashAlgorithm};
... ...
@@ -0,0 +1,1175 @@
1
+use crate::error::{CodeSignError, Result};
2
+use crate::file_bytes::read_file_bytes;
3
+use crate::signature::{
4
+    CmsSigner, CodeDirectoryParams, ExecutableSegment, SuperblobSizePlan, der_entitlements_blob,
5
+    empty_requirements_blob, encode_embedded_signature, entitlement_exec_flags, entitlements_blob,
6
+    entitlements_xml, planned_superblob_len_from_parts,
7
+};
8
+use byteorder::{BigEndian, ByteOrder, LittleEndian};
9
+use plist::Dictionary;
10
+use rayon::prelude::*;
11
+use std::fs;
12
+use std::path::{Path, PathBuf};
13
+
14
+const MH_MAGIC: u32 = 0xfeed_face;
15
+const MH_MAGIC_64: u32 = 0xfeed_facf;
16
+const MH_EXECUTE: u32 = 0x2;
17
+
18
+const FAT_MAGIC: u32 = 0xcafe_babe;
19
+const FAT_MAGIC_64: u32 = 0xcafe_babf;
20
+
21
+const LC_SEGMENT: u32 = 0x1;
22
+const LC_SEGMENT_64: u32 = 0x19;
23
+const LC_CODE_SIGNATURE: u32 = 0x1d;
24
+
25
+const LINKEDIT_DATA_COMMAND_SIZE: usize = 16;
26
+const FAT_HEADER_SIZE: usize = 8;
27
+const FAT_ARCH_SIZE: usize = 20;
28
+const PAGE_SIZE: usize = 16 * 1024;
29
+const CODE_SIGNATURE_ALIGNMENT: usize = 16;
30
+const SECTION_TYPE: u32 = 0xff;
31
+const S_ZEROFILL: u32 = 0x1;
32
+const S_THREAD_LOCAL_ZEROFILL: u32 = 0x11;
33
+
34
+pub const DEFAULT_CMS_BLOB_RESERVATION: usize = 16 * 1024;
35
+
36
+#[derive(Clone)]
37
+pub struct MachOSigningConfig<'a> {
38
+    pub identifier: &'a str,
39
+    pub team_id: &'a str,
40
+    pub entitlements: &'a Dictionary,
41
+    pub info_plist: Option<&'a [u8]>,
42
+    pub code_resources: Option<&'a [u8]>,
43
+    pub cms_signer: Option<&'a dyn CmsSigner>,
44
+    pub cms_blob_reservation: usize,
45
+}
46
+
47
+impl<'a> MachOSigningConfig<'a> {
48
+    pub fn new(
49
+        identifier: &'a str,
50
+        team_id: &'a str,
51
+        entitlements: &'a Dictionary,
52
+        cms_signer: Option<&'a dyn CmsSigner>,
53
+    ) -> Self {
54
+        Self {
55
+            identifier,
56
+            team_id,
57
+            entitlements,
58
+            info_plist: None,
59
+            code_resources: None,
60
+            cms_signer,
61
+            cms_blob_reservation: DEFAULT_CMS_BLOB_RESERVATION,
62
+        }
63
+    }
64
+}
65
+
66
+pub fn sign_macho_file(path: &Path, config: &MachOSigningConfig<'_>) -> Result<()> {
67
+    let original = read_file_bytes(path)?;
68
+    let signed = sign_macho_data(path, original.as_slice(), config)?;
69
+    fs::write(path, signed).map_err(|source| CodeSignError::io(path, source))?;
70
+    Ok(())
71
+}
72
+
73
+pub fn sign_macho_data(
74
+    path: &Path,
75
+    data: &[u8],
76
+    config: &MachOSigningConfig<'_>,
77
+) -> Result<Vec<u8>> {
78
+    sign_macho_owned(path, data.to_vec(), config)
79
+}
80
+
81
+fn sign_macho_owned(
82
+    path: &Path,
83
+    data: Vec<u8>,
84
+    config: &MachOSigningConfig<'_>,
85
+) -> Result<Vec<u8>> {
86
+    if let Some(fat_arches) = parse_fat_arches(path, &data)? {
87
+        let signed_arches = fat_arches
88
+            .par_iter()
89
+            .map(|arch| {
90
+                let bytes = data[arch.offset..arch.offset + arch.size].to_vec();
91
+                let signed = sign_thin_macho(path, bytes, config)?;
92
+                Ok(SignedArch {
93
+                    cputype: signed.cputype,
94
+                    cpusubtype: signed.cpusubtype,
95
+                    align: arch.align,
96
+                    data: signed.data,
97
+                })
98
+            })
99
+            .collect::<Result<Vec<_>>>()?;
100
+
101
+        rebuild_fat(path, &signed_arches)
102
+    } else {
103
+        Ok(sign_thin_macho(path, data, config)?.data)
104
+    }
105
+}
106
+
107
+fn sign_thin_macho(
108
+    path: &Path,
109
+    data: Vec<u8>,
110
+    config: &MachOSigningConfig<'_>,
111
+) -> Result<SignedThin> {
112
+    let mut macho = ThinMachO::parse(path, data)?;
113
+
114
+    let requirements = empty_requirements_blob();
115
+    let entitlements_xml = entitlements_xml(config.entitlements)?;
116
+    let entitlements = entitlements_blob(&entitlements_xml);
117
+    let der_entitlements = if macho.is_executable() {
118
+        Some(der_entitlements_blob(config.entitlements)?)
119
+    } else {
120
+        None
121
+    };
122
+
123
+    let mut cms_blob_reservation = config.cms_blob_reservation;
124
+
125
+    for _ in 0..8 {
126
+        let code_limit = macho.code_signature_offset().unwrap_or(macho.data.len());
127
+        let planned_len = planned_superblob_len_from_parts(SuperblobSizePlan {
128
+            identifier: config.identifier,
129
+            team_id: config.team_id,
130
+            code_limit,
131
+            is_executable: macho.is_executable(),
132
+            requirements_blob_len: requirements.len(),
133
+            entitlements_blob_len: entitlements.len(),
134
+            der_entitlements_blob_len: der_entitlements.as_ref().map(Vec::len),
135
+            has_cms_signature: config.cms_signer.is_some(),
136
+            cms_blob_reservation,
137
+        });
138
+
139
+        macho.reserve_code_signature(planned_len)?;
140
+
141
+        let code_limit = macho
142
+            .code_signature_offset()
143
+            .ok_or_else(|| CodeSignError::macho(path, "missing LC_CODE_SIGNATURE after reserve"))?;
144
+        let exec_flags = entitlement_exec_flags(config.entitlements, macho.is_executable());
145
+        let params = CodeDirectoryParams {
146
+            identifier: config.identifier,
147
+            team_id: config.team_id,
148
+            macho_data: &macho.data,
149
+            code_limit,
150
+            executable_segment: ExecutableSegment {
151
+                base: macho.exec_segment.base,
152
+                limit: macho.exec_segment.limit,
153
+                flags: exec_flags,
154
+            },
155
+            is_executable: macho.is_executable(),
156
+            requirements_blob: &requirements,
157
+            entitlements_blob: &entitlements,
158
+            der_entitlements_blob: der_entitlements.as_deref(),
159
+            info_plist: config.info_plist,
160
+            code_resources: config.code_resources,
161
+        };
162
+
163
+        match encode_embedded_signature(&params, config.cms_signer, cms_blob_reservation) {
164
+            Ok(signature) => {
165
+                macho.write_code_signature(&signature)?;
166
+                return Ok(SignedThin {
167
+                    cputype: macho.cputype,
168
+                    cpusubtype: macho.cpusubtype,
169
+                    data: macho.data,
170
+                });
171
+            }
172
+            Err(CodeSignError::SignatureTooLarge { actual, .. }) => {
173
+                cms_blob_reservation = next_reservation(cms_blob_reservation, actual);
174
+            }
175
+            Err(err) => return Err(err),
176
+        }
177
+    }
178
+
179
+    Err(CodeSignError::macho(
180
+        path,
181
+        "CMS signature did not fit after repeated reservation growth",
182
+    ))
183
+}
184
+
185
+fn next_reservation(current: usize, actual: usize) -> usize {
186
+    let doubled = current.saturating_mul(2).max(DEFAULT_CMS_BLOB_RESERVATION);
187
+    align_to(doubled.max(actual), 4096)
188
+}
189
+
190
+#[derive(Debug)]
191
+struct ThinMachO {
192
+    path: PathBuf,
193
+    data: Vec<u8>,
194
+    cputype: i32,
195
+    cpusubtype: i32,
196
+    filetype: u32,
197
+    ncmds: u32,
198
+    sizeofcmds: u32,
199
+    header_size: usize,
200
+    code_signature_command_offset: Option<usize>,
201
+    linkedit_command_offset: Option<usize>,
202
+    header_pad_limit: usize,
203
+    exec_segment: SegmentBounds,
204
+}
205
+
206
+#[derive(Clone, Copy, Debug, Default)]
207
+struct SegmentBounds {
208
+    base: u64,
209
+    limit: u64,
210
+}
211
+
212
+impl ThinMachO {
213
+    fn parse(path: &Path, data: Vec<u8>) -> Result<Self> {
214
+        ensure_len(path, &data, 28, "Mach-O header")?;
215
+
216
+        let magic = LittleEndian::read_u32(&data[0..4]);
217
+        let is_64 = match magic {
218
+            MH_MAGIC => false,
219
+            MH_MAGIC_64 => true,
220
+            _ => {
221
+                return Err(CodeSignError::macho(
222
+                    path,
223
+                    format!("unsupported Mach-O magic 0x{magic:08x}"),
224
+                ));
225
+            }
226
+        };
227
+
228
+        let header_size = if is_64 { 32 } else { 28 };
229
+        ensure_len(path, &data, header_size, "Mach-O header")?;
230
+
231
+        let cputype = LittleEndian::read_i32(&data[4..8]);
232
+        let cpusubtype = LittleEndian::read_i32(&data[8..12]);
233
+        let filetype = LittleEndian::read_u32(&data[12..16]);
234
+        let ncmds = LittleEndian::read_u32(&data[16..20]);
235
+        let sizeofcmds = LittleEndian::read_u32(&data[20..24]);
236
+        let commands_end = checked_add(path, header_size, sizeofcmds as usize, "load commands")?;
237
+        ensure_len(path, &data, commands_end, "load commands")?;
238
+
239
+        let mut offset = header_size;
240
+        let mut code_signature_command_offset = None;
241
+        let mut linkedit_command_offset = None;
242
+        let mut first_section_offset = None;
243
+        let mut exec_segment = SegmentBounds::default();
244
+
245
+        for _ in 0..ncmds {
246
+            ensure_len(path, &data, offset + 8, "load command")?;
247
+            let cmd = LittleEndian::read_u32(&data[offset..offset + 4]);
248
+            let cmdsize = LittleEndian::read_u32(&data[offset + 4..offset + 8]) as usize;
249
+            if cmdsize < 8 {
250
+                return Err(CodeSignError::macho(
251
+                    path,
252
+                    "load command is smaller than 8 bytes",
253
+                ));
254
+            }
255
+            let command_end = checked_add(path, offset, cmdsize, "load command")?;
256
+            if command_end > commands_end {
257
+                return Err(CodeSignError::macho(
258
+                    path,
259
+                    "load command extends beyond sizeofcmds",
260
+                ));
261
+            }
262
+
263
+            match cmd {
264
+                LC_SEGMENT => {
265
+                    parse_segment_32(
266
+                        path,
267
+                        &data,
268
+                        offset,
269
+                        cmdsize,
270
+                        &mut linkedit_command_offset,
271
+                        &mut exec_segment,
272
+                        &mut first_section_offset,
273
+                    )?;
274
+                }
275
+                LC_SEGMENT_64 => {
276
+                    parse_segment_64(
277
+                        path,
278
+                        &data,
279
+                        offset,
280
+                        cmdsize,
281
+                        &mut linkedit_command_offset,
282
+                        &mut exec_segment,
283
+                        &mut first_section_offset,
284
+                    )?;
285
+                }
286
+                LC_CODE_SIGNATURE => {
287
+                    if cmdsize < LINKEDIT_DATA_COMMAND_SIZE {
288
+                        return Err(CodeSignError::macho(path, "LC_CODE_SIGNATURE is too small"));
289
+                    }
290
+                    code_signature_command_offset = Some(offset);
291
+                }
292
+                _ => {}
293
+            }
294
+
295
+            offset = command_end;
296
+        }
297
+
298
+        let header_pad_limit = first_section_offset
299
+            .filter(|offset| *offset > commands_end)
300
+            .unwrap_or(data.len())
301
+            .min(data.len());
302
+
303
+        Ok(Self {
304
+            path: path.to_path_buf(),
305
+            data,
306
+            cputype,
307
+            cpusubtype,
308
+            filetype,
309
+            ncmds,
310
+            sizeofcmds,
311
+            header_size,
312
+            code_signature_command_offset,
313
+            linkedit_command_offset,
314
+            header_pad_limit,
315
+            exec_segment,
316
+        })
317
+    }
318
+
319
+    fn is_executable(&self) -> bool {
320
+        self.filetype == MH_EXECUTE
321
+    }
322
+
323
+    fn code_signature_offset(&self) -> Option<usize> {
324
+        self.code_signature_command_offset
325
+            .map(|offset| LittleEndian::read_u32(&self.data[offset + 8..offset + 12]) as usize)
326
+    }
327
+
328
+    fn reserve_code_signature(&mut self, signature_len: usize) -> Result<()> {
329
+        if self.code_signature_command_offset.is_none() {
330
+            self.install_code_signature_command(signature_len)?;
331
+        }
332
+
333
+        let command_offset = self
334
+            .code_signature_command_offset
335
+            .ok_or_else(|| CodeSignError::macho(&self.path, "missing LC_CODE_SIGNATURE"))?;
336
+        let dataoff =
337
+            LittleEndian::read_u32(&self.data[command_offset + 8..command_offset + 12]) as usize;
338
+        let datasize =
339
+            LittleEndian::read_u32(&self.data[command_offset + 12..command_offset + 16]) as usize;
340
+
341
+        if datasize == 0 {
342
+            return self.allocate_code_signature(command_offset, signature_len);
343
+        }
344
+
345
+        if signature_len <= datasize {
346
+            self.zero_signature_region(dataoff, datasize)?;
347
+            return Ok(());
348
+        }
349
+
350
+        let old_end = checked_add(&self.path, dataoff, datasize, "code signature")?;
351
+        if old_end < self.data.len() {
352
+            return Err(CodeSignError::macho(
353
+                &self.path,
354
+                "cannot grow a code signature that is not at the end of the Mach-O slice",
355
+            ));
356
+        }
357
+
358
+        let linkedit_offset = self.linkedit_command_offset.ok_or_else(|| {
359
+            CodeSignError::macho(&self.path, "missing __LINKEDIT segment for code signature")
360
+        })?;
361
+
362
+        let extra_file_size = signature_len - datasize;
363
+        let extra_vm_size = page_ceil(signature_len).saturating_sub(page_ceil(datasize));
364
+        self.grow_linkedit_segment(linkedit_offset, extra_file_size, extra_vm_size)?;
365
+
366
+        self.data.truncate(dataoff);
367
+        self.data.resize(dataoff + signature_len, 0);
368
+        LittleEndian::write_u32(
369
+            &mut self.data[command_offset + 8..command_offset + 12],
370
+            dataoff.try_into().map_err(|_| {
371
+                CodeSignError::macho(&self.path, "code signature offset exceeds u32")
372
+            })?,
373
+        );
374
+        LittleEndian::write_u32(
375
+            &mut self.data[command_offset + 12..command_offset + 16],
376
+            signature_len
377
+                .try_into()
378
+                .map_err(|_| CodeSignError::macho(&self.path, "code signature size exceeds u32"))?,
379
+        );
380
+
381
+        Ok(())
382
+    }
383
+
384
+    fn install_code_signature_command(&mut self, signature_len: usize) -> Result<()> {
385
+        let command_offset = self.header_size + self.sizeofcmds as usize;
386
+        let command_end = command_offset + LINKEDIT_DATA_COMMAND_SIZE;
387
+
388
+        if command_end > self.header_pad_limit {
389
+            return Err(CodeSignError::NeedsCodeSignatureAllocation {
390
+                path: self.path.clone(),
391
+                signature_len,
392
+            });
393
+        }
394
+
395
+        if command_end > self.data.len() {
396
+            return Err(CodeSignError::macho(
397
+                &self.path,
398
+                "Mach-O ended before available load command padding",
399
+            ));
400
+        }
401
+
402
+        if self.data[command_offset..command_end]
403
+            .iter()
404
+            .any(|byte| *byte != 0)
405
+        {
406
+            return Err(CodeSignError::NeedsCodeSignatureAllocation {
407
+                path: self.path.clone(),
408
+                signature_len,
409
+            });
410
+        }
411
+
412
+        LittleEndian::write_u32(
413
+            &mut self.data[command_offset..command_offset + 4],
414
+            LC_CODE_SIGNATURE,
415
+        );
416
+        LittleEndian::write_u32(
417
+            &mut self.data[command_offset + 4..command_offset + 8],
418
+            LINKEDIT_DATA_COMMAND_SIZE as u32,
419
+        );
420
+        LittleEndian::write_u32(&mut self.data[command_offset + 8..command_offset + 12], 0);
421
+        LittleEndian::write_u32(&mut self.data[command_offset + 12..command_offset + 16], 0);
422
+
423
+        self.ncmds += 1;
424
+        self.sizeofcmds += LINKEDIT_DATA_COMMAND_SIZE as u32;
425
+        LittleEndian::write_u32(&mut self.data[16..20], self.ncmds);
426
+        LittleEndian::write_u32(&mut self.data[20..24], self.sizeofcmds);
427
+        self.code_signature_command_offset = Some(command_offset);
428
+
429
+        Ok(())
430
+    }
431
+
432
+    fn allocate_code_signature(
433
+        &mut self,
434
+        command_offset: usize,
435
+        signature_len: usize,
436
+    ) -> Result<()> {
437
+        let linkedit_offset = self.linkedit_command_offset.ok_or_else(|| {
438
+            CodeSignError::macho(&self.path, "missing __LINKEDIT segment for code signature")
439
+        })?;
440
+        let linkedit_end = self.linkedit_file_end(linkedit_offset)?;
441
+        if linkedit_end > self.data.len() {
442
+            return Err(CodeSignError::macho(
443
+                &self.path,
444
+                "__LINKEDIT extends beyond the Mach-O slice",
445
+            ));
446
+        }
447
+
448
+        let dataoff = align_to(linkedit_end, CODE_SIGNATURE_ALIGNMENT);
449
+        let padding = dataoff - linkedit_end;
450
+        self.resize_linkedit_for_new_signature(linkedit_offset, padding, signature_len)?;
451
+
452
+        self.data.truncate(linkedit_end);
453
+        self.data.resize(dataoff + signature_len, 0);
454
+
455
+        LittleEndian::write_u32(
456
+            &mut self.data[command_offset + 8..command_offset + 12],
457
+            dataoff.try_into().map_err(|_| {
458
+                CodeSignError::macho(&self.path, "code signature offset exceeds u32")
459
+            })?,
460
+        );
461
+        LittleEndian::write_u32(
462
+            &mut self.data[command_offset + 12..command_offset + 16],
463
+            signature_len
464
+                .try_into()
465
+                .map_err(|_| CodeSignError::macho(&self.path, "code signature size exceeds u32"))?,
466
+        );
467
+
468
+        Ok(())
469
+    }
470
+
471
+    fn write_code_signature(&mut self, signature: &[u8]) -> Result<()> {
472
+        let command_offset = self
473
+            .code_signature_command_offset
474
+            .ok_or_else(|| CodeSignError::macho(&self.path, "missing LC_CODE_SIGNATURE"))?;
475
+        let dataoff =
476
+            LittleEndian::read_u32(&self.data[command_offset + 8..command_offset + 12]) as usize;
477
+        let datasize =
478
+            LittleEndian::read_u32(&self.data[command_offset + 12..command_offset + 16]) as usize;
479
+
480
+        if signature.len() > datasize {
481
+            return Err(CodeSignError::macho(
482
+                &self.path,
483
+                "encoded signature exceeded reserved LC_CODE_SIGNATURE size",
484
+            ));
485
+        }
486
+
487
+        let end = checked_add(&self.path, dataoff, datasize, "code signature")?;
488
+        ensure_len(&self.path, &self.data, end, "code signature")?;
489
+        self.data[dataoff..dataoff + signature.len()].copy_from_slice(signature);
490
+        self.data[dataoff + signature.len()..end].fill(0);
491
+        Ok(())
492
+    }
493
+
494
+    fn zero_signature_region(&mut self, dataoff: usize, datasize: usize) -> Result<()> {
495
+        let end = checked_add(&self.path, dataoff, datasize, "code signature")?;
496
+        ensure_len(&self.path, &self.data, end, "code signature")?;
497
+        self.data[dataoff..end].fill(0);
498
+        Ok(())
499
+    }
500
+
501
+    fn grow_linkedit_segment(
502
+        &mut self,
503
+        command_offset: usize,
504
+        extra_file_size: usize,
505
+        extra_vm_size: usize,
506
+    ) -> Result<()> {
507
+        let cmd = LittleEndian::read_u32(&self.data[command_offset..command_offset + 4]);
508
+        match cmd {
509
+            LC_SEGMENT => {
510
+                let filesize_offset = command_offset + 36;
511
+                let vmsize_offset = command_offset + 28;
512
+                let filesize =
513
+                    LittleEndian::read_u32(&self.data[filesize_offset..filesize_offset + 4])
514
+                        as usize;
515
+                let vmsize =
516
+                    LittleEndian::read_u32(&self.data[vmsize_offset..vmsize_offset + 4]) as usize;
517
+                LittleEndian::write_u32(
518
+                    &mut self.data[filesize_offset..filesize_offset + 4],
519
+                    checked_add(&self.path, filesize, extra_file_size, "__LINKEDIT filesize")?
520
+                        .try_into()
521
+                        .map_err(|_| {
522
+                            CodeSignError::macho(&self.path, "__LINKEDIT filesize exceeds u32")
523
+                        })?,
524
+                );
525
+                LittleEndian::write_u32(
526
+                    &mut self.data[vmsize_offset..vmsize_offset + 4],
527
+                    checked_add(&self.path, vmsize, extra_vm_size, "__LINKEDIT vmsize")?
528
+                        .try_into()
529
+                        .map_err(|_| {
530
+                            CodeSignError::macho(&self.path, "__LINKEDIT vmsize exceeds u32")
531
+                        })?,
532
+                );
533
+            }
534
+            LC_SEGMENT_64 => {
535
+                let filesize_offset = command_offset + 48;
536
+                let vmsize_offset = command_offset + 32;
537
+                let filesize =
538
+                    LittleEndian::read_u64(&self.data[filesize_offset..filesize_offset + 8]);
539
+                let vmsize = LittleEndian::read_u64(&self.data[vmsize_offset..vmsize_offset + 8]);
540
+                LittleEndian::write_u64(
541
+                    &mut self.data[filesize_offset..filesize_offset + 8],
542
+                    filesize
543
+                        .checked_add(extra_file_size as u64)
544
+                        .ok_or_else(|| {
545
+                            CodeSignError::macho(&self.path, "__LINKEDIT filesize overflow")
546
+                        })?,
547
+                );
548
+                LittleEndian::write_u64(
549
+                    &mut self.data[vmsize_offset..vmsize_offset + 8],
550
+                    vmsize.checked_add(extra_vm_size as u64).ok_or_else(|| {
551
+                        CodeSignError::macho(&self.path, "__LINKEDIT vmsize overflow")
552
+                    })?,
553
+                );
554
+            }
555
+            _ => {
556
+                return Err(CodeSignError::macho(
557
+                    &self.path,
558
+                    "__LINKEDIT command was not a segment command",
559
+                ));
560
+            }
561
+        }
562
+
563
+        Ok(())
564
+    }
565
+
566
+    fn linkedit_file_end(&self, command_offset: usize) -> Result<usize> {
567
+        let cmd = LittleEndian::read_u32(&self.data[command_offset..command_offset + 4]);
568
+        match cmd {
569
+            LC_SEGMENT => {
570
+                let fileoff =
571
+                    LittleEndian::read_u32(&self.data[command_offset + 32..command_offset + 36])
572
+                        as usize;
573
+                let filesize =
574
+                    LittleEndian::read_u32(&self.data[command_offset + 36..command_offset + 40])
575
+                        as usize;
576
+                checked_add(&self.path, fileoff, filesize, "__LINKEDIT end")
577
+            }
578
+            LC_SEGMENT_64 => {
579
+                let fileoff =
580
+                    LittleEndian::read_u64(&self.data[command_offset + 40..command_offset + 48]);
581
+                let filesize =
582
+                    LittleEndian::read_u64(&self.data[command_offset + 48..command_offset + 56]);
583
+                let end = fileoff
584
+                    .checked_add(filesize)
585
+                    .ok_or_else(|| CodeSignError::macho(&self.path, "__LINKEDIT end overflow"))?;
586
+                usize::try_from(end)
587
+                    .map_err(|_| CodeSignError::macho(&self.path, "__LINKEDIT end exceeds usize"))
588
+            }
589
+            _ => Err(CodeSignError::macho(
590
+                &self.path,
591
+                "__LINKEDIT command was not a segment command",
592
+            )),
593
+        }
594
+    }
595
+
596
+    fn resize_linkedit_for_new_signature(
597
+        &mut self,
598
+        command_offset: usize,
599
+        padding: usize,
600
+        signature_len: usize,
601
+    ) -> Result<()> {
602
+        let extra_file_size = checked_add(
603
+            &self.path,
604
+            padding,
605
+            signature_len,
606
+            "__LINKEDIT code signature growth",
607
+        )?;
608
+        let cmd = LittleEndian::read_u32(&self.data[command_offset..command_offset + 4]);
609
+
610
+        match cmd {
611
+            LC_SEGMENT => {
612
+                let filesize_offset = command_offset + 36;
613
+                let vmsize_offset = command_offset + 28;
614
+                let filesize =
615
+                    LittleEndian::read_u32(&self.data[filesize_offset..filesize_offset + 4])
616
+                        as usize;
617
+                let vmsize =
618
+                    LittleEndian::read_u32(&self.data[vmsize_offset..vmsize_offset + 4]) as usize;
619
+                let new_filesize =
620
+                    checked_add(&self.path, filesize, extra_file_size, "__LINKEDIT filesize")?;
621
+                let new_vmsize = vmsize.max(page_ceil(new_filesize));
622
+
623
+                LittleEndian::write_u32(
624
+                    &mut self.data[filesize_offset..filesize_offset + 4],
625
+                    new_filesize.try_into().map_err(|_| {
626
+                        CodeSignError::macho(&self.path, "__LINKEDIT filesize exceeds u32")
627
+                    })?,
628
+                );
629
+                LittleEndian::write_u32(
630
+                    &mut self.data[vmsize_offset..vmsize_offset + 4],
631
+                    new_vmsize.try_into().map_err(|_| {
632
+                        CodeSignError::macho(&self.path, "__LINKEDIT vmsize exceeds u32")
633
+                    })?,
634
+                );
635
+            }
636
+            LC_SEGMENT_64 => {
637
+                let filesize_offset = command_offset + 48;
638
+                let vmsize_offset = command_offset + 32;
639
+                let filesize =
640
+                    LittleEndian::read_u64(&self.data[filesize_offset..filesize_offset + 8]);
641
+                let vmsize = LittleEndian::read_u64(&self.data[vmsize_offset..vmsize_offset + 8]);
642
+                let new_filesize =
643
+                    filesize
644
+                        .checked_add(extra_file_size as u64)
645
+                        .ok_or_else(|| {
646
+                            CodeSignError::macho(&self.path, "__LINKEDIT filesize overflow")
647
+                        })?;
648
+                let new_vmsize = vmsize.max(page_ceil_u64(new_filesize));
649
+
650
+                LittleEndian::write_u64(
651
+                    &mut self.data[filesize_offset..filesize_offset + 8],
652
+                    new_filesize,
653
+                );
654
+                LittleEndian::write_u64(
655
+                    &mut self.data[vmsize_offset..vmsize_offset + 8],
656
+                    new_vmsize,
657
+                );
658
+            }
659
+            _ => {
660
+                return Err(CodeSignError::macho(
661
+                    &self.path,
662
+                    "__LINKEDIT command was not a segment command",
663
+                ));
664
+            }
665
+        }
666
+
667
+        Ok(())
668
+    }
669
+}
670
+
671
+#[derive(Debug)]
672
+struct SignedThin {
673
+    cputype: i32,
674
+    cpusubtype: i32,
675
+    data: Vec<u8>,
676
+}
677
+
678
+#[derive(Clone, Debug)]
679
+struct FatArch {
680
+    offset: usize,
681
+    size: usize,
682
+    align: u32,
683
+}
684
+
685
+#[derive(Debug)]
686
+struct SignedArch {
687
+    cputype: i32,
688
+    cpusubtype: i32,
689
+    align: u32,
690
+    data: Vec<u8>,
691
+}
692
+
693
+fn parse_fat_arches(path: &Path, data: &[u8]) -> Result<Option<Vec<FatArch>>> {
694
+    if data.len() < 4 {
695
+        return Err(CodeSignError::macho(path, "file is too small"));
696
+    }
697
+
698
+    let magic = BigEndian::read_u32(&data[0..4]);
699
+    if magic == FAT_MAGIC_64 {
700
+        return Err(CodeSignError::macho(
701
+            path,
702
+            "64-bit fat Mach-O headers are not supported yet",
703
+        ));
704
+    }
705
+    if magic != FAT_MAGIC {
706
+        return Ok(None);
707
+    }
708
+
709
+    ensure_len(path, data, FAT_HEADER_SIZE, "fat header")?;
710
+    let nfat_arch = BigEndian::read_u32(&data[4..8]) as usize;
711
+    let table_len = checked_mul(path, nfat_arch, FAT_ARCH_SIZE, "fat architecture table")?;
712
+    ensure_len(
713
+        path,
714
+        data,
715
+        FAT_HEADER_SIZE + table_len,
716
+        "fat architecture table",
717
+    )?;
718
+
719
+    let mut arches = Vec::with_capacity(nfat_arch);
720
+    for index in 0..nfat_arch {
721
+        let offset = FAT_HEADER_SIZE + index * FAT_ARCH_SIZE;
722
+        let arch_offset = BigEndian::read_u32(&data[offset + 8..offset + 12]) as usize;
723
+        let size = BigEndian::read_u32(&data[offset + 12..offset + 16]) as usize;
724
+        let align = BigEndian::read_u32(&data[offset + 16..offset + 20]);
725
+        ensure_len(path, data, arch_offset + size, "fat architecture slice")?;
726
+        arches.push(FatArch {
727
+            offset: arch_offset,
728
+            size,
729
+            align,
730
+        });
731
+    }
732
+
733
+    Ok(Some(arches))
734
+}
735
+
736
+fn rebuild_fat(path: &Path, arches: &[SignedArch]) -> Result<Vec<u8>> {
737
+    let table_len = checked_mul(path, arches.len(), FAT_ARCH_SIZE, "fat architecture table")?;
738
+    let mut records = Vec::with_capacity(arches.len());
739
+    let mut cursor = FAT_HEADER_SIZE + table_len;
740
+
741
+    for arch in arches {
742
+        let alignment = arch_alignment(path, arch.align)?;
743
+        cursor = align_to(cursor, alignment);
744
+        records.push((cursor, arch));
745
+        cursor = checked_add(path, cursor, arch.data.len(), "fat Mach-O data")?;
746
+    }
747
+
748
+    if cursor > u32::MAX as usize {
749
+        return Err(CodeSignError::macho(
750
+            path,
751
+            "fat Mach-O exceeds 32-bit offsets",
752
+        ));
753
+    }
754
+
755
+    let mut out = vec![0; FAT_HEADER_SIZE + table_len];
756
+    BigEndian::write_u32(&mut out[0..4], FAT_MAGIC);
757
+    BigEndian::write_u32(&mut out[4..8], arches.len() as u32);
758
+
759
+    for (index, (offset, arch)) in records.iter().enumerate() {
760
+        let record_offset = FAT_HEADER_SIZE + index * FAT_ARCH_SIZE;
761
+        BigEndian::write_i32(&mut out[record_offset..record_offset + 4], arch.cputype);
762
+        BigEndian::write_i32(
763
+            &mut out[record_offset + 4..record_offset + 8],
764
+            arch.cpusubtype,
765
+        );
766
+        BigEndian::write_u32(
767
+            &mut out[record_offset + 8..record_offset + 12],
768
+            *offset as u32,
769
+        );
770
+        BigEndian::write_u32(
771
+            &mut out[record_offset + 12..record_offset + 16],
772
+            arch.data.len() as u32,
773
+        );
774
+        BigEndian::write_u32(&mut out[record_offset + 16..record_offset + 20], arch.align);
775
+    }
776
+
777
+    for (offset, arch) in records {
778
+        out.resize(offset, 0);
779
+        out.extend_from_slice(&arch.data);
780
+    }
781
+
782
+    Ok(out)
783
+}
784
+
785
+fn parse_segment_32(
786
+    path: &Path,
787
+    data: &[u8],
788
+    offset: usize,
789
+    cmdsize: usize,
790
+    linkedit_command_offset: &mut Option<usize>,
791
+    exec_segment: &mut SegmentBounds,
792
+    first_section_offset: &mut Option<usize>,
793
+) -> Result<()> {
794
+    if cmdsize < 56 {
795
+        return Err(CodeSignError::macho(path, "LC_SEGMENT is too small"));
796
+    }
797
+
798
+    let name = segment_name(&data[offset + 8..offset + 24]);
799
+    let fileoff = LittleEndian::read_u32(&data[offset + 32..offset + 36]) as u64;
800
+    let filesize = LittleEndian::read_u32(&data[offset + 36..offset + 40]) as u64;
801
+    let nsects = LittleEndian::read_u32(&data[offset + 48..offset + 52]) as usize;
802
+    if name == "__TEXT" {
803
+        exec_segment.base = fileoff;
804
+        exec_segment.limit = fileoff + filesize;
805
+    } else if name == "__LINKEDIT" {
806
+        *linkedit_command_offset = Some(offset);
807
+    }
808
+
809
+    let sections_offset = offset + 56;
810
+    let sections_size = checked_mul(path, nsects, 68, "LC_SEGMENT sections")?;
811
+    if sections_offset + sections_size > offset + cmdsize {
812
+        return Err(CodeSignError::macho(
813
+            path,
814
+            "LC_SEGMENT sections exceed cmdsize",
815
+        ));
816
+    }
817
+
818
+    if nsects == 0 {
819
+        if fileoff != 0 && filesize != 0 {
820
+            record_first_section_offset(first_section_offset, fileoff as usize);
821
+        }
822
+    } else {
823
+        for index in 0..nsects {
824
+            let section_offset = sections_offset + index * 68;
825
+            let size = LittleEndian::read_u32(&data[section_offset + 36..section_offset + 40]);
826
+            let file_offset =
827
+                LittleEndian::read_u32(&data[section_offset + 40..section_offset + 44]) as usize;
828
+            let flags = LittleEndian::read_u32(&data[section_offset + 56..section_offset + 60]);
829
+            if section_has_file_contents(size as u64, flags) {
830
+                record_first_section_offset(first_section_offset, file_offset);
831
+            }
832
+        }
833
+    }
834
+
835
+    Ok(())
836
+}
837
+
838
+fn parse_segment_64(
839
+    path: &Path,
840
+    data: &[u8],
841
+    offset: usize,
842
+    cmdsize: usize,
843
+    linkedit_command_offset: &mut Option<usize>,
844
+    exec_segment: &mut SegmentBounds,
845
+    first_section_offset: &mut Option<usize>,
846
+) -> Result<()> {
847
+    if cmdsize < 72 {
848
+        return Err(CodeSignError::macho(path, "LC_SEGMENT_64 is too small"));
849
+    }
850
+
851
+    let name = segment_name(&data[offset + 8..offset + 24]);
852
+    let fileoff = LittleEndian::read_u64(&data[offset + 40..offset + 48]);
853
+    let filesize = LittleEndian::read_u64(&data[offset + 48..offset + 56]);
854
+    let nsects = LittleEndian::read_u32(&data[offset + 64..offset + 68]) as usize;
855
+
856
+    if name == "__TEXT" {
857
+        exec_segment.base = fileoff;
858
+        exec_segment.limit = fileoff + filesize;
859
+    } else if name == "__LINKEDIT" {
860
+        *linkedit_command_offset = Some(offset);
861
+    }
862
+
863
+    let sections_offset = offset + 72;
864
+    let sections_size = checked_mul(path, nsects, 80, "LC_SEGMENT_64 sections")?;
865
+    if sections_offset + sections_size > offset + cmdsize {
866
+        return Err(CodeSignError::macho(
867
+            path,
868
+            "LC_SEGMENT_64 sections exceed cmdsize",
869
+        ));
870
+    }
871
+
872
+    if nsects == 0 {
873
+        if fileoff != 0 && filesize != 0 {
874
+            record_first_section_offset(
875
+                first_section_offset,
876
+                usize::try_from(fileoff)
877
+                    .map_err(|_| CodeSignError::macho(path, "segment file offset exceeds usize"))?,
878
+            );
879
+        }
880
+    } else {
881
+        for index in 0..nsects {
882
+            let section_offset = sections_offset + index * 80;
883
+            let size = LittleEndian::read_u64(&data[section_offset + 40..section_offset + 48]);
884
+            let file_offset =
885
+                LittleEndian::read_u32(&data[section_offset + 48..section_offset + 52]) as usize;
886
+            let flags = LittleEndian::read_u32(&data[section_offset + 68..section_offset + 72]);
887
+            if section_has_file_contents(size, flags) {
888
+                record_first_section_offset(first_section_offset, file_offset);
889
+            }
890
+        }
891
+    }
892
+
893
+    Ok(())
894
+}
895
+
896
+fn section_has_file_contents(size: u64, flags: u32) -> bool {
897
+    let section_type = flags & SECTION_TYPE;
898
+    size != 0 && section_type != S_ZEROFILL && section_type != S_THREAD_LOCAL_ZEROFILL
899
+}
900
+
901
+fn record_first_section_offset(first_section_offset: &mut Option<usize>, file_offset: usize) {
902
+    if file_offset == 0 {
903
+        return;
904
+    }
905
+
906
+    match first_section_offset {
907
+        Some(existing) if *existing <= file_offset => {}
908
+        _ => *first_section_offset = Some(file_offset),
909
+    }
910
+}
911
+
912
+fn segment_name(bytes: &[u8]) -> &str {
913
+    let end = bytes
914
+        .iter()
915
+        .position(|byte| *byte == 0)
916
+        .unwrap_or(bytes.len());
917
+    std::str::from_utf8(&bytes[..end]).unwrap_or("")
918
+}
919
+
920
+fn ensure_len(path: &Path, data: &[u8], required: usize, what: &str) -> Result<()> {
921
+    if data.len() < required {
922
+        return Err(CodeSignError::macho(
923
+            path,
924
+            format!(
925
+                "{what} requires {required} bytes, file only has {}",
926
+                data.len()
927
+            ),
928
+        ));
929
+    }
930
+    Ok(())
931
+}
932
+
933
+fn checked_add(path: &Path, a: usize, b: usize, what: &str) -> Result<usize> {
934
+    a.checked_add(b)
935
+        .ok_or_else(|| CodeSignError::macho(path, format!("{what} offset overflow")))
936
+}
937
+
938
+fn checked_mul(path: &Path, a: usize, b: usize, what: &str) -> Result<usize> {
939
+    a.checked_mul(b)
940
+        .ok_or_else(|| CodeSignError::macho(path, format!("{what} size overflow")))
941
+}
942
+
943
+fn page_ceil(value: usize) -> usize {
944
+    align_to(value, PAGE_SIZE)
945
+}
946
+
947
+fn page_ceil_u64(value: u64) -> u64 {
948
+    let alignment = PAGE_SIZE as u64;
949
+    value.saturating_add(alignment - 1) & !(alignment - 1)
950
+}
951
+
952
+fn align_to(value: usize, alignment: usize) -> usize {
953
+    debug_assert!(alignment.is_power_of_two());
954
+    (value + alignment - 1) & !(alignment - 1)
955
+}
956
+
957
+fn arch_alignment(path: &Path, align: u32) -> Result<usize> {
958
+    1usize
959
+        .checked_shl(align)
960
+        .filter(|alignment| *alignment > 0)
961
+        .ok_or_else(|| CodeSignError::macho(path, format!("invalid fat arch alignment {align}")))
962
+}
963
+
964
+#[cfg(test)]
965
+mod tests {
966
+    use super::*;
967
+    use crate::signature::{CSMAGIC_EMBEDDED_SIGNATURE, CmsSigningRequest};
968
+
969
+    const CPU_TYPE_ARM64_TEST: i32 = 0x0100_000c;
970
+
971
+    struct TestCmsSigner;
972
+
973
+    impl CmsSigner for TestCmsSigner {
974
+        fn sign(&self, _: CmsSigningRequest<'_>) -> Result<Vec<u8>> {
975
+            Ok(vec![0x30, 0x00])
976
+        }
977
+    }
978
+
979
+    #[test]
980
+    fn signs_thin_macho_by_growing_existing_code_signature() {
981
+        let macho = minimal_macho_with_empty_signature();
982
+        let entitlements = Dictionary::new();
983
+        let config = MachOSigningConfig::new("com.example.test", "TEAMID", &entitlements, None);
984
+
985
+        let signed = sign_macho_data(Path::new("TestBinary"), &macho, &config).unwrap();
986
+
987
+        let code_sig_cmd = 32 + 72 + 72;
988
+        let dataoff = LittleEndian::read_u32(&signed[code_sig_cmd + 8..code_sig_cmd + 12]) as usize;
989
+        let datasize =
990
+            LittleEndian::read_u32(&signed[code_sig_cmd + 12..code_sig_cmd + 16]) as usize;
991
+
992
+        assert!(datasize > 0);
993
+        assert_eq!(signed.len(), dataoff + datasize);
994
+        assert_eq!(
995
+            BigEndian::read_u32(&signed[dataoff..dataoff + 4]),
996
+            CSMAGIC_EMBEDDED_SIGNATURE
997
+        );
998
+    }
999
+
1000
+    #[test]
1001
+    fn cms_reservation_is_zero_padding_after_superblob() {
1002
+        let macho = minimal_macho_with_empty_signature();
1003
+        let entitlements = Dictionary::new();
1004
+        let signer = TestCmsSigner;
1005
+        let mut config =
1006
+            MachOSigningConfig::new("com.example.test", "TEAMID", &entitlements, Some(&signer));
1007
+        config.cms_blob_reservation = 1024;
1008
+
1009
+        let signed = sign_macho_data(Path::new("TestBinary"), &macho, &config).unwrap();
1010
+        let code_sig_cmd = 32 + 72 + 72;
1011
+        let dataoff = LittleEndian::read_u32(&signed[code_sig_cmd + 8..code_sig_cmd + 12]) as usize;
1012
+        let datasize =
1013
+            LittleEndian::read_u32(&signed[code_sig_cmd + 12..code_sig_cmd + 16]) as usize;
1014
+        let superblob_len = BigEndian::read_u32(&signed[dataoff + 4..dataoff + 8]) as usize;
1015
+
1016
+        assert!(superblob_len < datasize);
1017
+        assert!(
1018
+            signed[dataoff + superblob_len..dataoff + datasize]
1019
+                .iter()
1020
+                .all(|byte| *byte == 0)
1021
+        );
1022
+    }
1023
+
1024
+    #[test]
1025
+    fn allocates_code_signature_command_when_headerpad_exists() {
1026
+        let macho = minimal_macho_without_signature(128);
1027
+        let entitlements = Dictionary::new();
1028
+        let config = MachOSigningConfig::new("com.example.test", "TEAMID", &entitlements, None);
1029
+
1030
+        let signed = sign_macho_data(Path::new("HeaderPaddedBinary"), &macho, &config).unwrap();
1031
+
1032
+        let code_sig_cmd = 32 + 152 + 72;
1033
+        assert_eq!(
1034
+            LittleEndian::read_u32(&signed[code_sig_cmd..code_sig_cmd + 4]),
1035
+            LC_CODE_SIGNATURE
1036
+        );
1037
+
1038
+        let dataoff = LittleEndian::read_u32(&signed[code_sig_cmd + 8..code_sig_cmd + 12]) as usize;
1039
+        let datasize =
1040
+            LittleEndian::read_u32(&signed[code_sig_cmd + 12..code_sig_cmd + 16]) as usize;
1041
+        let linkedit_cmd = 32 + 152;
1042
+        let linkedit_filesize =
1043
+            LittleEndian::read_u64(&signed[linkedit_cmd + 48..linkedit_cmd + 56]);
1044
+
1045
+        assert_eq!(dataoff % CODE_SIGNATURE_ALIGNMENT, 0);
1046
+        assert!(datasize > 0);
1047
+        assert_eq!(signed.len(), dataoff + datasize);
1048
+        assert_eq!(linkedit_filesize as usize, signed.len() - 512);
1049
+        assert_eq!(
1050
+            BigEndian::read_u32(&signed[dataoff..dataoff + 4]),
1051
+            CSMAGIC_EMBEDDED_SIGNATURE
1052
+        );
1053
+    }
1054
+
1055
+    #[test]
1056
+    fn refuses_to_add_code_signature_command_without_headerpad() {
1057
+        let macho = minimal_macho_without_signature(0);
1058
+        let entitlements = Dictionary::new();
1059
+        let config = MachOSigningConfig::new("com.example.test", "TEAMID", &entitlements, None);
1060
+
1061
+        let err = sign_macho_data(Path::new("TightBinary"), &macho, &config).unwrap_err();
1062
+
1063
+        assert!(matches!(
1064
+            err,
1065
+            CodeSignError::NeedsCodeSignatureAllocation { .. }
1066
+        ));
1067
+    }
1068
+
1069
+    fn minimal_macho_with_empty_signature() -> Vec<u8> {
1070
+        let sizeofcmds = 72 + 72 + 16;
1071
+        let dataoff = 512usize;
1072
+        let mut data = vec![0; dataoff];
1073
+
1074
+        LittleEndian::write_u32(&mut data[0..4], MH_MAGIC_64);
1075
+        LittleEndian::write_i32(&mut data[4..8], CPU_TYPE_ARM64_TEST);
1076
+        LittleEndian::write_i32(&mut data[8..12], 0);
1077
+        LittleEndian::write_u32(&mut data[12..16], MH_EXECUTE);
1078
+        LittleEndian::write_u32(&mut data[16..20], 3);
1079
+        LittleEndian::write_u32(&mut data[20..24], sizeofcmds as u32);
1080
+
1081
+        write_segment_64(&mut data, 32, "__TEXT", 0, dataoff as u64);
1082
+        write_segment_64(&mut data, 32 + 72, "__LINKEDIT", dataoff as u64, 0);
1083
+
1084
+        let code_sig_cmd = 32 + 72 + 72;
1085
+        LittleEndian::write_u32(&mut data[code_sig_cmd..code_sig_cmd + 4], LC_CODE_SIGNATURE);
1086
+        LittleEndian::write_u32(
1087
+            &mut data[code_sig_cmd + 4..code_sig_cmd + 8],
1088
+            LINKEDIT_DATA_COMMAND_SIZE as u32,
1089
+        );
1090
+        LittleEndian::write_u32(
1091
+            &mut data[code_sig_cmd + 8..code_sig_cmd + 12],
1092
+            dataoff as u32,
1093
+        );
1094
+        LittleEndian::write_u32(&mut data[code_sig_cmd + 12..code_sig_cmd + 16], 0);
1095
+
1096
+        data
1097
+    }
1098
+
1099
+    fn minimal_macho_without_signature(headerpad: usize) -> Vec<u8> {
1100
+        let sizeofcmds = 152 + 72;
1101
+        let content_offset = 32 + sizeofcmds + headerpad;
1102
+        let linkedit_offset = 512usize;
1103
+        let mut data = vec![0; linkedit_offset + 33];
1104
+
1105
+        LittleEndian::write_u32(&mut data[0..4], MH_MAGIC_64);
1106
+        LittleEndian::write_i32(&mut data[4..8], CPU_TYPE_ARM64_TEST);
1107
+        LittleEndian::write_i32(&mut data[8..12], 0);
1108
+        LittleEndian::write_u32(&mut data[12..16], MH_EXECUTE);
1109
+        LittleEndian::write_u32(&mut data[16..20], 2);
1110
+        LittleEndian::write_u32(&mut data[20..24], sizeofcmds as u32);
1111
+
1112
+        write_segment_64_with_section(
1113
+            &mut data,
1114
+            32,
1115
+            "__TEXT",
1116
+            0,
1117
+            linkedit_offset as u64,
1118
+            content_offset as u32,
1119
+            1,
1120
+        );
1121
+        write_segment_64(
1122
+            &mut data,
1123
+            32 + 152,
1124
+            "__LINKEDIT",
1125
+            linkedit_offset as u64,
1126
+            33,
1127
+        );
1128
+
1129
+        data[content_offset] = 0xaa;
1130
+        data[linkedit_offset] = 0xbb;
1131
+        data
1132
+    }
1133
+
1134
+    fn write_segment_64(data: &mut [u8], offset: usize, name: &str, fileoff: u64, filesize: u64) {
1135
+        LittleEndian::write_u32(&mut data[offset..offset + 4], LC_SEGMENT_64);
1136
+        LittleEndian::write_u32(&mut data[offset + 4..offset + 8], 72);
1137
+        data[offset + 8..offset + 8 + name.len()].copy_from_slice(name.as_bytes());
1138
+        LittleEndian::write_u64(&mut data[offset + 24..offset + 32], 0);
1139
+        LittleEndian::write_u64(
1140
+            &mut data[offset + 32..offset + 40],
1141
+            page_ceil(filesize as usize) as u64,
1142
+        );
1143
+        LittleEndian::write_u64(&mut data[offset + 40..offset + 48], fileoff);
1144
+        LittleEndian::write_u64(&mut data[offset + 48..offset + 56], filesize);
1145
+        LittleEndian::write_u32(&mut data[offset + 64..offset + 68], 0);
1146
+    }
1147
+
1148
+    fn write_segment_64_with_section(
1149
+        data: &mut [u8],
1150
+        offset: usize,
1151
+        name: &str,
1152
+        fileoff: u64,
1153
+        filesize: u64,
1154
+        section_offset: u32,
1155
+        section_size: u64,
1156
+    ) {
1157
+        LittleEndian::write_u32(&mut data[offset..offset + 4], LC_SEGMENT_64);
1158
+        LittleEndian::write_u32(&mut data[offset + 4..offset + 8], 152);
1159
+        data[offset + 8..offset + 8 + name.len()].copy_from_slice(name.as_bytes());
1160
+        LittleEndian::write_u64(&mut data[offset + 24..offset + 32], 0);
1161
+        LittleEndian::write_u64(
1162
+            &mut data[offset + 32..offset + 40],
1163
+            page_ceil(filesize as usize) as u64,
1164
+        );
1165
+        LittleEndian::write_u64(&mut data[offset + 40..offset + 48], fileoff);
1166
+        LittleEndian::write_u64(&mut data[offset + 48..offset + 56], filesize);
1167
+        LittleEndian::write_u32(&mut data[offset + 64..offset + 68], 1);
1168
+
1169
+        let section = offset + 72;
1170
+        LittleEndian::write_u64(&mut data[section + 32..section + 40], 0);
1171
+        LittleEndian::write_u64(&mut data[section + 40..section + 48], section_size);
1172
+        LittleEndian::write_u32(&mut data[section + 48..section + 52], section_offset);
1173
+        LittleEndian::write_u32(&mut data[section + 68..section + 72], 0);
1174
+    }
1175
+}
... ...
@@ -1,126 +1,216 @@
1
-use futures::future::join_all;
2
-use futures::{FutureExt, future::BoxFuture};
3
-use log::warn;
4
-use plist::{Dictionary, Value};
5
-use sha1::Digest as _;
6
-use sha2::Digest as _;
1
+use apple_codesign::{
2
+    BundleSigningSettings, CodeSignError, ProvisioningProfile, Result, RustCryptoCmsSigner,
3
+    sign_bundle,
4
+};
5
+use std::collections::BTreeMap;
6
+use std::ffi::OsString;
7 7
 use std::fs;
8 8
 use std::path::{Path, PathBuf};
9
-use tokio::task::{block_in_place, spawn_blocking};
10
-
11
-struct Bundle {
12
-    path: PathBuf,
13
-}
14
-
15
-impl Bundle {
16
-    async fn code_resources(&self) -> Dictionary {
17
-        struct CodeResourceFile {
18
-            path: PathBuf,
19
-            sha1: Vec<u8>,
20
-            sha256: Vec<u8>,
21
-        }
22
-
23
-        async fn hash_file(relative_path: PathBuf, path: PathBuf) -> CodeResourceFile {
24
-            let file_data = fs::read(&path).unwrap(); // FIXME unwrap
25
-            let file_data2 = file_data.clone();
26
-            let sha1 = spawn_blocking(move || sha1::Sha1::digest(&file_data)[..].into());
27
-            let sha256 = spawn_blocking(move || sha2::Sha256::digest(&file_data2)[..].into());
28
-            CodeResourceFile {
29
-                path: relative_path,
30
-                sha1: sha1.await.unwrap(),
31
-                sha256: sha256.await.unwrap(),
32
-            }
33
-        }
34
-
35
-        fn resources_for_directory(
36
-            root: &Path,
37
-            directory: PathBuf,
38
-        ) -> Vec<BoxFuture<'static, CodeResourceFile>> {
39
-            let mut output = Vec::new();
40
-            let mut subfolders = Vec::new();
41
-            if let Ok(read_dir) = fs::read_dir(directory) {
42
-                for entry in read_dir {
43
-                    if let Ok(entry) = entry {
44
-                        let metadata = entry.metadata().expect("metadata call failed");
45
-                        let path = entry.path();
46
-                        if metadata.is_file() {
47
-                            if let Ok(relative_path) = entry.path().strip_prefix(root) {
48
-                                // TODO check rules
49
-                                output.push(hash_file(relative_path.to_path_buf(), path).boxed());
50
-                            } else {
51
-                                warn!("Skipping {:?} as its prefix is not correct", entry.path());
52
-                            }
53
-                        } else if metadata.is_dir() {
54
-                            subfolders.push(resources_for_directory(root, path));
55
-                        }
9
+
10
+fn main() -> Result<()> {
11
+    let Some(args) = CliArgs::parse()? else {
12
+        print_usage();
13
+        return Ok(());
14
+    };
15
+
16
+    let mobileprovision = read_file(&args.mobileprovision)?;
17
+    let profile = ProvisioningProfile::parse(&mobileprovision)?;
18
+    let private_key = read_der_or_pem(&args.private_key, &["PRIVATE KEY", "RSA PRIVATE KEY"])?;
19
+    let certificate = read_der_or_pem(&args.certificate, &["CERTIFICATE"])?;
20
+    let mut certificate_chain =
21
+        Vec::with_capacity(args.certificate_chain.len() + profile.certificate_chain_der().len());
22
+
23
+    for certificate in &args.certificate_chain {
24
+        certificate_chain.push(read_der_or_pem(certificate, &["CERTIFICATE"])?);
25
+    }
26
+    certificate_chain.extend(profile.certificate_chain_der().iter().cloned());
27
+
28
+    let signer = RustCryptoCmsSigner::from_der(
29
+        &private_key,
30
+        &certificate,
31
+        certificate_chain.iter().map(Vec::as_slice),
32
+    )?;
33
+
34
+    let team_id = args
35
+        .team_id
36
+        .as_deref()
37
+        .unwrap_or_else(|| profile.team_id())
38
+        .to_string();
39
+    let mut settings =
40
+        BundleSigningSettings::new(team_id, profile.entitlements().clone(), Some(&signer));
41
+    settings.embedded_mobileprovision = Some(&mobileprovision);
42
+    let nested_profiles = args
43
+        .profiles_by_bundle_id
44
+        .iter()
45
+        .map(|(bundle_id, path)| {
46
+            let data = read_file(path)?;
47
+            let profile = ProvisioningProfile::parse(&data)?;
48
+            let entitlements = profile.entitlements().clone();
49
+            Ok((bundle_id.clone(), data, entitlements))
50
+        })
51
+        .collect::<Result<Vec<_>>>()?;
52
+    settings.embedded_mobileprovisions_by_bundle_id = nested_profiles
53
+        .iter()
54
+        .map(|(bundle_id, data, _)| (bundle_id.clone(), data.as_slice()))
55
+        .collect::<BTreeMap<_, _>>();
56
+    settings.entitlements_by_bundle_id = nested_profiles
57
+        .iter()
58
+        .map(|(bundle_id, _, entitlements)| (bundle_id.clone(), entitlements.clone()))
59
+        .collect();
60
+    if let Some(reservation) = args.cms_blob_reservation {
61
+        settings.cms_blob_reservation = reservation;
62
+    }
63
+
64
+    sign_bundle(args.bundle, &settings)
65
+}
66
+
67
+#[derive(Debug)]
68
+struct CliArgs {
69
+    bundle: PathBuf,
70
+    certificate: PathBuf,
71
+    private_key: PathBuf,
72
+    mobileprovision: PathBuf,
73
+    certificate_chain: Vec<PathBuf>,
74
+    profiles_by_bundle_id: Vec<(String, PathBuf)>,
75
+    team_id: Option<String>,
76
+    cms_blob_reservation: Option<usize>,
77
+}
78
+
79
+impl CliArgs {
80
+    fn parse() -> Result<Option<Self>> {
81
+        let raw_args: Vec<OsString> = std::env::args_os().skip(1).collect();
82
+        if raw_args.is_empty() || raw_args.iter().any(|arg| arg == "-h" || arg == "--help") {
83
+            return Ok(None);
84
+        }
85
+
86
+        let mut bundle = None;
87
+        let mut certificate = None;
88
+        let mut private_key = None;
89
+        let mut mobileprovision = None;
90
+        let mut certificate_chain = Vec::new();
91
+        let mut profiles_by_bundle_id = Vec::new();
92
+        let mut team_id = None;
93
+        let mut cms_blob_reservation = None;
94
+
95
+        let mut args = raw_args.into_iter();
96
+        while let Some(arg) = args.next() {
97
+            match arg.to_string_lossy().as_ref() {
98
+                "--bundle" | "-b" => set_path_arg(&mut bundle, "--bundle", args.next())?,
99
+                "--certificate" | "--cert" | "-c" => {
100
+                    set_path_arg(&mut certificate, "--certificate", args.next())?
101
+                }
102
+                "--private-key" | "--key" | "-k" => {
103
+                    set_path_arg(&mut private_key, "--private-key", args.next())?
104
+                }
105
+                "--mobileprovision" | "--profile" | "-m" => {
106
+                    set_path_arg(&mut mobileprovision, "--mobileprovision", args.next())?
107
+                }
108
+                "--chain" => certificate_chain.push(next_path_arg("--chain", args.next())?),
109
+                "--profile-for" => profiles_by_bundle_id.push(next_profile_arg(args.next())?),
110
+                "--team-id" => {
111
+                    team_id = Some(next_string_arg("--team-id", args.next())?);
112
+                }
113
+                "--cms-reservation" => {
114
+                    let value = next_string_arg("--cms-reservation", args.next())?;
115
+                    cms_blob_reservation = Some(value.parse().map_err(|_| {
116
+                        CodeSignError::Argument(format!(
117
+                            "--cms-reservation must be a byte count, got {value}"
118
+                        ))
119
+                    })?);
120
+                }
121
+                value if value.starts_with('-') => {
122
+                    return Err(CodeSignError::Argument(format!("unknown argument {value}")));
123
+                }
124
+                _ if bundle.is_none() => {
125
+                    bundle = Some(PathBuf::from(arg));
126
+                }
127
+                value => {
128
+                    return Err(CodeSignError::Argument(format!(
129
+                        "unexpected positional argument {value}"
130
+                    )));
131
+                }
132
+            }
133
+        }
134
+
135
+        Ok(Some(Self {
136
+            bundle: required_path(bundle, "--bundle")?,
137
+            certificate: required_path(certificate, "--certificate")?,
138
+            private_key: required_path(private_key, "--private-key")?,
139
+            mobileprovision: required_path(mobileprovision, "--mobileprovision")?,
140
+            certificate_chain,
141
+            profiles_by_bundle_id,
142
+            team_id,
143
+            cms_blob_reservation,
144
+        }))
145
+    }
56 146
 }
147
+
148
+fn next_profile_arg(value: Option<OsString>) -> Result<(String, PathBuf)> {
149
+    let value = next_string_arg("--profile-for", value)?;
150
+    let (bundle_id, path) = value.split_once('=').ok_or_else(|| {
151
+        CodeSignError::Argument("--profile-for requires BUNDLE_IDENTIFIER=PROFILE_PATH".to_string())
152
+    })?;
153
+    if bundle_id.trim().is_empty() || path.trim().is_empty() {
154
+        return Err(CodeSignError::Argument(
155
+            "--profile-for requires non-empty bundle identifier and path".to_string(),
156
+        ));
57 157
     }
158
+    Ok((bundle_id.to_string(), PathBuf::from(path)))
58 159
 }
59 160
 
60
-            for subfolder in subfolders {
61
-                let data = subfolder;
62
-                output.extend(data);
161
+fn set_path_arg(slot: &mut Option<PathBuf>, name: &str, value: Option<OsString>) -> Result<()> {
162
+    if slot.is_some() {
163
+        return Err(CodeSignError::Argument(format!(
164
+            "{name} was provided twice"
165
+        )));
63 166
     }
64
-            output
167
+    *slot = Some(next_path_arg(name, value)?);
168
+    Ok(())
65 169
 }
66 170
 
67
-        let path_buf = self.path.to_path_buf();
68
-        let resources = resources_for_directory(&self.path, path_buf);
69
-
70
-        let rules = Dictionary::new();
71
-        let rules2 = Dictionary::new();
72
-
73
-        let (files, files2) = {
74
-            let mut files = Dictionary::new();
75
-            let mut files2 = Dictionary::new();
76
-
77
-            for res in resources {
78
-                let CodeResourceFile { path, sha1, sha256 } = res.await;
79
-
80
-                let path = path.to_string_lossy().into_owned();
81
-
82
-                let sha1: Vec<u8> = sha1.into();
83
-                let sha256: Vec<u8> = sha256.into();
84
-
85
-                files.insert(path.clone(), Value::Data(sha1.clone()));
86
-
87
-                let mut file2 = Dictionary::new();
88
-                file2.insert("hash".to_string(), Value::Data(sha1));
89
-                file2.insert("hash2".to_string(), Value::Data(sha256));
90
-
91
-                files2.insert(path, Value::Dictionary(file2));
171
+fn next_path_arg(name: &str, value: Option<OsString>) -> Result<PathBuf> {
172
+    value
173
+        .map(PathBuf::from)
174
+        .ok_or_else(|| CodeSignError::Argument(format!("{name} requires a value")))
92 175
 }
93 176
 
94
-            (files, files2)
95
-        };
96
-
97
-        let code_resources = {
98
-            let mut code_resources = Dictionary::new();
99
-            code_resources.insert("files".to_string(), Value::Dictionary(files));
100
-            code_resources.insert("files2".to_string(), Value::Dictionary(files2));
101
-            code_resources.insert("rules".to_string(), Value::Dictionary(rules));
102
-            code_resources.insert("rules2".to_string(), Value::Dictionary(rules2));
177
+fn next_string_arg(name: &str, value: Option<OsString>) -> Result<String> {
178
+    let value = value.ok_or_else(|| CodeSignError::Argument(format!("{name} requires a value")))?;
179
+    value
180
+        .into_string()
181
+        .map_err(|_| CodeSignError::Argument(format!("{name} must be valid UTF-8")))
182
+}
103 183
 
104
-            code_resources
105
-        };
184
+fn required_path(value: Option<PathBuf>, name: &str) -> Result<PathBuf> {
185
+    value.ok_or_else(|| CodeSignError::Argument(format!("missing required {name}")))
186
+}
106 187
 
107
-        code_resources
188
+fn read_file(path: &Path) -> Result<Vec<u8>> {
189
+    fs::read(path).map_err(|source| CodeSignError::Io {
190
+        path: path.to_path_buf(),
191
+        source,
192
+    })
108 193
 }
109 194
 
110
-    async fn sign(&self) {
111
-        let code_resources = self.code_resources().await;
195
+fn read_der_or_pem(path: &Path, allowed_labels: &[&str]) -> Result<Vec<u8>> {
196
+    let data = read_file(path)?;
197
+    if !data.starts_with(b"-----BEGIN ") {
198
+        return Ok(data);
199
+    }
112 200
 
113
-        let mut data = Vec::<u8>::new();
114
-        plist::to_writer_xml(&mut data, &code_resources).expect("TODO: panic message");
115
-        println!("{}", String::from_utf8(data).unwrap());
201
+    let (label, der) = pem_rfc7468::decode_vec(&data)
202
+        .map_err(|err| CodeSignError::Argument(format!("failed to decode PEM {path:?}: {err}")))?;
203
+    if allowed_labels.contains(&label) {
204
+        Ok(der)
205
+    } else {
206
+        Err(CodeSignError::Argument(format!(
207
+            "unsupported PEM label {label:?} in {path:?}; expected one of {allowed_labels:?}"
208
+        )))
116 209
     }
117 210
 }
118 211
 
119
-#[tokio::main]
120
-async fn main() {
121
-    Bundle {
122
-        path: "/home/dadoum/Téléchargements/YouTube.app/".into(),
123
-    }
124
-    .sign()
125
-    .await;
212
+fn print_usage() {
213
+    eprintln!(
214
+        "usage: apple-codesign --bundle Bundle.app --certificate signer.cer --private-key signer.key --mobileprovision profile.mobileprovision [--profile-for BUNDLE_IDENTIFIER=PROFILE_PATH ...] [--chain wwdr.cer ...] [--team-id TEAMID] [--cms-reservation BYTES]"
215
+    );
126 216
 }
... ...
@@ -0,0 +1,128 @@
1
+use crate::error::{CodeSignError, Result};
2
+use cms::cert::CertificateChoices;
3
+use cms::content_info::ContentInfo;
4
+use cms::signed_data::SignedData;
5
+use der::{Decode, Encode};
6
+use plist::{Dictionary, Value};
7
+
8
+#[derive(Clone, Debug)]
9
+pub struct ProvisioningProfile {
10
+    plist: Dictionary,
11
+    team_id: String,
12
+    entitlements: Dictionary,
13
+    certificate_chain: Vec<Vec<u8>>,
14
+}
15
+
16
+impl ProvisioningProfile {
17
+    pub fn parse(data: &[u8]) -> Result<Self> {
18
+        let signed_data = decode_signed_data(data)?;
19
+        let profile_data = signed_data
20
+            .encap_content_info
21
+            .econtent
22
+            .as_ref()
23
+            .ok_or_else(|| {
24
+                CodeSignError::provisioning_profile("CMS SignedData has no embedded plist")
25
+            })?;
26
+        let plist = match Value::from_reader_xml(profile_data.value())? {
27
+            Value::Dictionary(plist) => plist,
28
+            _ => {
29
+                return Err(CodeSignError::provisioning_profile(
30
+                    "embedded plist is not a dictionary",
31
+                ));
32
+            }
33
+        };
34
+
35
+        let team_id = team_identifier(&plist)?;
36
+        let entitlements = match plist.get("Entitlements") {
37
+            Some(Value::Dictionary(entitlements)) => entitlements.clone(),
38
+            _ => {
39
+                return Err(CodeSignError::provisioning_profile(
40
+                    "profile is missing Entitlements dictionary",
41
+                ));
42
+            }
43
+        };
44
+        let certificate_chain = certificate_chain_der(&signed_data)?;
45
+
46
+        Ok(Self {
47
+            plist,
48
+            team_id,
49
+            entitlements,
50
+            certificate_chain,
51
+        })
52
+    }
53
+
54
+    pub fn plist(&self) -> &Dictionary {
55
+        &self.plist
56
+    }
57
+
58
+    pub fn team_id(&self) -> &str {
59
+        &self.team_id
60
+    }
61
+
62
+    pub fn entitlements(&self) -> &Dictionary {
63
+        &self.entitlements
64
+    }
65
+
66
+    pub fn certificate_chain_der(&self) -> &[Vec<u8>] {
67
+        &self.certificate_chain
68
+    }
69
+}
70
+
71
+fn decode_signed_data(data: &[u8]) -> Result<SignedData> {
72
+    let content_info = ContentInfo::from_der(data).map_err(|err| {
73
+        CodeSignError::provisioning_profile(format!("failed to decode CMS ContentInfo: {err}"))
74
+    })?;
75
+    if content_info.content_type != const_oid::db::rfc5911::ID_SIGNED_DATA {
76
+        return Err(CodeSignError::provisioning_profile(
77
+            "CMS ContentInfo is not SignedData",
78
+        ));
79
+    }
80
+
81
+    let signed_data = content_info.content.to_der().map_err(|err| {
82
+        CodeSignError::provisioning_profile(format!("failed to re-encode SignedData: {err}"))
83
+    })?;
84
+    SignedData::from_der(&signed_data).map_err(|err| {
85
+        CodeSignError::provisioning_profile(format!("failed to decode CMS SignedData: {err}"))
86
+    })
87
+}
88
+
89
+fn team_identifier(plist: &Dictionary) -> Result<String> {
90
+    plist
91
+        .get("TeamIdentifier")
92
+        .and_then(Value::as_array)
93
+        .and_then(|values| values.first())
94
+        .and_then(Value::as_string)
95
+        .map(ToString::to_string)
96
+        .or_else(|| {
97
+            plist
98
+                .get("ApplicationIdentifierPrefix")
99
+                .and_then(Value::as_array)
100
+                .and_then(|values| values.first())
101
+                .and_then(Value::as_string)
102
+                .map(ToString::to_string)
103
+        })
104
+        .ok_or_else(|| {
105
+            CodeSignError::provisioning_profile(
106
+                "profile is missing TeamIdentifier/ApplicationIdentifierPrefix",
107
+            )
108
+        })
109
+}
110
+
111
+fn certificate_chain_der(signed_data: &SignedData) -> Result<Vec<Vec<u8>>> {
112
+    let Some(certificates) = &signed_data.certificates else {
113
+        return Ok(Vec::new());
114
+    };
115
+    let mut out = Vec::with_capacity(certificates.0.len());
116
+
117
+    for certificate in certificates.0.iter() {
118
+        if let CertificateChoices::Certificate(certificate) = certificate {
119
+            out.push(certificate.to_der().map_err(|err| {
120
+                CodeSignError::provisioning_profile(format!(
121
+                    "failed to encode CMS certificate: {err}"
122
+                ))
123
+            })?);
124
+        }
125
+    }
126
+
127
+    Ok(out)
128
+}
... ...
@@ -0,0 +1,467 @@
1
+use crate::error::{CodeSignError, Result};
2
+use crate::signature::{CmsSigner, CmsSigningRequest, HashAlgorithm};
3
+use cms::builder::{SignedDataBuilder, SignerInfoBuilder};
4
+use cms::cert::{CertificateChoices, IssuerAndSerialNumber};
5
+use cms::signed_data::{EncapsulatedContentInfo, SignerIdentifier};
6
+use const_oid::ObjectIdentifier;
7
+use der::asn1::{Any, ObjectIdentifier as DerObjectIdentifier, OctetStringRef, SetOfVec};
8
+use der::{DateTime, Decode, Encode, Sequence};
9
+use plist::{Dictionary, Value};
10
+use rsa::RsaPrivateKey;
11
+use rsa::pkcs1::DecodeRsaPrivateKey;
12
+use rsa::pkcs1v15::SigningKey;
13
+use rsa::pkcs8::DecodePrivateKey;
14
+use spki::AlgorithmIdentifierOwned;
15
+use std::fmt::Display;
16
+use std::time::SystemTime;
17
+use x509_cert::Certificate;
18
+use x509_cert::attr::{Attribute, AttributeValue};
19
+
20
+const APPLE_CODE_DIRECTORY_HASHES_OID: ObjectIdentifier =
21
+    ObjectIdentifier::new_unwrap("1.2.840.113635.100.9.1");
22
+const APPLE_CODE_DIRECTORY_DIGEST_OID: ObjectIdentifier =
23
+    ObjectIdentifier::new_unwrap("1.2.840.113635.100.9.2");
24
+const APPLE_WWDR_G3_CERTIFICATE_DER: &[u8] = include_bytes!("../assets/AppleWWDRCAG3.cer");
25
+const APPLE_ROOT_CERTIFICATE_DER: &[u8] = include_bytes!("../assets/AppleIncRootCertificate.cer");
26
+
27
+type RsaSha256SigningKey = SigningKey<sha2_010::Sha256>;
28
+
29
+/// CMS signer for Apple Code Signing using RustCrypto's CMS/DER/X.509 crates.
30
+///
31
+/// The generated CMS is detached SignedData over the primary CodeDirectory and
32
+/// includes the Apple-specific CodeDirectory digest attributes that AMFI expects.
33
+pub struct RustCryptoCmsSigner {
34
+    signing_key: RsaSha256SigningKey,
35
+    signer_certificate: Certificate,
36
+    certificate_chain: Vec<Certificate>,
37
+    signing_time: Option<SystemTime>,
38
+}
39
+
40
+impl RustCryptoCmsSigner {
41
+    pub fn new(
42
+        private_key: RsaPrivateKey,
43
+        signer_certificate: Certificate,
44
+        certificate_chain: Vec<Certificate>,
45
+    ) -> Self {
46
+        Self {
47
+            signing_key: RsaSha256SigningKey::new(private_key),
48
+            signer_certificate,
49
+            certificate_chain,
50
+            signing_time: Some(SystemTime::now()),
51
+        }
52
+    }
53
+
54
+    pub fn from_der<I, C>(
55
+        private_key_der: &[u8],
56
+        signer_certificate_der: &[u8],
57
+        certificate_chain_der: I,
58
+    ) -> Result<Self>
59
+    where
60
+        I: IntoIterator<Item = C>,
61
+        C: AsRef<[u8]>,
62
+    {
63
+        let private_key = decode_rsa_private_key(private_key_der)?;
64
+        let signer_certificate = Certificate::from_der(signer_certificate_der)
65
+            .map_err(|err| cms_error(format!("failed to decode signer certificate: {err}")))?;
66
+        let mut certificate_candidates = Vec::new();
67
+
68
+        for certificate_der in certificate_chain_der {
69
+            let certificate = Certificate::from_der(certificate_der.as_ref())
70
+                .map_err(|err| cms_error(format!("failed to decode chain certificate: {err}")))?;
71
+            push_unique_certificate(&mut certificate_candidates, certificate);
72
+        }
73
+
74
+        for (name, certificate_der) in [
75
+            ("Apple WWDR G3", APPLE_WWDR_G3_CERTIFICATE_DER),
76
+            ("Apple Root CA", APPLE_ROOT_CERTIFICATE_DER),
77
+        ] {
78
+            let certificate = Certificate::from_der(certificate_der).map_err(|err| {
79
+                cms_error(format!(
80
+                    "failed to decode bundled {name} certificate: {err}"
81
+                ))
82
+            })?;
83
+            push_unique_certificate(&mut certificate_candidates, certificate);
84
+        }
85
+
86
+        let certificate_chain =
87
+            certificate_chain_for_signer(&signer_certificate, &certificate_candidates)?;
88
+
89
+        Ok(Self::new(
90
+            private_key,
91
+            signer_certificate,
92
+            certificate_chain,
93
+        ))
94
+    }
95
+
96
+    pub fn with_signing_time(mut self, signing_time: SystemTime) -> Self {
97
+        self.signing_time = Some(signing_time);
98
+        self
99
+    }
100
+
101
+    pub fn without_signing_time(mut self) -> Self {
102
+        self.signing_time = None;
103
+        self
104
+    }
105
+
106
+    pub fn signer_certificate(&self) -> &Certificate {
107
+        &self.signer_certificate
108
+    }
109
+
110
+    pub fn certificate_chain(&self) -> &[Certificate] {
111
+        &self.certificate_chain
112
+    }
113
+}
114
+
115
+impl CmsSigner for RustCryptoCmsSigner {
116
+    fn sign(&self, request: CmsSigningRequest<'_>) -> Result<Vec<u8>> {
117
+        let primary_code_directory = request
118
+            .code_directories
119
+            .first()
120
+            .ok_or_else(|| cms_error("cannot build CMS without a primary CodeDirectory"))?;
121
+        let message_digest = HashAlgorithm::Sha256.digest(primary_code_directory.bytes);
122
+
123
+        let encapsulated_content_info = EncapsulatedContentInfo {
124
+            econtent_type: const_oid::db::rfc5911::ID_DATA,
125
+            econtent: None,
126
+        };
127
+        let digest_algorithm = sha256_algorithm_identifier();
128
+        let signer_identifier = signer_identifier(&self.signer_certificate);
129
+        let mut signer_info = SignerInfoBuilder::new(
130
+            &self.signing_key,
131
+            signer_identifier,
132
+            digest_algorithm.clone(),
133
+            &encapsulated_content_info,
134
+            Some(&message_digest),
135
+        )
136
+        .map_err(cms_builder_error)?;
137
+
138
+        if let Some(signing_time) = self.signing_time {
139
+            signer_info
140
+                .add_signed_attribute(signing_time_attribute(signing_time)?)
141
+                .map_err(cms_builder_error)?;
142
+        }
143
+
144
+        signer_info
145
+            .add_signed_attribute(apple_code_directory_digest_attribute(&request)?)
146
+            .map_err(cms_builder_error)?;
147
+        signer_info
148
+            .add_signed_attribute(apple_code_directory_hashes_attribute(&request)?)
149
+            .map_err(cms_builder_error)?;
150
+
151
+        let mut builder = SignedDataBuilder::new(&encapsulated_content_info);
152
+        builder
153
+            .add_digest_algorithm(digest_algorithm)
154
+            .map_err(cms_builder_error)?;
155
+        add_certificates(
156
+            &mut builder,
157
+            &self.signer_certificate,
158
+            &self.certificate_chain,
159
+        )?;
160
+        builder
161
+            .add_signer_info::<RsaSha256SigningKey, rsa::pkcs1v15::Signature>(signer_info)
162
+            .map_err(cms_builder_error)?;
163
+
164
+        let content_info = builder.build().map_err(cms_builder_error)?;
165
+        content_info.to_der().map_err(cms_der_error)
166
+    }
167
+}
168
+
169
+#[derive(Sequence)]
170
+struct AppleCodeDirectoryDigest<'a> {
171
+    algorithm: DerObjectIdentifier,
172
+    digest: OctetStringRef<'a>,
173
+}
174
+
175
+fn decode_rsa_private_key(der: &[u8]) -> Result<RsaPrivateKey> {
176
+    match RsaPrivateKey::from_pkcs8_der(der) {
177
+        Ok(private_key) => Ok(private_key),
178
+        Err(pkcs8_error) => RsaPrivateKey::from_pkcs1_der(der).map_err(|pkcs1_error| {
179
+            cms_error(format!(
180
+                "failed to decode RSA private key as PKCS#8 ({pkcs8_error}) or PKCS#1 ({pkcs1_error})"
181
+            ))
182
+        }),
183
+    }
184
+}
185
+
186
+fn push_unique_certificate(certificates: &mut Vec<Certificate>, certificate: Certificate) {
187
+    if !certificates.contains(&certificate) {
188
+        certificates.push(certificate);
189
+    }
190
+}
191
+
192
+fn certificate_chain_for_signer(
193
+    signer_certificate: &Certificate,
194
+    certificate_candidates: &[Certificate],
195
+) -> Result<Vec<Certificate>> {
196
+    if signer_certificate.tbs_certificate.subject == signer_certificate.tbs_certificate.issuer {
197
+        return Ok(Vec::new());
198
+    }
199
+
200
+    let mut issuer = signer_certificate.tbs_certificate.issuer.clone();
201
+    let mut certificate_chain = Vec::new();
202
+
203
+    loop {
204
+        let certificate = certificate_candidates
205
+            .iter()
206
+            .find(|candidate| candidate.tbs_certificate.subject == issuer)
207
+            .ok_or_else(|| {
208
+                cms_error(format!(
209
+                    "missing issuer certificate {issuer} for the code-signing certificate chain"
210
+                ))
211
+            })?
212
+            .clone();
213
+
214
+        if certificate_chain.contains(&certificate) {
215
+            return Err(cms_error("certificate chain contains a cycle"));
216
+        }
217
+
218
+        let is_root = certificate.tbs_certificate.subject == certificate.tbs_certificate.issuer;
219
+        issuer = certificate.tbs_certificate.issuer.clone();
220
+        certificate_chain.push(certificate);
221
+
222
+        if is_root {
223
+            return Ok(certificate_chain);
224
+        }
225
+    }
226
+}
227
+
228
+fn add_certificates(
229
+    builder: &mut SignedDataBuilder<'_>,
230
+    signer_certificate: &Certificate,
231
+    certificate_chain: &[Certificate],
232
+) -> Result<()> {
233
+    let mut added = Vec::with_capacity(certificate_chain.len() + 1);
234
+
235
+    for certificate in certificate_chain
236
+        .iter()
237
+        .chain(std::iter::once(signer_certificate))
238
+    {
239
+        if added.contains(&certificate) {
240
+            continue;
241
+        }
242
+
243
+        builder
244
+            .add_certificate(CertificateChoices::Certificate(certificate.clone()))
245
+            .map_err(cms_builder_error)?;
246
+        added.push(certificate);
247
+    }
248
+
249
+    Ok(())
250
+}
251
+
252
+fn signing_time_attribute(signing_time: SystemTime) -> Result<Attribute> {
253
+    let date_time = DateTime::from_system_time(signing_time).map_err(cms_der_error)?;
254
+    let time_der = if date_time.year() < 1950 || date_time.year() > 2049 {
255
+        der::asn1::GeneralizedTime::from_date_time(date_time)
256
+            .to_der()
257
+            .map_err(cms_der_error)?
258
+    } else {
259
+        der::asn1::UtcTime::from_date_time(date_time)
260
+            .map_err(cms_der_error)?
261
+            .to_der()
262
+            .map_err(cms_der_error)?
263
+    };
264
+    let value = AttributeValue::from_der(&time_der).map_err(cms_der_error)?;
265
+
266
+    single_value_attribute(const_oid::db::rfc5911::ID_SIGNING_TIME, value)
267
+}
268
+
269
+fn signer_identifier(certificate: &Certificate) -> SignerIdentifier {
270
+    SignerIdentifier::IssuerAndSerialNumber(IssuerAndSerialNumber {
271
+        issuer: certificate.tbs_certificate.issuer.clone(),
272
+        serial_number: certificate.tbs_certificate.serial_number.clone(),
273
+    })
274
+}
275
+
276
+fn apple_code_directory_digest_attribute(request: &CmsSigningRequest<'_>) -> Result<Attribute> {
277
+    if !request
278
+        .code_directories
279
+        .iter()
280
+        .any(|code_directory| code_directory.algorithm == HashAlgorithm::Sha256)
281
+    {
282
+        return Err(cms_error(
283
+            "cannot build CMS without a SHA-256 CodeDirectory",
284
+        ));
285
+    }
286
+
287
+    let mut values = Vec::with_capacity(request.code_directories.len());
288
+    for code_directory in request.code_directories {
289
+        let digest = code_directory.algorithm.digest(code_directory.bytes);
290
+        let digest = OctetStringRef::new(&digest).map_err(cms_der_error)?;
291
+        values.push(
292
+            Any::encode_from(&AppleCodeDirectoryDigest {
293
+                algorithm: match code_directory.algorithm {
294
+                    HashAlgorithm::Sha1 => const_oid::db::rfc5912::ID_SHA_1,
295
+                    HashAlgorithm::Sha256 => const_oid::db::rfc5912::ID_SHA_256,
296
+                },
297
+                digest,
298
+            })
299
+            .map_err(cms_der_error)?,
300
+        );
301
+    }
302
+
303
+    Ok(Attribute {
304
+        oid: APPLE_CODE_DIRECTORY_DIGEST_OID,
305
+        values: SetOfVec::try_from(values).map_err(cms_der_error)?,
306
+    })
307
+}
308
+
309
+fn apple_code_directory_hashes_attribute(request: &CmsSigningRequest<'_>) -> Result<Attribute> {
310
+    let mut cdhashes = Vec::with_capacity(request.code_directories.len());
311
+
312
+    for code_directory in request.code_directories {
313
+        let digest = code_directory.algorithm.digest(code_directory.bytes);
314
+        let cdhash = digest
315
+            .get(..20)
316
+            .ok_or_else(|| cms_error("CodeDirectory digest is shorter than a cdhash"))?;
317
+        cdhashes.push(Value::Data(cdhash.to_vec()));
318
+    }
319
+
320
+    let mut plist = Dictionary::new();
321
+    plist.insert("cdhashes".to_string(), Value::Array(cdhashes));
322
+
323
+    let mut xml = Vec::new();
324
+    plist::to_writer_xml(&mut xml, &Value::Dictionary(plist))?;
325
+    if xml.last() != Some(&b'\n') {
326
+        xml.push(b'\n');
327
+    }
328
+
329
+    let value = Any::from(OctetStringRef::new(&xml).map_err(cms_der_error)?);
330
+    single_value_attribute(APPLE_CODE_DIRECTORY_HASHES_OID, value)
331
+}
332
+
333
+fn single_value_attribute(oid: DerObjectIdentifier, value: AttributeValue) -> Result<Attribute> {
334
+    Ok(Attribute {
335
+        oid,
336
+        values: SetOfVec::try_from(vec![value]).map_err(cms_der_error)?,
337
+    })
338
+}
339
+
340
+fn sha256_algorithm_identifier() -> AlgorithmIdentifierOwned {
341
+    AlgorithmIdentifierOwned {
342
+        oid: const_oid::db::rfc5912::ID_SHA_256,
343
+        parameters: Some(Any::null()),
344
+    }
345
+}
346
+
347
+fn cms_builder_error(error: cms::builder::Error) -> CodeSignError {
348
+    cms_error(error)
349
+}
350
+
351
+fn cms_der_error(error: der::Error) -> CodeSignError {
352
+    cms_error(error)
353
+}
354
+
355
+fn cms_error(message: impl Display) -> CodeSignError {
356
+    CodeSignError::cms(message.to_string())
357
+}
358
+
359
+#[cfg(test)]
360
+mod tests {
361
+    use super::*;
362
+    use crate::signature::EncodedCodeDirectory;
363
+    use der::Decode;
364
+
365
+    #[test]
366
+    fn apple_code_directory_digest_requires_sha256_code_directory() {
367
+        let code_directory = EncodedCodeDirectory {
368
+            algorithm: HashAlgorithm::Sha1,
369
+            bytes: b"primary",
370
+        };
371
+        let request = CmsSigningRequest {
372
+            code_directories: &[code_directory],
373
+        };
374
+
375
+        assert!(apple_code_directory_digest_attribute(&request).is_err());
376
+    }
377
+
378
+    #[test]
379
+    fn apple_code_directory_digest_contains_a_bare_hash_oid() {
380
+        let code_directory = EncodedCodeDirectory {
381
+            algorithm: HashAlgorithm::Sha256,
382
+            bytes: b"alternate",
383
+        };
384
+        let request = CmsSigningRequest {
385
+            code_directories: &[code_directory],
386
+        };
387
+
388
+        let attribute = apple_code_directory_digest_attribute(&request).unwrap();
389
+        let value_der = attribute.values.get(0).unwrap().to_der().unwrap();
390
+
391
+        assert_eq!(
392
+            &value_der[..15],
393
+            &[
394
+                0x30, 0x2d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x04,
395
+                0x20,
396
+            ]
397
+        );
398
+        assert_eq!(value_der.len(), 47);
399
+    }
400
+
401
+    #[test]
402
+    fn apple_code_directory_hashes_are_xml_plist_octet_string() {
403
+        let code_directories = [
404
+            EncodedCodeDirectory {
405
+                algorithm: HashAlgorithm::Sha1,
406
+                bytes: b"primary",
407
+            },
408
+            EncodedCodeDirectory {
409
+                algorithm: HashAlgorithm::Sha256,
410
+                bytes: b"alternate",
411
+            },
412
+        ];
413
+        let request = CmsSigningRequest {
414
+            code_directories: &code_directories,
415
+        };
416
+
417
+        let attribute = apple_code_directory_hashes_attribute(&request).unwrap();
418
+        assert_eq!(attribute.oid, APPLE_CODE_DIRECTORY_HASHES_OID);
419
+
420
+        let value = attribute.values.get(0).unwrap();
421
+        let plist_xml = OctetStringRef::from_der(value.to_der().unwrap().as_slice())
422
+            .unwrap()
423
+            .as_bytes()
424
+            .to_vec();
425
+        assert!(plist_xml.starts_with(br#"<?xml version="1.0""#));
426
+        assert!(plist_xml.ends_with(b"\n"));
427
+    }
428
+
429
+    #[test]
430
+    fn apple_code_directory_digest_includes_every_code_directory() {
431
+        let code_directories = [
432
+            EncodedCodeDirectory {
433
+                algorithm: HashAlgorithm::Sha1,
434
+                bytes: b"primary",
435
+            },
436
+            EncodedCodeDirectory {
437
+                algorithm: HashAlgorithm::Sha256,
438
+                bytes: b"alternate",
439
+            },
440
+        ];
441
+        let request = CmsSigningRequest {
442
+            code_directories: &code_directories,
443
+        };
444
+
445
+        let attribute = apple_code_directory_digest_attribute(&request).unwrap();
446
+        let sha1_value_der = attribute.values.get(0).unwrap().to_der().unwrap();
447
+
448
+        assert_eq!(attribute.values.len(), 2);
449
+        assert_eq!(
450
+            &sha1_value_der[..11],
451
+            &[
452
+                0x30, 0x1d, 0x06, 0x05, 0x2b, 0x0e, 0x03, 0x02, 0x1a, 0x04, 0x14
453
+            ]
454
+        );
455
+    }
456
+
457
+    #[test]
458
+    fn bundled_apple_chain_links_wwdr_g3_to_root() {
459
+        let wwdr = Certificate::from_der(APPLE_WWDR_G3_CERTIFICATE_DER).unwrap();
460
+        let root = Certificate::from_der(APPLE_ROOT_CERTIFICATE_DER).unwrap();
461
+        let candidates = [wwdr.clone(), root.clone()];
462
+
463
+        let chain = certificate_chain_for_signer(&wwdr, &candidates).unwrap();
464
+
465
+        assert_eq!(chain, vec![root]);
466
+    }
467
+}
... ...
@@ -0,0 +1,696 @@
1
+use crate::error::{CodeSignError, Result};
2
+use plist::{Dictionary, Value};
3
+use rayon::prelude::*;
4
+use sha1::Digest as _;
5
+use std::borrow::Cow;
6
+
7
+pub const CSSLOT_CODEDIRECTORY: u32 = 0;
8
+pub const CSSLOT_REQUIREMENTS: u32 = 2;
9
+pub const CSSLOT_ENTITLEMENTS: u32 = 5;
10
+pub const CSSLOT_DER_ENTITLEMENTS: u32 = 7;
11
+pub const CSSLOT_ALTERNATE_CODEDIRECTORIES: u32 = 0x1000;
12
+pub const CSSLOT_SIGNATURESLOT: u32 = 0x10000;
13
+
14
+pub const CSMAGIC_BLOBWRAPPER: u32 = 0xfade_0b01;
15
+pub const CSMAGIC_REQUIREMENTS: u32 = 0xfade_0c01;
16
+pub const CSMAGIC_CODEDIRECTORY: u32 = 0xfade_0c02;
17
+pub const CSMAGIC_EMBEDDED_SIGNATURE: u32 = 0xfade_0cc0;
18
+pub const CSMAGIC_EMBEDDED_ENTITLEMENTS: u32 = 0xfade_7171;
19
+pub const CSMAGIC_EMBEDDED_DER_ENTITLEMENTS: u32 = 0xfade_7172;
20
+
21
+const CODEDIRECTORY_VERSION_EXECSEG: u32 = 0x20400;
22
+const CODEDIRECTORY_HEADER_LEN: usize = 88;
23
+const CODEDIRECTORY_PAGE_SIZE_LOG2: u8 = 12;
24
+const CODEDIRECTORY_PAGE_SIZE: usize = 1 << CODEDIRECTORY_PAGE_SIZE_LOG2;
25
+const CODEDIRECTORY_PARALLEL_HASH_PAGES: usize = 64;
26
+
27
+pub const CS_EXECSEG_MAIN_BINARY: u64 = 0x1;
28
+pub const CS_EXECSEG_ALLOW_UNSIGNED: u64 = 0x10;
29
+pub const CS_EXECSEG_DEBUGGER: u64 = 0x20;
30
+pub const CS_EXECSEG_JIT: u64 = 0x40;
31
+pub const CS_EXECSEG_SKIP_LV: u64 = 0x80;
32
+pub const CS_EXECSEG_CAN_LOAD_CDHASH: u64 = 0x100;
33
+pub const CS_EXECSEG_CAN_EXEC_CDHASH: u64 = 0x200;
34
+
35
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
36
+pub enum HashAlgorithm {
37
+    Sha1,
38
+    Sha256,
39
+}
40
+
41
+impl HashAlgorithm {
42
+    pub fn digest(self, bytes: &[u8]) -> Vec<u8> {
43
+        match self {
44
+            Self::Sha1 => sha1::Sha1::digest(bytes).to_vec(),
45
+            Self::Sha256 => sha2::Sha256::digest(bytes).to_vec(),
46
+        }
47
+    }
48
+
49
+    fn push_digest(self, out: &mut Vec<u8>, bytes: &[u8]) {
50
+        match self {
51
+            Self::Sha1 => {
52
+                let digest = sha1::Sha1::digest(bytes);
53
+                out.extend_from_slice(&digest);
54
+            }
55
+            Self::Sha256 => {
56
+                let digest = sha2::Sha256::digest(bytes);
57
+                out.extend_from_slice(&digest);
58
+            }
59
+        }
60
+    }
61
+
62
+    pub fn digest_len(self) -> usize {
63
+        match self {
64
+            Self::Sha1 => 20,
65
+            Self::Sha256 => 32,
66
+        }
67
+    }
68
+
69
+    pub fn code_directory_hash_type(self) -> u8 {
70
+        match self {
71
+            Self::Sha1 => 1,
72
+            Self::Sha256 => 2,
73
+        }
74
+    }
75
+}
76
+
77
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
78
+pub struct ExecutableSegment {
79
+    pub base: u64,
80
+    pub limit: u64,
81
+    pub flags: u64,
82
+}
83
+
84
+#[derive(Debug)]
85
+pub struct EncodedCodeDirectory<'a> {
86
+    pub algorithm: HashAlgorithm,
87
+    pub bytes: &'a [u8],
88
+}
89
+
90
+#[derive(Debug)]
91
+pub struct CmsSigningRequest<'a> {
92
+    pub code_directories: &'a [EncodedCodeDirectory<'a>],
93
+}
94
+
95
+pub trait CmsSigner: Sync {
96
+    fn sign(&self, request: CmsSigningRequest<'_>) -> Result<Vec<u8>>;
97
+}
98
+
99
+#[derive(Debug)]
100
+pub struct CodeDirectoryParams<'a> {
101
+    pub identifier: &'a str,
102
+    pub team_id: &'a str,
103
+    pub macho_data: &'a [u8],
104
+    pub code_limit: usize,
105
+    pub executable_segment: ExecutableSegment,
106
+    pub is_executable: bool,
107
+    pub requirements_blob: &'a [u8],
108
+    pub entitlements_blob: &'a [u8],
109
+    pub der_entitlements_blob: Option<&'a [u8]>,
110
+    pub info_plist: Option<&'a [u8]>,
111
+    pub code_resources: Option<&'a [u8]>,
112
+}
113
+
114
+#[derive(Clone, Copy, Debug, Default)]
115
+struct CodePageHashes {
116
+    sha1: [u8; 20],
117
+    sha256: [u8; 32],
118
+}
119
+
120
+impl CodePageHashes {
121
+    fn get(&self, algorithm: HashAlgorithm) -> &[u8] {
122
+        match algorithm {
123
+            HashAlgorithm::Sha1 => &self.sha1,
124
+            HashAlgorithm::Sha256 => &self.sha256,
125
+        }
126
+    }
127
+}
128
+
129
+pub fn entitlements_xml(entitlements: &Dictionary) -> Result<Vec<u8>> {
130
+    let mut data = Vec::new();
131
+    plist::to_writer_xml(&mut data, &Value::Dictionary(entitlements.clone()))?;
132
+    Ok(data)
133
+}
134
+
135
+pub fn empty_requirements_blob() -> Vec<u8> {
136
+    let mut out = Vec::with_capacity(12);
137
+    push_be_u32(&mut out, CSMAGIC_REQUIREMENTS);
138
+    push_be_u32(&mut out, 12);
139
+    push_be_u32(&mut out, 0);
140
+    out
141
+}
142
+
143
+pub fn entitlements_blob(xml: &[u8]) -> Vec<u8> {
144
+    let mut out = Vec::with_capacity(8 + xml.len());
145
+    push_be_u32(&mut out, CSMAGIC_EMBEDDED_ENTITLEMENTS);
146
+    push_be_u32(&mut out, (8 + xml.len()) as u32);
147
+    out.extend_from_slice(xml);
148
+    out
149
+}
150
+
151
+pub fn der_entitlements_blob(entitlements: &Dictionary) -> Result<Vec<u8>> {
152
+    let der = der_entitlements(entitlements)?;
153
+    let mut out = Vec::with_capacity(8 + der.len());
154
+    push_be_u32(&mut out, CSMAGIC_EMBEDDED_DER_ENTITLEMENTS);
155
+    push_be_u32(&mut out, (8 + der.len()) as u32);
156
+    out.extend_from_slice(&der);
157
+    Ok(out)
158
+}
159
+
160
+pub fn entitlement_exec_flags(entitlements: &Dictionary, is_main_binary: bool) -> u64 {
161
+    let mut flags = if is_main_binary {
162
+        CS_EXECSEG_MAIN_BINARY
163
+    } else {
164
+        0
165
+    };
166
+
167
+    for (key, flag) in [
168
+        ("get-task-allow", CS_EXECSEG_ALLOW_UNSIGNED),
169
+        ("run-unsigned-code", CS_EXECSEG_ALLOW_UNSIGNED),
170
+        ("com.apple.private.cs.debugger", CS_EXECSEG_DEBUGGER),
171
+        ("dynamic-codesigning", CS_EXECSEG_JIT),
172
+        (
173
+            "com.apple.private.skip-library-validation",
174
+            CS_EXECSEG_SKIP_LV,
175
+        ),
176
+        (
177
+            "com.apple.private.amfi.can-load-cdhash",
178
+            CS_EXECSEG_CAN_LOAD_CDHASH,
179
+        ),
180
+        (
181
+            "com.apple.private.amfi.can-execute-cdhash",
182
+            CS_EXECSEG_CAN_EXEC_CDHASH,
183
+        ),
184
+    ] {
185
+        if matches!(entitlements.get(key), Some(Value::Boolean(true))) {
186
+            flags |= flag;
187
+        }
188
+    }
189
+
190
+    flags
191
+}
192
+
193
+pub(crate) struct SuperblobSizePlan<'a> {
194
+    pub identifier: &'a str,
195
+    pub team_id: &'a str,
196
+    pub code_limit: usize,
197
+    pub is_executable: bool,
198
+    pub requirements_blob_len: usize,
199
+    pub entitlements_blob_len: usize,
200
+    pub der_entitlements_blob_len: Option<usize>,
201
+    pub has_cms_signature: bool,
202
+    pub cms_blob_reservation: usize,
203
+}
204
+
205
+pub(crate) fn planned_superblob_len_from_parts(plan: SuperblobSizePlan<'_>) -> usize {
206
+    let count = 4
207
+        + usize::from(plan.der_entitlements_blob_len.is_some())
208
+        + usize::from(plan.has_cms_signature);
209
+    let mut len = 12 + count * 8;
210
+
211
+    len += code_directory_len(
212
+        HashAlgorithm::Sha1,
213
+        plan.identifier,
214
+        plan.team_id,
215
+        plan.code_limit,
216
+        plan.is_executable,
217
+    );
218
+    len += code_directory_len(
219
+        HashAlgorithm::Sha256,
220
+        plan.identifier,
221
+        plan.team_id,
222
+        plan.code_limit,
223
+        plan.is_executable,
224
+    );
225
+    len += plan.requirements_blob_len;
226
+    len += plan.entitlements_blob_len;
227
+
228
+    if let Some(der_entitlements_blob_len) = plan.der_entitlements_blob_len {
229
+        len += der_entitlements_blob_len;
230
+    }
231
+
232
+    if plan.has_cms_signature {
233
+        len += plan.cms_blob_reservation;
234
+    }
235
+
236
+    len
237
+}
238
+
239
+pub fn encode_embedded_signature(
240
+    params: &CodeDirectoryParams<'_>,
241
+    cms_signer: Option<&dyn CmsSigner>,
242
+    cms_blob_reservation: usize,
243
+) -> Result<Vec<u8>> {
244
+    if params.code_limit > params.macho_data.len() {
245
+        return Err(CodeSignError::macho(
246
+            "<memory>",
247
+            format!(
248
+                "code limit {} exceeds Mach-O length {}",
249
+                params.code_limit,
250
+                params.macho_data.len()
251
+            ),
252
+        ));
253
+    }
254
+
255
+    let code_hashes = hash_code_pages(&params.macho_data[..params.code_limit]);
256
+    let (primary_code_directory, alternate_code_directory) =
257
+        if should_parallelize_code_directory_encoding(params, code_hashes.len()) {
258
+            let (primary, alternate) = rayon::join(
259
+                || encode_code_directory(params, HashAlgorithm::Sha1, &code_hashes),
260
+                || encode_code_directory(params, HashAlgorithm::Sha256, &code_hashes),
261
+            );
262
+            (primary?, alternate?)
263
+        } else {
264
+            (
265
+                encode_code_directory(params, HashAlgorithm::Sha1, &code_hashes)?,
266
+                encode_code_directory(params, HashAlgorithm::Sha256, &code_hashes)?,
267
+            )
268
+        };
269
+
270
+    let signature_blob = if let Some(cms_signer) = cms_signer {
271
+        let directories = [
272
+            EncodedCodeDirectory {
273
+                algorithm: HashAlgorithm::Sha1,
274
+                bytes: &primary_code_directory,
275
+            },
276
+            EncodedCodeDirectory {
277
+                algorithm: HashAlgorithm::Sha256,
278
+                bytes: &alternate_code_directory,
279
+            },
280
+        ];
281
+        let cms = cms_signer.sign(CmsSigningRequest {
282
+            code_directories: &directories,
283
+        })?;
284
+        Some(signature_blob_wrapper(&cms, cms_blob_reservation)?)
285
+    } else {
286
+        None
287
+    };
288
+
289
+    let mut blobs: Vec<(u32, Cow<'_, [u8]>)> = vec![
290
+        (CSSLOT_CODEDIRECTORY, Cow::Owned(primary_code_directory)),
291
+        (CSSLOT_REQUIREMENTS, Cow::Borrowed(params.requirements_blob)),
292
+        (CSSLOT_ENTITLEMENTS, Cow::Borrowed(params.entitlements_blob)),
293
+    ];
294
+
295
+    if let Some(der_entitlements_blob) = params.der_entitlements_blob {
296
+        blobs.push((
297
+            CSSLOT_DER_ENTITLEMENTS,
298
+            Cow::Borrowed(der_entitlements_blob),
299
+        ));
300
+    }
301
+
302
+    blobs.push((
303
+        CSSLOT_ALTERNATE_CODEDIRECTORIES,
304
+        Cow::Owned(alternate_code_directory),
305
+    ));
306
+
307
+    if let Some(signature_blob) = signature_blob {
308
+        blobs.push((CSSLOT_SIGNATURESLOT, Cow::Owned(signature_blob)));
309
+    }
310
+
311
+    Ok(encode_superblob(blobs))
312
+}
313
+
314
+fn code_directory_len(
315
+    algorithm: HashAlgorithm,
316
+    identifier: &str,
317
+    team_id: &str,
318
+    code_limit: usize,
319
+    is_executable: bool,
320
+) -> usize {
321
+    let hash_len = algorithm.digest_len();
322
+    let special_slots = if is_executable { 7 } else { 5 };
323
+    let code_slots = code_limit.div_ceil(CODEDIRECTORY_PAGE_SIZE);
324
+
325
+    CODEDIRECTORY_HEADER_LEN
326
+        + identifier.len()
327
+        + 1
328
+        + team_id.len()
329
+        + 1
330
+        + (special_slots + code_slots) * hash_len
331
+}
332
+
333
+fn encode_code_directory(
334
+    params: &CodeDirectoryParams<'_>,
335
+    algorithm: HashAlgorithm,
336
+    code_hashes: &[CodePageHashes],
337
+) -> Result<Vec<u8>> {
338
+    let hash_len = algorithm.digest_len();
339
+    let special_slot_count = if params.is_executable { 7 } else { 5 };
340
+    let code_slot_count = code_hashes.len();
341
+    let dynamic_len = params.identifier.len()
342
+        + 1
343
+        + params.team_id.len()
344
+        + 1
345
+        + special_slot_count * hash_len
346
+        + code_slot_count * hash_len;
347
+    let ident_offset = CODEDIRECTORY_HEADER_LEN;
348
+    let team_offset = ident_offset + params.identifier.len() + 1;
349
+    let hash_offset = team_offset + params.team_id.len() + 1 + special_slot_count * hash_len;
350
+    let length = CODEDIRECTORY_HEADER_LEN + dynamic_len;
351
+
352
+    let mut out = Vec::with_capacity(length);
353
+    push_be_u32(&mut out, CSMAGIC_CODEDIRECTORY);
354
+    push_be_u32(&mut out, length as u32);
355
+    push_be_u32(&mut out, CODEDIRECTORY_VERSION_EXECSEG);
356
+    push_be_u32(&mut out, 0);
357
+    push_be_u32(&mut out, hash_offset as u32);
358
+    push_be_u32(&mut out, ident_offset as u32);
359
+    push_be_u32(&mut out, special_slot_count as u32);
360
+    push_be_u32(&mut out, code_slot_count as u32);
361
+    push_be_u32(&mut out, u32::try_from(params.code_limit).unwrap_or(0));
362
+    out.push(hash_len as u8);
363
+    out.push(algorithm.code_directory_hash_type());
364
+    out.push(0);
365
+    out.push(CODEDIRECTORY_PAGE_SIZE_LOG2);
366
+    push_be_u32(&mut out, 0);
367
+    push_be_u32(&mut out, 0);
368
+    push_be_u32(&mut out, team_offset as u32);
369
+    push_be_u32(&mut out, 0);
370
+    push_be_u64(
371
+        &mut out,
372
+        if params.code_limit > u32::MAX as usize {
373
+            params.code_limit as u64
374
+        } else {
375
+            0
376
+        },
377
+    );
378
+    push_be_u64(&mut out, params.executable_segment.base);
379
+    push_be_u64(&mut out, params.executable_segment.limit);
380
+    push_be_u64(&mut out, params.executable_segment.flags);
381
+
382
+    out.extend_from_slice(params.identifier.as_bytes());
383
+    out.push(0);
384
+    out.extend_from_slice(params.team_id.as_bytes());
385
+    out.push(0);
386
+
387
+    if params.is_executable {
388
+        let der = params.der_entitlements_blob.ok_or_else(|| {
389
+            CodeSignError::macho("<memory>", "executable signatures require DER entitlements")
390
+        })?;
391
+        algorithm.push_digest(&mut out, der);
392
+        push_zero_hash(&mut out, hash_len);
393
+    }
394
+
395
+    algorithm.push_digest(&mut out, params.entitlements_blob);
396
+    push_zero_hash(&mut out, hash_len);
397
+
398
+    if let Some(code_resources) = params.code_resources {
399
+        algorithm.push_digest(&mut out, code_resources);
400
+    } else {
401
+        push_zero_hash(&mut out, hash_len);
402
+    }
403
+
404
+    algorithm.push_digest(&mut out, params.requirements_blob);
405
+
406
+    if let Some(info_plist) = params.info_plist {
407
+        algorithm.push_digest(&mut out, info_plist);
408
+    } else {
409
+        push_zero_hash(&mut out, hash_len);
410
+    }
411
+
412
+    for slot in code_hashes {
413
+        out.extend_from_slice(slot.get(algorithm));
414
+    }
415
+
416
+    debug_assert_eq!(out.len(), length);
417
+
418
+    Ok(out)
419
+}
420
+
421
+fn hash_code_pages(code: &[u8]) -> Vec<CodePageHashes> {
422
+    let pages = code.len().div_ceil(CODEDIRECTORY_PAGE_SIZE);
423
+    let mut hashes = vec![CodePageHashes::default(); pages];
424
+
425
+    if pages < 8 {
426
+        for (hash, page) in hashes.iter_mut().zip(code.chunks(CODEDIRECTORY_PAGE_SIZE)) {
427
+            *hash = hash_code_page(page);
428
+        }
429
+        return hashes;
430
+    }
431
+
432
+    hashes
433
+        .par_chunks_mut(CODEDIRECTORY_PARALLEL_HASH_PAGES)
434
+        .enumerate()
435
+        .for_each(|(chunk_index, out)| {
436
+            let start_page = chunk_index * CODEDIRECTORY_PARALLEL_HASH_PAGES;
437
+            let start = start_page * CODEDIRECTORY_PAGE_SIZE;
438
+            let end = (start + out.len() * CODEDIRECTORY_PAGE_SIZE).min(code.len());
439
+
440
+            for (hash, page) in out
441
+                .iter_mut()
442
+                .zip(code[start..end].chunks(CODEDIRECTORY_PAGE_SIZE))
443
+            {
444
+                *hash = hash_code_page(page);
445
+            }
446
+        });
447
+
448
+    hashes
449
+}
450
+
451
+fn hash_code_page(page: &[u8]) -> CodePageHashes {
452
+    CodePageHashes {
453
+        sha1: sha1::Sha1::digest(page).into(),
454
+        sha256: sha2::Sha256::digest(page).into(),
455
+    }
456
+}
457
+
458
+fn should_parallelize_code_directory_encoding(
459
+    params: &CodeDirectoryParams<'_>,
460
+    code_page_count: usize,
461
+) -> bool {
462
+    code_page_count >= 8
463
+        || params
464
+            .code_resources
465
+            .is_some_and(|data| data.len() >= 64 * 1024)
466
+}
467
+
468
+fn push_zero_hash(out: &mut Vec<u8>, hash_len: usize) {
469
+    out.resize(out.len() + hash_len, 0);
470
+}
471
+
472
+fn signature_blob_wrapper(cms: &[u8], reserved_len: usize) -> Result<Vec<u8>> {
473
+    let actual_len = 8 + cms.len();
474
+    if actual_len > reserved_len {
475
+        return Err(CodeSignError::SignatureTooLarge {
476
+            actual: actual_len,
477
+            reserved: reserved_len,
478
+        });
479
+    }
480
+
481
+    let mut out = Vec::with_capacity(actual_len);
482
+    push_be_u32(&mut out, CSMAGIC_BLOBWRAPPER);
483
+    push_be_u32(&mut out, actual_len as u32);
484
+    out.extend_from_slice(cms);
485
+    Ok(out)
486
+}
487
+
488
+fn encode_superblob(mut blobs: Vec<(u32, Cow<'_, [u8]>)>) -> Vec<u8> {
489
+    if let Some(primary_index) = blobs
490
+        .iter()
491
+        .position(|(slot, _)| *slot == CSSLOT_CODEDIRECTORY)
492
+    {
493
+        blobs.swap(0, primary_index);
494
+    }
495
+
496
+    let index_len = blobs.len() * 8;
497
+    let data_len: usize = blobs.iter().map(|(_, data)| data.as_ref().len()).sum();
498
+    let total_len = 12 + index_len + data_len;
499
+
500
+    let mut out = Vec::with_capacity(total_len);
501
+    push_be_u32(&mut out, CSMAGIC_EMBEDDED_SIGNATURE);
502
+    push_be_u32(&mut out, total_len as u32);
503
+    push_be_u32(&mut out, blobs.len() as u32);
504
+
505
+    let mut offset = 12 + index_len;
506
+    for (slot, data) in &blobs {
507
+        push_be_u32(&mut out, *slot);
508
+        push_be_u32(&mut out, offset as u32);
509
+        offset += data.as_ref().len();
510
+    }
511
+
512
+    for (_, data) in blobs {
513
+        out.extend_from_slice(data.as_ref());
514
+    }
515
+
516
+    out
517
+}
518
+
519
+pub fn der_entitlements(entitlements: &Dictionary) -> Result<Vec<u8>> {
520
+    let dictionary = der_dictionary_body(entitlements, "$")?;
521
+    let mut body = der_tlv(0x02, &[1]);
522
+    body.extend_from_slice(&der_tlv(0xb0, &dictionary));
523
+    Ok(der_tlv(0x70, &body))
524
+}
525
+
526
+fn der_value(value: &Value, path: &str) -> Result<Vec<u8>> {
527
+    match value {
528
+        Value::Boolean(value) => Ok(der_tlv(0x01, &[if *value { 0xff } else { 0x00 }])),
529
+        Value::Integer(value) => {
530
+            if let Some(unsigned) = value.as_unsigned() {
531
+                Ok(der_unsigned_integer(unsigned))
532
+            } else if let Some(signed) = value.as_signed() {
533
+                if signed >= 0 {
534
+                    Ok(der_unsigned_integer(signed as u64))
535
+                } else {
536
+                    Err(CodeSignError::UnsupportedEntitlement(path.to_string()))
537
+                }
538
+            } else {
539
+                Err(CodeSignError::UnsupportedEntitlement(path.to_string()))
540
+            }
541
+        }
542
+        Value::String(value) => Ok(der_tlv(0x0c, value.as_bytes())),
543
+        Value::Array(values) => {
544
+            let mut body = Vec::new();
545
+            for (index, value) in values.iter().enumerate() {
546
+                body.extend_from_slice(&der_value(value, &format!("{path}[{index}]"))?);
547
+            }
548
+            Ok(der_tlv(0x30, &body))
549
+        }
550
+        Value::Dictionary(value) => der_dictionary(value, path),
551
+        _ => Err(CodeSignError::UnsupportedEntitlement(path.to_string())),
552
+    }
553
+}
554
+
555
+fn der_dictionary(dict: &Dictionary, path: &str) -> Result<Vec<u8>> {
556
+    Ok(der_tlv(0x31, &der_dictionary_body(dict, path)?))
557
+}
558
+
559
+fn der_dictionary_body(dict: &Dictionary, path: &str) -> Result<Vec<u8>> {
560
+    let mut entries = Vec::with_capacity(dict.len());
561
+
562
+    for (key, value) in dict {
563
+        let mut entry_body = der_tlv(0x0c, key.as_bytes());
564
+        entry_body.extend_from_slice(&der_value(value, &format!("{path}.{key}"))?);
565
+        entries.push((key.as_str(), der_tlv(0x30, &entry_body)));
566
+    }
567
+
568
+    entries.sort_by(|(left, _), (right, _)| left.as_bytes().cmp(right.as_bytes()));
569
+
570
+    let body_len = entries.iter().map(|(_, entry)| entry.len()).sum();
571
+    let mut body = Vec::with_capacity(body_len);
572
+    for (_, entry) in entries {
573
+        body.extend_from_slice(&entry);
574
+    }
575
+
576
+    Ok(body)
577
+}
578
+
579
+fn der_unsigned_integer(value: u64) -> Vec<u8> {
580
+    let bytes = value.to_be_bytes();
581
+    let first_non_zero = bytes
582
+        .iter()
583
+        .position(|byte| *byte != 0)
584
+        .unwrap_or(bytes.len() - 1);
585
+    let significant = &bytes[first_non_zero..];
586
+
587
+    let mut body = Vec::with_capacity(significant.len() + 1);
588
+    if significant[0] & 0x80 != 0 {
589
+        body.push(0);
590
+    }
591
+    body.extend_from_slice(significant);
592
+    der_tlv(0x02, &body)
593
+}
594
+
595
+fn der_tlv(tag: u8, body: &[u8]) -> Vec<u8> {
596
+    let mut out = Vec::with_capacity(1 + 5 + body.len());
597
+    out.push(tag);
598
+    write_der_len(&mut out, body.len());
599
+    out.extend_from_slice(body);
600
+    out
601
+}
602
+
603
+fn write_der_len(out: &mut Vec<u8>, len: usize) {
604
+    if len < 128 {
605
+        out.push(len as u8);
606
+        return;
607
+    }
608
+
609
+    let bytes = len.to_be_bytes();
610
+    let first = bytes
611
+        .iter()
612
+        .position(|byte| *byte != 0)
613
+        .unwrap_or(bytes.len() - 1);
614
+    let significant = &bytes[first..];
615
+    out.push(0x80 | significant.len() as u8);
616
+    out.extend_from_slice(significant);
617
+}
618
+
619
+fn push_be_u32(out: &mut Vec<u8>, value: u32) {
620
+    out.extend_from_slice(&value.to_be_bytes());
621
+}
622
+
623
+fn push_be_u64(out: &mut Vec<u8>, value: u64) {
624
+    out.extend_from_slice(&value.to_be_bytes());
625
+}
626
+
627
+#[cfg(test)]
628
+mod tests {
629
+    use super::*;
630
+
631
+    #[test]
632
+    fn der_entitlements_use_apple_versioned_application_envelope() {
633
+        assert_eq!(
634
+            der_entitlements(&Dictionary::new()).unwrap(),
635
+            [0x70, 0x05, 0x02, 0x01, 0x01, 0xb0, 0x00]
636
+        );
637
+    }
638
+
639
+    #[test]
640
+    fn der_entitlements_blob_reports_the_wrapped_length() {
641
+        let blob = der_entitlements_blob(&Dictionary::new()).unwrap();
642
+
643
+        assert_eq!(&blob[..4], &CSMAGIC_EMBEDDED_DER_ENTITLEMENTS.to_be_bytes());
644
+        assert_eq!(
645
+            u32::from_be_bytes(blob[4..8].try_into().unwrap()) as usize,
646
+            blob.len()
647
+        );
648
+        assert_eq!(&blob[8..], &[0x70, 0x05, 0x02, 0x01, 0x01, 0xb0, 0x00]);
649
+    }
650
+
651
+    #[test]
652
+    fn entitlement_dictionary_entries_are_sorted_by_key() {
653
+        let mut entitlements = Dictionary::new();
654
+        entitlements.insert("z".to_string(), Value::Boolean(true));
655
+        entitlements.insert(
656
+            "application-identifier".to_string(),
657
+            Value::String("TEAM.com.example.App".to_string()),
658
+        );
659
+        let body = der_dictionary_body(&entitlements, "$").unwrap();
660
+        let expected_first = der_tlv(
661
+            0x30,
662
+            &[
663
+                der_tlv(0x0c, b"application-identifier"),
664
+                der_tlv(0x0c, b"TEAM.com.example.App"),
665
+            ]
666
+            .concat(),
667
+        );
668
+
669
+        assert!(body.starts_with(&expected_first));
670
+    }
671
+
672
+    #[test]
673
+    fn cms_reservation_is_not_encoded_inside_blob_wrapper() {
674
+        let cms = [0x30, 0x00];
675
+        let blob = signature_blob_wrapper(&cms, 32).unwrap();
676
+
677
+        assert_eq!(blob.len(), 10);
678
+        assert_eq!(u32::from_be_bytes(blob[4..8].try_into().unwrap()), 10);
679
+        assert_eq!(&blob[8..10], &cms);
680
+    }
681
+
682
+    #[test]
683
+    fn cms_reservation_is_not_counted_in_outer_superblob_length() {
684
+        let cms = signature_blob_wrapper(&[0x30, 0x00], 32).unwrap();
685
+        let superblob = encode_superblob(vec![
686
+            (CSSLOT_CODEDIRECTORY, Cow::Owned(vec![0; 8])),
687
+            (CSSLOT_SIGNATURESLOT, Cow::Owned(cms)),
688
+        ]);
689
+
690
+        assert_eq!(
691
+            u32::from_be_bytes(superblob[4..8].try_into().unwrap()) as usize,
692
+            superblob.len()
693
+        );
694
+        assert_eq!(superblob.len(), 46);
695
+    }
696
+}
... ...
@@ -6,28 +6,35 @@ edition = "2021"
6 6
 [dependencies]
7 7
 adi.workspace = true
8 8
 android-coreadi.workspace = true
9
-async_zip = { version = "0.0.18", features = ["deflate", "tokio-fs"] }
9
+apple-codesign = { path = "../apple-codesign-quick" }
10
+async_zip = { version = "0.0.18", features = ["deflate", "tokio", "tokio-fs"] }
10 11
 chrono = { version = "0.4.42", default-features = false, features = ["clock"] }
12
+crc32fast = "1.5"
11 13
 directories = "6.0.0"
12 14
 env_logger = "0.11.8"
15
+flate2 = "1.1"
13 16
 futures-lite = "2.6.1"
14 17
 grandslam.workspace = true
15 18
 gpui = { package = "gpui", git = "https://github.com/gpui-ce/gpui-ce" }
16 19
 gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "277f220a6196572116a8fd84fba6c36187094cab" }
17 20
 gpui_platform = { git = "https://github.com/gpui-ce/gpui-ce", features = ["font-kit", "x11", "wayland", "runtime_shaders"] }
18
-idevice = { version = "0.1.64", features = ["usbmuxd"] }
21
+idevice = { version = "0.1.64", features = ["afc", "installation_proxy", "usbmuxd"] }
22
+image = { version = "0.25", default-features = false, features = ["png"] }
19 23
 futures = "0.3"
20 24
 keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
21 25
 log = "0.4.33"
22 26
 plist.workspace = true
27
+pem-rfc7468 = { version = "0.7", features = ["alloc"] }
23 28
 rand = "0.10.2"
24 29
 reqwest = { workspace = true, features = ["blocking"] }
25 30
 serde = { version = "1.0.228", features = ["derive"] }
26 31
 sha1.workspace = true
27 32
 thiserror.workspace = true
33
+tempfile = "3.27"
28 34
 toml = "0.8.23"
29
-tokio = { workspace = true, features = ["io-util", "net", "time"] }
35
+tokio = { workspace = true, features = ["fs", "io-util", "net", "time"] }
30 36
 uuid = { version = "1.19", features = ["v4"] }
37
+walkdir = "2.5"
31 38
 xcode.workspace = true
32 39
 
33 40
 [target.'cfg(target_os = "macos")'.dependencies]
... ...
@@ -1,14 +1,20 @@
1
+use crate::app::entitlements::{
2
+    effective_bundle_identifier, effective_bundle_identifier_for_app,
3
+    effective_nested_bundle_identifier,
4
+};
1 5
 use crate::app::models::{
2
-    AccountOption, AdiBackendKind, AdiBackendOption, AppOption, DeviceOption, MachineIdentity,
3
-    PatchOption,
6
+    AccountOption, AdiBackendKind, AdiBackendOption, AppIdOption, AppOption, DeveloperDeviceOption,
7
+    DeviceOption, MachineIdentity, PatchOption, TeamOption,
4 8
 };
5 9
 use crate::app::selection;
6 10
 use crate::app::view_models::{
7
-    account_option, account_options, adi_backends, app_option, device_options, domain_adi_kind,
8
-    domain_machine_identity, domain_patch, machine_identity,
11
+    account_option, account_options, adi_backends, app_option, developer_device_options,
12
+    device_options, domain_adi_kind, domain_app_metadata, domain_machine_identity, domain_patch,
13
+    machine_identity,
9 14
 };
10 15
 use crate::app::{AppError, AppResult};
11 16
 use crate::backend::{adi_services, developer_services, device_discovery, system_identity};
17
+use std::collections::BTreeMap;
12 18
 use std::path::{Path, PathBuf};
13 19
 
14 20
 pub(crate) use crate::domain::DeviceWatchEvent;
... ...
@@ -17,6 +23,220 @@ pub(crate) use developer_services::{
17 23
     DeveloperAppIdCapabilityUpdate, DownloadedProvisioningProfile,
18 24
 };
19 25
 
26
+#[derive(Clone)]
27
+pub(crate) struct SignIpaRequest {
28
+    pub(crate) developer_context: DeveloperSessionContext,
29
+    pub(crate) team_id: String,
30
+    pub(crate) team_app_ids: Vec<AppIdOption>,
31
+    pub(crate) certificate_fingerprint: String,
32
+    pub(crate) public_key_fingerprint: String,
33
+    pub(crate) auto_app_id: bool,
34
+    pub(crate) selected_app_id: Option<AppIdOption>,
35
+    pub(crate) app: AppOption,
36
+    pub(crate) output: SignIpaOutput,
37
+}
38
+
39
+pub(crate) struct SignIpaOutcome {
40
+    pub(crate) artifact: SignIpaArtifact,
41
+    pub(crate) updated_account: Option<AccountOption>,
42
+}
43
+
44
+#[derive(Clone)]
45
+pub(crate) enum SignIpaOutput {
46
+    Ipa(PathBuf),
47
+    AppBundle,
48
+}
49
+
50
+pub(crate) enum SignIpaArtifact {
51
+    Ipa(PathBuf),
52
+    AppBundle(crate::backend::ipa::SignedAppBundle),
53
+}
54
+
55
+#[derive(Clone, Debug, Eq, PartialEq)]
56
+pub(crate) struct AppIdProvisioningItem {
57
+    pub(crate) name: String,
58
+    pub(crate) identifier: String,
59
+}
60
+
61
+#[derive(Clone, Debug, Eq, PartialEq)]
62
+pub(crate) struct AppIdProvisioningPlan {
63
+    pub(crate) app_ids: Vec<AppIdProvisioningItem>,
64
+    pub(crate) available_quantity: Option<u64>,
65
+}
66
+
67
+impl AppIdProvisioningPlan {
68
+    pub(crate) fn remaining_after_signing(&self) -> Option<u64> {
69
+        self.available_quantity
70
+            .map(|available| available.saturating_sub(self.app_ids.len() as u64))
71
+    }
72
+}
73
+
74
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
75
+pub(crate) enum SignIpaProgress {
76
+    Preparing,
77
+    ResolvingAppId,
78
+    DownloadingProfile,
79
+    Extracting { completed: usize, total: usize },
80
+    Patching,
81
+    Signing,
82
+    Packaging { completed: usize, total: usize },
83
+    Saving,
84
+    Ready,
85
+}
86
+
87
+impl SignIpaProgress {
88
+    pub(crate) fn progress(self) -> f32 {
89
+        match self {
90
+            Self::Preparing => 0.04,
91
+            Self::ResolvingAppId => 0.1,
92
+            Self::DownloadingProfile => 0.22,
93
+            Self::Extracting { completed, total } => 0.3 + progress_ratio(completed, total) * 0.25,
94
+            Self::Patching => 0.58,
95
+            Self::Signing => 0.64,
96
+            Self::Packaging { completed, total } => 0.72 + progress_ratio(completed, total) * 0.24,
97
+            Self::Saving => 0.98,
98
+            Self::Ready => 0.98,
99
+        }
100
+    }
101
+
102
+    pub(crate) fn label(self) -> String {
103
+        match self {
104
+            Self::Preparing => "Preparing signing resources".to_string(),
105
+            Self::ResolvingAppId => "Resolving App ID".to_string(),
106
+            Self::DownloadingProfile => "Downloading provisioning profile".to_string(),
107
+            Self::Extracting { completed, total } => {
108
+                format!("Extracting IPA ({completed}/{total})")
109
+            }
110
+            Self::Patching => "Applying app changes".to_string(),
111
+            Self::Signing => "Signing app bundle".to_string(),
112
+            Self::Packaging { completed, total } => {
113
+                format!("Packaging signed IPA ({completed}/{total})")
114
+            }
115
+            Self::Saving => "Saving signed IPA".to_string(),
116
+            Self::Ready => "Signed app ready for transfer".to_string(),
117
+        }
118
+    }
119
+}
120
+
121
+impl From<crate::backend::ipa::IpaSigningProgress> for SignIpaProgress {
122
+    fn from(progress: crate::backend::ipa::IpaSigningProgress) -> Self {
123
+        match progress {
124
+            crate::backend::ipa::IpaSigningProgress::Extracting { completed, total } => {
125
+                Self::Extracting { completed, total }
126
+            }
127
+            crate::backend::ipa::IpaSigningProgress::Patching => Self::Patching,
128
+            crate::backend::ipa::IpaSigningProgress::Signing => Self::Signing,
129
+            crate::backend::ipa::IpaSigningProgress::Packaging { completed, total } => {
130
+                Self::Packaging { completed, total }
131
+            }
132
+            crate::backend::ipa::IpaSigningProgress::Saving => Self::Saving,
133
+            crate::backend::ipa::IpaSigningProgress::Ready => Self::Ready,
134
+        }
135
+    }
136
+}
137
+
138
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
139
+pub(crate) enum InstallAppProgress {
140
+    Connecting,
141
+    Uploading {
142
+        transferred_bytes: u64,
143
+        total_bytes: u64,
144
+        completed_files: usize,
145
+        total_files: usize,
146
+    },
147
+    Installing {
148
+        percent: u64,
149
+    },
150
+    Finalizing,
151
+}
152
+
153
+impl InstallAppProgress {
154
+    pub(crate) fn progress(self) -> f32 {
155
+        match self {
156
+            Self::Connecting => 0.02,
157
+            Self::Uploading {
158
+                transferred_bytes,
159
+                total_bytes,
160
+                ..
161
+            } => 0.05 + byte_progress_ratio(transferred_bytes, total_bytes) * 0.43,
162
+            Self::Installing { percent } => 0.5 + (percent.min(100) as f32 / 100.) * 0.47,
163
+            Self::Finalizing => 0.99,
164
+        }
165
+    }
166
+
167
+    pub(crate) fn label(self) -> String {
168
+        match self {
169
+            Self::Connecting => "Connecting to device".to_string(),
170
+            Self::Uploading {
171
+                transferred_bytes,
172
+                total_bytes,
173
+                completed_files,
174
+                total_files,
175
+            } => format!(
176
+                "Uploading app files ({completed_files}/{total_files}, {} / {})",
177
+                formatted_byte_count(transferred_bytes),
178
+                formatted_byte_count(total_bytes)
179
+            ),
180
+            Self::Installing { percent } => {
181
+                format!("Verifying and installing on device ({percent}%)")
182
+            }
183
+            Self::Finalizing => "Cleaning up device staging".to_string(),
184
+        }
185
+    }
186
+}
187
+
188
+impl From<crate::domain::DeviceInstallProgress> for InstallAppProgress {
189
+    fn from(progress: crate::domain::DeviceInstallProgress) -> Self {
190
+        match progress {
191
+            crate::domain::DeviceInstallProgress::Connecting => Self::Connecting,
192
+            crate::domain::DeviceInstallProgress::Uploading {
193
+                transferred_bytes,
194
+                total_bytes,
195
+                completed_files,
196
+                total_files,
197
+            } => Self::Uploading {
198
+                transferred_bytes,
199
+                total_bytes,
200
+                completed_files,
201
+                total_files,
202
+            },
203
+            crate::domain::DeviceInstallProgress::Installing { percent } => {
204
+                Self::Installing { percent }
205
+            }
206
+            crate::domain::DeviceInstallProgress::Finalizing => Self::Finalizing,
207
+        }
208
+    }
209
+}
210
+
211
+fn progress_ratio(completed: usize, total: usize) -> f32 {
212
+    if total == 0 {
213
+        1.
214
+    } else {
215
+        (completed as f32 / total as f32).clamp(0., 1.)
216
+    }
217
+}
218
+
219
+fn byte_progress_ratio(completed: u64, total: u64) -> f32 {
220
+    if total == 0 {
221
+        1.
222
+    } else {
223
+        (completed as f32 / total as f32).clamp(0., 1.)
224
+    }
225
+}
226
+
227
+fn formatted_byte_count(bytes: u64) -> String {
228
+    const MEBIBYTE: f64 = 1024. * 1024.;
229
+    const KIBIBYTE: f64 = 1024.;
230
+
231
+    if bytes as f64 >= MEBIBYTE {
232
+        format!("{:.1} MB", bytes as f64 / MEBIBYTE)
233
+    } else if bytes as f64 >= KIBIBYTE {
234
+        format!("{:.1} KB", bytes as f64 / KIBIBYTE)
235
+    } else {
236
+        format!("{bytes} B")
237
+    }
238
+}
239
+
20 240
 #[derive(Clone, Debug)]
21 241
 pub(crate) struct DeveloperSessionContext {
22 242
     inner: developer_services::DeveloperSessionContext,
... ...
@@ -157,6 +377,315 @@ pub(crate) async fn load_ipa(path: PathBuf, patches: Vec<PatchOption>) -> AppRes
157 377
         .map_err(AppError::from)
158 378
 }
159 379
 
380
+pub(crate) async fn install_app(
381
+    udid: String,
382
+    signed_app: crate::backend::ipa::SignedAppBundle,
383
+    mut progress: impl FnMut(InstallAppProgress) + Send + 'static,
384
+) -> AppResult<()> {
385
+    crate::backend::device::install_app(udid, signed_app, move |event| {
386
+        progress(event.into());
387
+    })
388
+    .await
389
+    .map_err(AppError::from)
390
+}
391
+
392
+pub(crate) async fn sign_ipa(
393
+    request: SignIpaRequest,
394
+    mut progress: impl FnMut(SignIpaProgress) + Send + 'static,
395
+) -> AppResult<SignIpaOutcome> {
396
+    if request.app.bundle_id().trim().is_empty() {
397
+        return Err(AppError::from(
398
+            "The app bundle identifier cannot be empty when signing.",
399
+        ));
400
+    }
401
+    if request.app.name().trim().is_empty() {
402
+        return Err(AppError::from("The app name cannot be empty when signing."));
403
+    }
404
+    progress(SignIpaProgress::Preparing);
405
+    let signing_material = developer_services::load_signing_material(
406
+        &request.certificate_fingerprint,
407
+        &request.public_key_fingerprint,
408
+    )
409
+    .map_err(AppError::from)?;
410
+
411
+    progress(SignIpaProgress::ResolvingAppId);
412
+    let root_identifier = developer_app_identifier(&request.team_id, request.app.bundle_id());
413
+    let (app_id, mut updated_account) = resolve_signing_app_id(
414
+        request.developer_context.clone(),
415
+        &request.team_id,
416
+        &request.team_app_ids,
417
+        request.auto_app_id,
418
+        request.selected_app_id,
419
+        &root_identifier,
420
+        request.app.name(),
421
+    )
422
+    .await?;
423
+    if app_id.developer_id.trim().is_empty() {
424
+        return Err(AppError::from(
425
+            "The selected App ID is missing its Xcode identifier. Refresh Developer Settings, then try again.",
426
+        ));
427
+    }
428
+    let expected_identifier = developer_app_identifier(&request.team_id, request.app.bundle_id());
429
+    if !app_id_matches_identifier(&app_id.identifier, &expected_identifier) {
430
+        return Err(AppError::from(format!(
431
+            "App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
432
+            app_id.identifier,
433
+            request.app.bundle_id()
434
+        )));
435
+    }
436
+
437
+    let mut team_app_ids = updated_account
438
+        .as_ref()
439
+        .and_then(|account| team_app_ids_for_account(account, &request.team_id))
440
+        .unwrap_or_else(|| request.team_app_ids.clone());
441
+    let effective_bundle_id = effective_bundle_identifier_for_app(&request.app, &request.team_id);
442
+    let mut bundle_id_replacements = BTreeMap::from([(
443
+        request.app.bundle_id().to_string(),
444
+        effective_bundle_id.clone(),
445
+    )]);
446
+    let mut profile_requests = vec![(effective_bundle_id.clone(), app_id.developer_id)];
447
+
448
+    for nested_bundle in &request.app.nested_bundles {
449
+        let effective_nested_bundle_id = effective_nested_bundle_identifier(
450
+            request.app.original_bundle_id(),
451
+            request.app.bundle_id(),
452
+            &nested_bundle.bundle_id,
453
+            &request.team_id,
454
+        );
455
+        let (nested_app_id, account_update) = resolve_signing_app_id(
456
+            request.developer_context.clone(),
457
+            &request.team_id,
458
+            &team_app_ids,
459
+            true,
460
+            None,
461
+            &effective_nested_bundle_id,
462
+            &nested_bundle.name,
463
+        )
464
+        .await?;
465
+        let expected_identifier = effective_nested_bundle_id.clone();
466
+        if !app_id_matches_identifier(&nested_app_id.identifier, &expected_identifier) {
467
+            return Err(AppError::from(format!(
468
+                "App ID {} does not match nested bundle identifier {}.",
469
+                nested_app_id.identifier, nested_bundle.bundle_id
470
+            )));
471
+        }
472
+        if nested_app_id.developer_id.trim().is_empty() {
473
+            return Err(AppError::from(format!(
474
+                "App ID {} is missing its Xcode identifier.",
475
+                nested_app_id.identifier
476
+            )));
477
+        }
478
+        if let Some(account) = account_update {
479
+            team_app_ids = team_app_ids_for_account(&account, &request.team_id)
480
+                .unwrap_or_else(|| team_app_ids.clone());
481
+            updated_account = Some(account);
482
+        }
483
+        bundle_id_replacements.insert(
484
+            nested_bundle.bundle_id.clone(),
485
+            effective_nested_bundle_id.clone(),
486
+        );
487
+        profile_requests.push((effective_nested_bundle_id, nested_app_id.developer_id));
488
+    }
489
+
490
+    progress(SignIpaProgress::DownloadingProfile);
491
+    let mut provisioning_profiles = BTreeMap::new();
492
+    for (bundle_id, app_id_id) in profile_requests {
493
+        let profile = download_provisioning_profile(
494
+            request.developer_context.clone(),
495
+            request.team_id.clone(),
496
+            app_id_id,
497
+        )
498
+        .await?;
499
+        provisioning_profiles.insert(bundle_id, profile.bytes);
500
+    }
501
+
502
+    let mut metadata = domain_app_metadata(&request.app);
503
+    metadata.bundle_id = effective_bundle_id.clone();
504
+    let destination = match request.output {
505
+        SignIpaOutput::Ipa(path) => crate::backend::ipa::SigningDestination::Ipa(path),
506
+        SignIpaOutput::AppBundle => crate::backend::ipa::SigningDestination::AppBundle,
507
+    };
508
+    let artifact = crate::backend::ipa::sign_ipa(
509
+        crate::backend::ipa::IpaSigningRequest {
510
+            source_path: PathBuf::from(&request.app.path),
511
+            destination,
512
+            metadata,
513
+            bundle_id_replacements,
514
+            icon_override_path: request.app.icon_override_path.as_deref().map(PathBuf::from),
515
+            team_id: request.team_id,
516
+            provisioning_profiles,
517
+            private_key_pem: signing_material.private_key_pem,
518
+            certificate_der: signing_material.certificate_der,
519
+        },
520
+        move |event| progress(event.into()),
521
+    )
522
+    .await
523
+    .map_err(AppError::from)?;
524
+    let artifact = match artifact {
525
+        crate::backend::ipa::SigningArtifact::Ipa(path) => SignIpaArtifact::Ipa(path),
526
+        crate::backend::ipa::SigningArtifact::AppBundle(app) => SignIpaArtifact::AppBundle(app),
527
+    };
528
+
529
+    Ok(SignIpaOutcome {
530
+        artifact,
531
+        updated_account,
532
+    })
533
+}
534
+
535
+pub(crate) fn app_id_provisioning_plan(
536
+    team: &TeamOption,
537
+    auto_app_id: bool,
538
+    selected_app_id: Option<AppIdOption>,
539
+    app: &AppOption,
540
+) -> AppResult<AppIdProvisioningPlan> {
541
+    let mut missing = BTreeMap::<String, String>::new();
542
+    let root_identifier = developer_app_identifier(&team.identifier, app.bundle_id());
543
+
544
+    if auto_app_id {
545
+        add_missing_app_id(
546
+            &mut missing,
547
+            &team.app_ids,
548
+            root_identifier,
549
+            app.name().to_string(),
550
+        );
551
+    } else {
552
+        let selected_app_id = selected_app_id.ok_or_else(|| {
553
+            AppError::from("Select an App ID in Developer Settings before signing.")
554
+        })?;
555
+        if !app_id_matches_identifier(&selected_app_id.identifier, &root_identifier) {
556
+            return Err(AppError::from(format!(
557
+                "App ID {} does not match bundle identifier {}. Select a matching App ID in Developer Settings.",
558
+                selected_app_id.identifier,
559
+                app.bundle_id()
560
+            )));
561
+        }
562
+    }
563
+
564
+    for nested_bundle in &app.nested_bundles {
565
+        add_missing_app_id(
566
+            &mut missing,
567
+            &team.app_ids,
568
+            effective_nested_bundle_identifier(
569
+                app.original_bundle_id(),
570
+                app.bundle_id(),
571
+                &nested_bundle.bundle_id,
572
+                &team.identifier,
573
+            ),
574
+            nested_bundle.name.clone(),
575
+        );
576
+    }
577
+
578
+    let app_ids = missing
579
+        .into_iter()
580
+        .map(|(identifier, name)| AppIdProvisioningItem { name, identifier })
581
+        .collect::<Vec<_>>();
582
+    if let Some(available) = team.app_id_available_quantity {
583
+        if app_ids.len() as u64 > available {
584
+            return Err(AppError::from(format!(
585
+                "Signing requires {} new App IDs, but Apple reports only {available} remaining for {}.",
586
+                app_ids.len(),
587
+                team.name
588
+            )));
589
+        }
590
+    }
591
+
592
+    Ok(AppIdProvisioningPlan {
593
+        app_ids,
594
+        available_quantity: team.app_id_available_quantity,
595
+    })
596
+}
597
+
598
+fn add_missing_app_id(
599
+    missing: &mut BTreeMap<String, String>,
600
+    existing: &[AppIdOption],
601
+    identifier: String,
602
+    name: String,
603
+) {
604
+    if !existing
605
+        .iter()
606
+        .any(|app_id| app_id.identifier == identifier)
607
+    {
608
+        missing.entry(identifier).or_insert(name);
609
+    }
610
+}
611
+
612
+fn team_app_ids_for_account(account: &AccountOption, team_id: &str) -> Option<Vec<AppIdOption>> {
613
+    account
614
+        .teams
615
+        .iter()
616
+        .find(|team| team.identifier == team_id)
617
+        .map(|team| team.app_ids.clone())
618
+}
619
+
620
+async fn resolve_signing_app_id(
621
+    developer_context: DeveloperSessionContext,
622
+    team_id: &str,
623
+    team_app_ids: &[AppIdOption],
624
+    auto_app_id: bool,
625
+    selected_app_id: Option<AppIdOption>,
626
+    identifier: &str,
627
+    app_name: &str,
628
+) -> AppResult<(AppIdOption, Option<AccountOption>)> {
629
+    if let Some(app_id) =
630
+        existing_signing_app_id(team_app_ids, auto_app_id, selected_app_id, identifier)?
631
+    {
632
+        return Ok((app_id.clone(), None));
633
+    }
634
+
635
+    let account = add_app_id(
636
+        developer_context,
637
+        team_id.to_string(),
638
+        identifier.to_string(),
639
+        app_name.to_string(),
640
+    )
641
+    .await?;
642
+    let app_id = account
643
+        .teams
644
+        .iter()
645
+        .find(|team| team.identifier == team_id)
646
+        .and_then(|team| {
647
+            team.app_ids
648
+                .iter()
649
+                .find(|app_id| app_id.identifier == identifier)
650
+        })
651
+        .cloned()
652
+        .ok_or_else(|| {
653
+            AppError::from(format!(
654
+                "Apple created App ID {identifier}, but it was not returned by the developer portal. Refresh Developer Settings and try again."
655
+            ))
656
+        })?;
657
+    Ok((app_id, Some(account)))
658
+}
659
+
660
+fn existing_signing_app_id(
661
+    team_app_ids: &[AppIdOption],
662
+    auto_app_id: bool,
663
+    selected_app_id: Option<AppIdOption>,
664
+    automatic_identifier: &str,
665
+) -> AppResult<Option<AppIdOption>> {
666
+    if !auto_app_id {
667
+        return selected_app_id.map(Some).ok_or_else(|| {
668
+            AppError::from("Select an App ID in Developer Settings before signing.")
669
+        });
670
+    }
671
+
672
+    Ok(team_app_ids
673
+        .iter()
674
+        .find(|app_id| app_id.identifier == automatic_identifier)
675
+        .cloned())
676
+}
677
+
678
+fn developer_app_identifier(team_id: &str, bundle_id: &str) -> String {
679
+    effective_bundle_identifier(bundle_id.trim(), team_id)
680
+}
681
+
682
+fn app_id_matches_identifier(app_id: &str, expected_identifier: &str) -> bool {
683
+    app_id == expected_identifier
684
+        || app_id
685
+            .strip_suffix('*')
686
+            .is_some_and(|prefix| expected_identifier.starts_with(prefix))
687
+}
688
+
160 689
 pub(crate) fn is_ipa_path(path: &Path) -> bool {
161 690
     selection::is_ipa_path(path)
162 691
 }
... ...
@@ -251,6 +780,39 @@ pub(crate) async fn delete_app_id(
251 780
         .map_err(AppError::from)
252 781
 }
253 782
 
783
+pub(crate) async fn list_developer_devices(
784
+    context: DeveloperSessionContext,
785
+    team_id: String,
786
+) -> AppResult<Vec<DeveloperDeviceOption>> {
787
+    developer_services::list_developer_devices(context.into_backend(), team_id)
788
+        .await
789
+        .map(developer_device_options)
790
+        .map_err(AppError::from)
791
+}
792
+
793
+pub(crate) async fn add_developer_device(
794
+    context: DeveloperSessionContext,
795
+    team_id: String,
796
+    name: String,
797
+    udid: String,
798
+) -> AppResult<Vec<DeveloperDeviceOption>> {
799
+    developer_services::add_developer_device(context.into_backend(), team_id, name, udid)
800
+        .await
801
+        .map(developer_device_options)
802
+        .map_err(AppError::from)
803
+}
804
+
805
+pub(crate) async fn delete_developer_device(
806
+    context: DeveloperSessionContext,
807
+    team_id: String,
808
+    device_id: String,
809
+) -> AppResult<Vec<DeveloperDeviceOption>> {
810
+    developer_services::delete_developer_device(context.into_backend(), team_id, device_id)
811
+        .await
812
+        .map(developer_device_options)
813
+        .map_err(AppError::from)
814
+}
815
+
254 816
 pub(crate) async fn create_certificate(
255 817
     context: DeveloperSessionContext,
256 818
     team_id: String,
... ...
@@ -300,3 +862,277 @@ pub(crate) async fn download_provisioning_profile(
300 862
         .await
301 863
         .map_err(AppError::from)
302 864
 }
865
+
866
+#[cfg(test)]
867
+mod tests {
868
+    use super::*;
869
+    use crate::app::models::{
870
+        AppMetadata, EntitlementsSource, NestedBundleOption, SupportedDeviceFamily,
871
+    };
872
+
873
+    fn app_id(identifier: &str) -> AppIdOption {
874
+        AppIdOption {
875
+            developer_id: format!("xcode-{identifier}"),
876
+            name: identifier.to_string(),
877
+            identifier: identifier.to_string(),
878
+            kind: "Explicit App ID".to_string(),
879
+            capabilities: Vec::new(),
880
+        }
881
+    }
882
+
883
+    fn app(bundle_id: &str, nested_bundle_ids: &[&str]) -> AppOption {
884
+        AppOption {
885
+            metadata: AppMetadata::sample(
886
+                "Example",
887
+                bundle_id,
888
+                "1.0",
889
+                "1",
890
+                "Example",
891
+                "15.0",
892
+                vec![SupportedDeviceFamily::IPhone],
893
+            ),
894
+            nested_bundles: nested_bundle_ids
895
+                .iter()
896
+                .map(|bundle_id| NestedBundleOption {
897
+                    name: bundle_id.rsplit('.').next().unwrap().to_string(),
898
+                    bundle_id: (*bundle_id).to_string(),
899
+                })
900
+                .collect(),
901
+            path: "/tmp/Example.ipa".to_string(),
902
+            icon_path: None,
903
+            icon_override_path: None,
904
+            entitlements: Vec::new(),
905
+            entitlements_source: EntitlementsSource::GeneratedFallback,
906
+            entitlement_overrides: None,
907
+            patches: Vec::new(),
908
+        }
909
+    }
910
+
911
+    fn team(app_ids: Vec<AppIdOption>, available: Option<u64>) -> TeamOption {
912
+        TeamOption {
913
+            name: "Example Team".to_string(),
914
+            identifier: "TEAM".to_string(),
915
+            role: "Admin".to_string(),
916
+            app_id_available_quantity: available,
917
+            app_id_max_quantity: Some(10),
918
+            app_ids,
919
+            certificates: Vec::new(),
920
+        }
921
+    }
922
+
923
+    #[test]
924
+    fn automatic_app_id_uses_exact_team_suffixed_match() {
925
+        let app_ids = vec![
926
+            app_id("com.example.other.TEAM"),
927
+            app_id("com.example.app.TEAM"),
928
+        ];
929
+
930
+        let selected = existing_signing_app_id(
931
+            &app_ids,
932
+            true,
933
+            Some(app_ids[0].clone()),
934
+            "com.example.app.TEAM",
935
+        )
936
+        .unwrap()
937
+        .unwrap();
938
+
939
+        assert_eq!(selected.identifier, "com.example.app.TEAM");
940
+    }
941
+
942
+    #[test]
943
+    fn automatic_app_id_requests_creation_when_exact_match_is_missing() {
944
+        let app_ids = vec![app_id("com.example.other.TEAM")];
945
+
946
+        assert!(
947
+            existing_signing_app_id(&app_ids, true, None, "com.example.app.TEAM")
948
+                .unwrap()
949
+                .is_none()
950
+        );
951
+    }
952
+
953
+    #[test]
954
+    fn manual_app_id_requires_and_uses_selection() {
955
+        let selected = app_id("com.example.manual.TEAM");
956
+        assert_eq!(
957
+            existing_signing_app_id(&[], false, Some(selected), "ignored")
958
+                .unwrap()
959
+                .unwrap()
960
+                .identifier,
961
+            "com.example.manual.TEAM"
962
+        );
963
+        assert!(existing_signing_app_id(&[], false, None, "ignored").is_err());
964
+    }
965
+
966
+    #[test]
967
+    fn developer_identifier_is_suffixed_only_once() {
968
+        assert_eq!(
969
+            developer_app_identifier("TEAM", "com.example.app"),
970
+            "com.example.app.TEAM"
971
+        );
972
+        assert_eq!(
973
+            developer_app_identifier("TEAM", "com.example.app.TEAM"),
974
+            "com.example.app.TEAM"
975
+        );
976
+    }
977
+
978
+    #[test]
979
+    fn app_id_matching_accepts_exact_and_wildcard_identifiers() {
980
+        assert!(app_id_matches_identifier(
981
+            "TEAM.com.example.app",
982
+            "TEAM.com.example.app"
983
+        ));
984
+        assert!(app_id_matches_identifier(
985
+            "TEAM.com.example.*",
986
+            "TEAM.com.example.app"
987
+        ));
988
+        assert!(!app_id_matches_identifier(
989
+            "TEAM.com.other.app",
990
+            "TEAM.com.example.app"
991
+        ));
992
+    }
993
+
994
+    #[test]
995
+    fn provisioning_plan_counts_unique_missing_root_and_nested_app_ids() {
996
+        let app = app(
997
+            "com.example.app",
998
+            &[
999
+                "com.example.app.widget",
1000
+                "com.example.app.widget",
1001
+                "com.example.app.share",
1002
+            ],
1003
+        );
1004
+        let team = team(vec![app_id("com.example.app.TEAM.widget")], Some(5));
1005
+
1006
+        let plan = app_id_provisioning_plan(&team, true, None, &app).unwrap();
1007
+
1008
+        assert_eq!(
1009
+            plan.app_ids
1010
+                .iter()
1011
+                .map(|app_id| app_id.identifier.as_str())
1012
+                .collect::<Vec<_>>(),
1013
+            vec!["com.example.app.TEAM", "com.example.app.TEAM.share"]
1014
+        );
1015
+        assert_eq!(plan.available_quantity, Some(5));
1016
+        assert_eq!(plan.remaining_after_signing(), Some(3));
1017
+    }
1018
+
1019
+    #[test]
1020
+    fn provisioning_plan_rebases_nested_ids_when_root_bundle_id_is_overridden() {
1021
+        let mut app = app("com.example.app", &["com.example.app.SubApp.Extension"]);
1022
+        app.metadata
1023
+            .bundle_id
1024
+            .set_override("altcom.example.app".to_string());
1025
+
1026
+        let plan = app_id_provisioning_plan(&team(Vec::new(), Some(5)), true, None, &app).unwrap();
1027
+
1028
+        assert_eq!(
1029
+            plan.app_ids
1030
+                .iter()
1031
+                .map(|app_id| app_id.identifier.as_str())
1032
+                .collect::<Vec<_>>(),
1033
+            vec![
1034
+                "altcom.example.app.TEAM",
1035
+                "altcom.example.app.TEAM.SubApp.Extension",
1036
+            ]
1037
+        );
1038
+    }
1039
+
1040
+    #[test]
1041
+    fn provisioning_plan_does_not_create_manually_selected_root_app_id() {
1042
+        let app = app("com.example.app", &["com.example.app.widget"]);
1043
+        let selected = app_id("com.example.app.TEAM");
1044
+        let team = team(vec![selected.clone()], Some(4));
1045
+
1046
+        let plan = app_id_provisioning_plan(&team, false, Some(selected), &app).unwrap();
1047
+
1048
+        assert_eq!(plan.app_ids.len(), 1);
1049
+        assert_eq!(plan.app_ids[0].identifier, "com.example.app.TEAM.widget");
1050
+    }
1051
+
1052
+    #[test]
1053
+    fn provisioning_plan_rejects_insufficient_refreshed_quota() {
1054
+        let app = app("com.example.app", &["com.example.app.widget"]);
1055
+        let error =
1056
+            app_id_provisioning_plan(&team(Vec::new(), Some(1)), true, None, &app).unwrap_err();
1057
+
1058
+        assert!(error.user_message().contains("requires 2 new App IDs"));
1059
+        assert!(error.user_message().contains("only 1 remaining"));
1060
+    }
1061
+
1062
+    #[test]
1063
+    fn signing_progress_is_monotonic_across_pipeline_stages() {
1064
+        let events = [
1065
+            SignIpaProgress::Preparing,
1066
+            SignIpaProgress::ResolvingAppId,
1067
+            SignIpaProgress::DownloadingProfile,
1068
+            SignIpaProgress::Extracting {
1069
+                completed: 0,
1070
+                total: 10,
1071
+            },
1072
+            SignIpaProgress::Extracting {
1073
+                completed: 10,
1074
+                total: 10,
1075
+            },
1076
+            SignIpaProgress::Patching,
1077
+            SignIpaProgress::Signing,
1078
+            SignIpaProgress::Packaging {
1079
+                completed: 0,
1080
+                total: 20,
1081
+            },
1082
+            SignIpaProgress::Packaging {
1083
+                completed: 20,
1084
+                total: 20,
1085
+            },
1086
+            SignIpaProgress::Saving,
1087
+            SignIpaProgress::Ready,
1088
+        ];
1089
+        let values = events.map(SignIpaProgress::progress);
1090
+
1091
+        assert!(values.windows(2).all(|window| window[0] <= window[1]));
1092
+        assert_eq!(SignIpaProgress::Saving.progress(), 0.98);
1093
+        assert_eq!(
1094
+            SignIpaProgress::Extracting {
1095
+                completed: 4,
1096
+                total: 10
1097
+            }
1098
+            .label(),
1099
+            "Extracting IPA (4/10)"
1100
+        );
1101
+    }
1102
+
1103
+    #[test]
1104
+    fn installation_progress_is_monotonic_and_reports_transfer_size() {
1105
+        let events = [
1106
+            InstallAppProgress::Connecting,
1107
+            InstallAppProgress::Uploading {
1108
+                transferred_bytes: 0,
1109
+                total_bytes: 10 * 1024 * 1024,
1110
+                completed_files: 0,
1111
+                total_files: 20,
1112
+            },
1113
+            InstallAppProgress::Uploading {
1114
+                transferred_bytes: 10 * 1024 * 1024,
1115
+                total_bytes: 10 * 1024 * 1024,
1116
+                completed_files: 20,
1117
+                total_files: 20,
1118
+            },
1119
+            InstallAppProgress::Installing { percent: 0 },
1120
+            InstallAppProgress::Installing { percent: 100 },
1121
+            InstallAppProgress::Finalizing,
1122
+        ];
1123
+        let values = events.map(InstallAppProgress::progress);
1124
+
1125
+        assert!(values.windows(2).all(|window| window[0] <= window[1]));
1126
+        assert_eq!(InstallAppProgress::Finalizing.progress(), 0.99);
1127
+        assert_eq!(
1128
+            InstallAppProgress::Uploading {
1129
+                transferred_bytes: 5 * 1024 * 1024,
1130
+                total_bytes: 10 * 1024 * 1024,
1131
+                completed_files: 10,
1132
+                total_files: 20,
1133
+            }
1134
+            .label(),
1135
+            "Uploading app files (10/20, 5.0 MB / 10.0 MB)"
1136
+        );
1137
+    }
1138
+}
... ...
@@ -4,23 +4,56 @@ pub(crate) fn effective_entitlements_for_app(
4 4
     app: &AppOption,
5 5
     team_id: &str,
6 6
 ) -> Vec<AppEntitlement> {
7
+    let bundle_id = effective_bundle_identifier_for_app(app, team_id);
7 8
     effective_entitlements(
8 9
         app.entitlements_source,
9 10
         &app.entitlements,
10 11
         app.entitlement_overrides.as_deref(),
11
-        app.bundle_id(),
12
+        &bundle_id,
12 13
         team_id,
13 14
     )
14 15
 }
15 16
 
17
+pub(crate) fn effective_bundle_identifier_for_app(app: &AppOption, team_id: &str) -> String {
18
+    effective_bundle_identifier(app.bundle_id(), team_id)
19
+}
20
+
21
+pub(crate) fn effective_bundle_identifier(bundle_id: &str, team_id: &str) -> String {
22
+    let team_id = team_id.trim();
23
+    if team_id.is_empty()
24
+        || bundle_id
25
+            .strip_suffix(team_id)
26
+            .is_some_and(|prefix| prefix.ends_with('.'))
27
+    {
28
+        bundle_id.to_string()
29
+    } else {
30
+        format!("{bundle_id}.{team_id}")
31
+    }
32
+}
33
+
34
+pub(crate) fn effective_nested_bundle_identifier(
35
+    original_root_bundle_id: &str,
36
+    root_bundle_id: &str,
37
+    nested_bundle_id: &str,
38
+    team_id: &str,
39
+) -> String {
40
+    let effective_root = effective_bundle_identifier(root_bundle_id, team_id);
41
+    nested_bundle_id
42
+        .strip_prefix(original_root_bundle_id)
43
+        .filter(|suffix| suffix.starts_with('.'))
44
+        .map(|suffix| format!("{effective_root}{suffix}"))
45
+        .unwrap_or_else(|| effective_bundle_identifier(nested_bundle_id, team_id))
46
+}
47
+
16 48
 pub(crate) fn default_effective_entitlements_for_app(
17 49
     app: &AppOption,
18 50
     team_id: &str,
19 51
 ) -> Vec<AppEntitlement> {
52
+    let bundle_id = effective_bundle_identifier_for_app(app, team_id);
20 53
     default_effective_entitlements(
21 54
         app.entitlements_source,
22 55
         &app.entitlements,
23
-        app.bundle_id(),
56
+        &bundle_id,
24 57
         team_id,
25 58
     )
26 59
 }
... ...
@@ -93,11 +126,8 @@ fn team_adjusted_entitlement(
93 126
     let mut entitlement = entitlement.clone();
94 127
     match entitlement.key.as_str() {
95 128
         "application-identifier" => {
96
-            entitlement.value = EntitlementValue::String(team_prefixed_identifier(
97
-                &entitlement.value.edit_text(),
98
-                bundle_id,
99
-                team_id,
100
-            ));
129
+            entitlement.value =
130
+                EntitlementValue::String(team_prefixed_bundle_identifier(bundle_id, team_id));
101 131
         }
102 132
         "com.apple.developer.team-identifier" => {
103 133
             entitlement.value = EntitlementValue::String(team_id.to_string());
... ...
@@ -151,9 +181,13 @@ fn team_prefixed_identifier(value: &str, bundle_id: &str, team_id: &str) -> Stri
151 181
     team_prefixed_bundle_identifier(suffix, team_id)
152 182
 }
153 183
 
154
-fn team_prefixed_bundle_identifier(bundle_id: &str, team_id: &str) -> String {
184
+pub(crate) fn team_prefixed_bundle_identifier(bundle_id: &str, team_id: &str) -> String {
155 185
     let team_id = team_id.trim();
156
-    if team_id.is_empty() {
186
+    if team_id.is_empty()
187
+        || bundle_id
188
+            .strip_prefix(team_id)
189
+            .is_some_and(|suffix| suffix.starts_with('.'))
190
+    {
157 191
         bundle_id.to_string()
158 192
     } else {
159 193
         format!("{team_id}.{bundle_id}")
... ...
@@ -190,6 +224,48 @@ mod tests {
190 224
         assert!(!text.contains("TEAMID"));
191 225
     }
192 226
 
227
+    #[test]
228
+    fn team_prefixed_bundle_identifier_is_idempotent() {
229
+        assert_eq!(
230
+            team_prefixed_bundle_identifier("com.example.app", "TEAM123"),
231
+            "TEAM123.com.example.app"
232
+        );
233
+        assert_eq!(
234
+            team_prefixed_bundle_identifier("TEAM123.com.example.app", "TEAM123"),
235
+            "TEAM123.com.example.app"
236
+        );
237
+    }
238
+
239
+    #[test]
240
+    fn effective_bundle_identifiers_match_sideloader_layout() {
241
+        assert_eq!(
242
+            effective_bundle_identifier("com.example.app", "TEAM123"),
243
+            "com.example.app.TEAM123"
244
+        );
245
+        assert_eq!(
246
+            effective_bundle_identifier("com.example.app.TEAM123", "TEAM123"),
247
+            "com.example.app.TEAM123"
248
+        );
249
+        assert_eq!(
250
+            effective_nested_bundle_identifier(
251
+                "com.example.app",
252
+                "com.example.app",
253
+                "com.example.app.widget",
254
+                "TEAM123"
255
+            ),
256
+            "com.example.app.TEAM123.widget"
257
+        );
258
+        assert_eq!(
259
+            effective_nested_bundle_identifier(
260
+                "com.example.app",
261
+                "altcom.example.app",
262
+                "com.example.app.SubApp.Extension",
263
+                "TEAM123"
264
+            ),
265
+            "altcom.example.app.TEAM123.SubApp.Extension"
266
+        );
267
+    }
268
+
193 269
     #[test]
194 270
     fn embedded_entitlements_are_adjusted_for_selected_team() {
195 271
         let entitlements = default_effective_entitlements(
... ...
@@ -212,7 +288,7 @@ mod tests {
212 288
 
213 289
         assert_eq!(
214 290
             entitlements[0].value,
215
-            EntitlementValue::String("TEAM123.com.old.app".into())
291
+            EntitlementValue::String("TEAM123.com.example.app".into())
216 292
         );
217 293
         assert_eq!(
218 294
             entitlements[1].value,
... ...
@@ -232,6 +308,7 @@ mod tests {
232 308
                 "16.0",
233 309
                 vec![SupportedDeviceFamily::IPhone],
234 310
             ),
311
+            nested_bundles: Vec::new(),
235 312
             path: "/tmp/Example.ipa".to_string(),
236 313
             icon_path: None,
237 314
             icon_override_path: None,
... ...
@@ -250,7 +327,7 @@ mod tests {
250 327
                 app.entitlements_source,
251 328
                 &app.entitlements,
252 329
                 app.entitlement_overrides.as_deref(),
253
-                app.bundle_id(),
330
+                "com.example.app.TEAM123",
254 331
                 "TEAM123",
255 332
             )
256 333
         );
... ...
@@ -259,9 +336,13 @@ mod tests {
259 336
             default_effective_entitlements(
260 337
                 app.entitlements_source,
261 338
                 &app.entitlements,
262
-                app.bundle_id(),
339
+                "com.example.app.TEAM123",
263 340
                 "TEAM123",
264 341
             )
265 342
         );
343
+        assert_eq!(
344
+            effective_entitlements_for_app(&app, "TEAM123")[0].value,
345
+            EntitlementValue::String("TEAM123.com.example.app.TEAM123".into())
346
+        );
266 347
     }
267 348
 }
... ...
@@ -29,15 +29,23 @@ pub(crate) struct DevelopmentCertificateOption {
29 29
     pub(crate) serial_number: String,
30 30
     pub(crate) machine_name: String,
31 31
     pub(crate) private_key_available: bool,
32
+    pub(crate) certificate_fingerprint: Option<String>,
32 33
     pub(crate) public_key_fingerprint: Option<String>,
33 34
 }
34 35
 
36
+#[derive(Clone, Debug, Eq, PartialEq)]
37
+pub(crate) struct DeveloperDeviceOption {
38
+    pub(crate) id: String,
39
+    pub(crate) name: String,
40
+    pub(crate) udid: String,
41
+}
42
+
35 43
 impl DevelopmentCertificateOption {
36 44
     pub(crate) fn private_key_status(&self) -> &'static str {
37 45
         if self.private_key_available {
38
-            "Private key available"
46
+            "Managed private key available"
39 47
         } else {
40
-            "Private key missing"
48
+            "Managed private key missing"
41 49
         }
42 50
     }
43 51
 
... ...
@@ -428,6 +436,7 @@ pub(crate) enum EntitlementsSource {
428 436
 #[derive(Clone, Debug)]
429 437
 pub(crate) struct AppOption {
430 438
     pub(crate) metadata: AppMetadata,
439
+    pub(crate) nested_bundles: Vec<NestedBundleOption>,
431 440
     pub(crate) path: String,
432 441
     pub(crate) icon_path: Option<String>,
433 442
     pub(crate) icon_override_path: Option<String>,
... ...
@@ -437,6 +446,12 @@ pub(crate) struct AppOption {
437 446
     pub(crate) patches: Vec<PatchOption>,
438 447
 }
439 448
 
449
+#[derive(Clone, Debug, Eq, PartialEq)]
450
+pub(crate) struct NestedBundleOption {
451
+    pub(crate) name: String,
452
+    pub(crate) bundle_id: String,
453
+}
454
+
440 455
 impl AppOption {
441 456
     pub(crate) fn name(&self) -> &String {
442 457
         self.metadata.name.value()
... ...
@@ -446,6 +461,10 @@ impl AppOption {
446 461
         self.metadata.bundle_id.value()
447 462
     }
448 463
 
464
+    pub(crate) fn original_bundle_id(&self) -> &String {
465
+        &self.metadata.bundle_id.default
466
+    }
467
+
449 468
     pub(crate) fn version(&self) -> &String {
450 469
         self.metadata.version.value()
451 470
     }
... ...
@@ -626,9 +645,17 @@ pub(crate) enum SideloadPhase {
626 645
 #[derive(Clone, Debug)]
627 646
 pub(crate) enum SideloadOperation {
628 647
     Idle,
629
-    Running { phase: SideloadPhase, progress: f32 },
630
-    Finished,
631
-    Failed { message: String },
648
+    Running {
649
+        phase: SideloadPhase,
650
+        progress: f32,
651
+        detail: String,
652
+    },
653
+    Finished {
654
+        message: String,
655
+    },
656
+    Failed {
657
+        message: String,
658
+    },
632 659
 }
633 660
 
634 661
 impl SideloadOperation {
... ...
@@ -640,7 +667,7 @@ impl SideloadOperation {
640 667
         match self {
641 668
             Self::Idle => 0.,
642 669
             Self::Running { progress, .. } => progress.clamp(0., 1.),
643
-            Self::Finished => 1.,
670
+            Self::Finished { .. } => 1.,
644 671
             Self::Failed { .. } => 0.,
645 672
         }
646 673
     }
... ...
@@ -311,6 +311,7 @@ mod tests {
311 311
                 "16.0",
312 312
                 vec![SupportedDeviceFamily::IPhone],
313 313
             ),
314
+            nested_bundles: Vec::new(),
314 315
             path: "/tmp/App.ipa".to_string(),
315 316
             icon_path: None,
316 317
             icon_override_path: None,
... ...
@@ -125,6 +125,7 @@ mod tests {
125 125
                 "16.0",
126 126
                 vec![SupportedDeviceFamily::IPhone],
127 127
             ),
128
+            nested_bundles: Vec::new(),
128 129
             path: path.to_string(),
129 130
             icon_path: None,
130 131
             icon_override_path: None,
... ...
@@ -579,6 +579,7 @@ mod tests {
579 579
             serial_number: serial_number.to_string(),
580 580
             machine_name: "Mac".to_string(),
581 581
             private_key_available: true,
582
+            certificate_fingerprint: Some(format!("certificate-fingerprint-{serial_number}")),
582 583
             public_key_fingerprint: Some(format!("fingerprint-{serial_number}")),
583 584
         }
584 585
     }
... ...
@@ -1,13 +1,14 @@
1 1
 use crate::app::models::{
2 2
     AccountOption, AdiBackendAvailability, AdiBackendDetail, AdiBackendKind, AdiBackendOption,
3 3
     AdiProvisioningState, AdiRepairAction, AppEntitlement, AppIdCapabilityOption, AppIdOption,
4
-    AppMetadata, AppOption, DevelopmentCertificateOption, DeviceOption, EntitlementValue,
5
-    EntitlementsSource, MachineIdentity, PatchOption, SupportedDeviceFamily, TeamOption,
4
+    AppMetadata, AppOption, DeveloperDeviceOption, DevelopmentCertificateOption, DeviceOption,
5
+    EntitlementValue, EntitlementsSource, MachineIdentity, NestedBundleOption, PatchOption,
6
+    SupportedDeviceFamily, TeamOption,
6 7
 };
7 8
 use crate::domain::{
8 9
     adi as domain_adi, device as domain_device, identity as domain_identity, ipa as domain_ipa,
9 10
     DeveloperAccount, DeveloperAppId, DeveloperAppIdCapability, DeveloperCertificate,
10
-    DeveloperTeam,
11
+    DeveloperDevice, DeveloperTeam,
11 12
 };
12 13
 
13 14
 pub(crate) fn account_option(account: DeveloperAccount) -> AccountOption {
... ...
@@ -93,6 +94,14 @@ pub(crate) fn app_option(app: domain_ipa::IpaApp) -> AppOption {
93 94
                 .map(supported_device_family)
94 95
                 .collect(),
95 96
         ),
97
+        nested_bundles: app
98
+            .nested_bundles
99
+            .into_iter()
100
+            .map(|bundle| NestedBundleOption {
101
+                name: bundle.name,
102
+                bundle_id: bundle.bundle_id,
103
+            })
104
+            .collect(),
96 105
         path: app.path,
97 106
         icon_path: app.icon_path,
98 107
         icon_override_path: None,
... ...
@@ -114,6 +123,19 @@ pub(crate) fn device_options(devices: Vec<domain_device::Device>) -> Vec<DeviceO
114 123
     devices.into_iter().map(device_option).collect()
115 124
 }
116 125
 
126
+pub(crate) fn developer_device_options(
127
+    devices: Vec<DeveloperDevice>,
128
+) -> Vec<DeveloperDeviceOption> {
129
+    devices
130
+        .into_iter()
131
+        .map(|device| DeveloperDeviceOption {
132
+            id: device.id,
133
+            name: device.name,
134
+            udid: device.udid,
135
+        })
136
+        .collect()
137
+}
138
+
117 139
 pub(crate) fn domain_patch(patch: PatchOption) -> domain_ipa::Patch {
118 140
     domain_ipa::Patch {
119 141
         name: patch.name,
... ...
@@ -121,6 +143,35 @@ pub(crate) fn domain_patch(patch: PatchOption) -> domain_ipa::Patch {
121 143
     }
122 144
 }
123 145
 
146
+pub(crate) fn domain_app_metadata(app: &AppOption) -> domain_ipa::AppMetadata {
147
+    domain_ipa::AppMetadata {
148
+        name: app.name().to_string(),
149
+        bundle_id: app.bundle_id().to_string(),
150
+        version: app.version().to_string(),
151
+        build: app.build().to_string(),
152
+        executable: app.metadata.executable.value().to_string(),
153
+        minimum_os: app.metadata.minimum_os.value().to_string(),
154
+        supported_devices: app
155
+            .supported_devices()
156
+            .iter()
157
+            .copied()
158
+            .map(domain_supported_device_family)
159
+            .collect(),
160
+    }
161
+}
162
+
163
+pub(crate) fn domain_app_entitlements(
164
+    entitlements: &[AppEntitlement],
165
+) -> Vec<domain_ipa::AppEntitlement> {
166
+    entitlements
167
+        .iter()
168
+        .map(|entitlement| domain_ipa::AppEntitlement {
169
+            key: entitlement.key.clone(),
170
+            value: domain_entitlement_value(&entitlement.value),
171
+        })
172
+        .collect()
173
+}
174
+
124 175
 fn team_option(team: DeveloperTeam) -> TeamOption {
125 176
     TeamOption {
126 177
         name: team.name,
... ...
@@ -171,6 +222,7 @@ fn certificate_option(certificate: DeveloperCertificate) -> DevelopmentCertifica
171 222
         serial_number: certificate.serial_number,
172 223
         machine_name: certificate.machine_name,
173 224
         private_key_available: certificate.private_key_available,
225
+        certificate_fingerprint: certificate.certificate_fingerprint,
174 226
         public_key_fingerprint: certificate.public_key_fingerprint,
175 227
     }
176 228
 }
... ...
@@ -230,6 +282,18 @@ fn supported_device_family(family: domain_ipa::SupportedDeviceFamily) -> Support
230 282
     }
231 283
 }
232 284
 
285
+fn domain_supported_device_family(
286
+    family: SupportedDeviceFamily,
287
+) -> domain_ipa::SupportedDeviceFamily {
288
+    match family {
289
+        SupportedDeviceFamily::IPhone => domain_ipa::SupportedDeviceFamily::IPhone,
290
+        SupportedDeviceFamily::IPad => domain_ipa::SupportedDeviceFamily::IPad,
291
+        SupportedDeviceFamily::AppleTv => domain_ipa::SupportedDeviceFamily::AppleTv,
292
+        SupportedDeviceFamily::AppleWatch => domain_ipa::SupportedDeviceFamily::AppleWatch,
293
+        SupportedDeviceFamily::Mac => domain_ipa::SupportedDeviceFamily::Mac,
294
+    }
295
+}
296
+
233 297
 fn app_entitlement(entitlement: domain_ipa::AppEntitlement) -> AppEntitlement {
234 298
     AppEntitlement {
235 299
         key: entitlement.key,
... ...
@@ -259,6 +323,28 @@ fn entitlement_value(value: domain_ipa::EntitlementValue) -> EntitlementValue {
259 323
     }
260 324
 }
261 325
 
326
+fn domain_entitlement_value(value: &EntitlementValue) -> domain_ipa::EntitlementValue {
327
+    match value {
328
+        EntitlementValue::String(value) => domain_ipa::EntitlementValue::String(value.clone()),
329
+        EntitlementValue::Boolean(value) => domain_ipa::EntitlementValue::Boolean(*value),
330
+        EntitlementValue::Integer(value) => domain_ipa::EntitlementValue::Integer(*value),
331
+        EntitlementValue::Number(value) => domain_ipa::EntitlementValue::Number(*value),
332
+        EntitlementValue::Array(values) => domain_ipa::EntitlementValue::Array(
333
+            values.iter().map(domain_entitlement_value).collect(),
334
+        ),
335
+        EntitlementValue::Dictionary(values) => domain_ipa::EntitlementValue::Dictionary(
336
+            values
337
+                .iter()
338
+                .map(|(key, value)| (key.clone(), domain_entitlement_value(value)))
339
+                .collect(),
340
+        ),
341
+        EntitlementValue::Data(value) => domain_ipa::EntitlementValue::Data(value.clone()),
342
+        EntitlementValue::Date(value) => domain_ipa::EntitlementValue::Date(value.clone()),
343
+        EntitlementValue::Uid(value) => domain_ipa::EntitlementValue::Uid(*value),
344
+        EntitlementValue::Unknown(value) => domain_ipa::EntitlementValue::Unknown(value.clone()),
345
+    }
346
+}
347
+
262 348
 fn entitlements_source(source: domain_ipa::EntitlementsSource) -> EntitlementsSource {
263 349
     match source {
264 350
         domain_ipa::EntitlementsSource::Embedded => EntitlementsSource::Embedded,
... ...
@@ -313,6 +399,7 @@ mod tests {
313 399
                     serial_number: "SERIAL".to_string(),
314 400
                     machine_name: "Mac".to_string(),
315 401
                     private_key_available: true,
402
+                    certificate_fingerprint: Some("certificate-fingerprint".to_string()),
316 403
                     public_key_fingerprint: Some("fingerprint".to_string()),
317 404
                 }],
318 405
             }],
... ...
@@ -377,6 +464,19 @@ mod tests {
377 464
         assert_eq!(options[0].connection, "USB");
378 465
     }
379 466
 
467
+    #[test]
468
+    fn developer_device_maps_to_settings_option() {
469
+        let options = developer_device_options(vec![DeveloperDevice {
470
+            id: "portal-device-id".to_string(),
471
+            name: "Development iPhone".to_string(),
472
+            udid: "0000000000000000000000000000000000000000".to_string(),
473
+        }]);
474
+
475
+        assert_eq!(options[0].id, "portal-device-id");
476
+        assert_eq!(options[0].name, "Development iPhone");
477
+        assert_eq!(options[0].udid.len(), 40);
478
+    }
479
+
380 480
     #[test]
381 481
     fn ipa_app_maps_to_ui_app_option_at_app_boundary() {
382 482
         let option = app_option(domain_ipa::IpaApp {
... ...
@@ -392,6 +492,10 @@ mod tests {
392 492
                     domain_ipa::SupportedDeviceFamily::IPad,
393 493
                 ],
394 494
             },
495
+            nested_bundles: vec![domain_ipa::NestedBundle {
496
+                name: "Widget".to_string(),
497
+                bundle_id: "com.example.app.Widget".to_string(),
498
+            }],
395 499
             path: "/tmp/Example.ipa".to_string(),
396 500
             icon_path: Some("/tmp/icon.png".to_string()),
397 501
             entitlements: vec![domain_ipa::AppEntitlement {
... ...
@@ -407,6 +511,7 @@ mod tests {
407 511
 
408 512
         assert_eq!(option.name(), "Example");
409 513
         assert_eq!(option.bundle_id(), "com.example.app");
514
+        assert_eq!(option.nested_bundles[0].bundle_id, "com.example.app.Widget");
410 515
         assert_eq!(
411 516
             option.supported_devices(),
412 517
             &[SupportedDeviceFamily::IPhone, SupportedDeviceFamily::IPad]
... ...
@@ -1,12 +1,14 @@
1 1
 pub(crate) use crate::backend::developer::cache::load_cached_account_options;
2 2
 pub(crate) use crate::backend::developer::repository::{
3
-    add_developer_app_id, create_developer_certificate, delete_account_cache,
4
-    delete_developer_app_id, developer_secondary_action_not_supported,
3
+    add_developer_app_id, add_developer_device, create_developer_certificate, delete_account_cache,
4
+    delete_developer_app_id, delete_developer_device, developer_secondary_action_not_supported,
5 5
     download_developer_provisioning_profile, import_developer_certificate_private_key,
6
-    login_developer_account, refresh_developer_account, revoke_developer_certificate,
7
-    update_developer_app_id, DeveloperAccountRefreshRequest, DeveloperAppIdAddRequest,
8
-    DeveloperAppIdCapabilityUpdate, DeveloperAppIdDeleteRequest, DeveloperAppIdUpdateRequest,
9
-    DeveloperCertificateCreateRequest, DeveloperCertificatePrivateKeyImportRequest,
10
-    DeveloperCertificateRevokeRequest, DeveloperLoginOutcome, DeveloperLoginRequest,
11
-    DeveloperProvisioningProfileDownloadRequest, DownloadedProvisioningProfile,
6
+    list_developer_devices, login_developer_account, refresh_developer_account,
7
+    revoke_developer_certificate, update_developer_app_id, DeveloperAccountRefreshRequest,
8
+    DeveloperAppIdAddRequest, DeveloperAppIdCapabilityUpdate, DeveloperAppIdDeleteRequest,
9
+    DeveloperAppIdUpdateRequest, DeveloperCertificateCreateRequest,
10
+    DeveloperCertificatePrivateKeyImportRequest, DeveloperCertificateRevokeRequest,
11
+    DeveloperDeviceAddRequest, DeveloperDeviceDeleteRequest, DeveloperDeviceListRequest,
12
+    DeveloperLoginOutcome, DeveloperLoginRequest, DeveloperProvisioningProfileDownloadRequest,
13
+    DownloadedProvisioningProfile,
12 14
 };
... ...
@@ -91,6 +91,8 @@ pub(crate) struct CachedDevelopmentCertificate {
91 91
     pub(crate) machine_name: String,
92 92
     pub(crate) private_key_available: bool,
93 93
     #[serde(default, skip_serializing_if = "Option::is_none")]
94
+    pub(crate) certificate_fingerprint: Option<String>,
95
+    #[serde(default, skip_serializing_if = "Option::is_none")]
94 96
     pub(crate) public_key_fingerprint: Option<String>,
95 97
 }
96 98
 
... ...
@@ -199,6 +201,7 @@ impl From<CachedDevelopmentCertificate> for DeveloperCertificate {
199 201
             serial_number: certificate.serial_number,
200 202
             machine_name: certificate.machine_name,
201 203
             private_key_available: certificate.private_key_available,
204
+            certificate_fingerprint: certificate.certificate_fingerprint,
202 205
             public_key_fingerprint: certificate.public_key_fingerprint,
203 206
         }
204 207
     }
... ...
@@ -440,6 +443,7 @@ mod tests {
440 443
                 serial_number: id.to_string(),
441 444
                 machine_name: "Machine".to_string(),
442 445
                 private_key_available: false,
446
+                certificate_fingerprint: None,
443 447
                 public_key_fingerprint: None,
444 448
             }],
445 449
         }
... ...
@@ -471,4 +475,21 @@ mod tests {
471 475
         assert_eq!(account.teams.len(), 2);
472 476
         assert_eq!(account.teams[1].id, "TEAM2");
473 477
     }
478
+
479
+    #[test]
480
+    fn old_certificate_cache_entries_need_a_certificate_refresh() {
481
+        let certificate: CachedDevelopmentCertificate = toml::from_str(
482
+            r#"
483
+id = "cert-id"
484
+name = "Apple Development"
485
+serial_number = "serial"
486
+machine_name = "Mac"
487
+private_key_available = true
488
+public_key_fingerprint = "0123456789012345678901234567890123456789"
489
+"#,
490
+        )
491
+        .unwrap();
492
+
493
+        assert_eq!(certificate.certificate_fingerprint, None);
494
+    }
474 495
 }
... ...
@@ -6,6 +6,9 @@ use std::path::{Path, PathBuf};
6 6
 use std::process::{Command, Stdio};
7 7
 use uuid::Uuid;
8 8
 
9
+#[cfg(unix)]
10
+use std::os::unix::fs::PermissionsExt;
11
+
9 12
 #[derive(Clone, Debug)]
10 13
 pub(crate) struct GeneratedCertificateSigningRequest {
11 14
     pub(crate) machine_id: String,
... ...
@@ -15,6 +18,11 @@ pub(crate) struct GeneratedCertificateSigningRequest {
15 18
     pub(crate) private_key_pem: Vec<u8>,
16 19
 }
17 20
 
21
+pub(crate) struct AppManagedSigningMaterial {
22
+    pub(crate) private_key_pem: Vec<u8>,
23
+    pub(crate) certificate_der: Vec<u8>,
24
+}
25
+
18 26
 pub(crate) fn generate_development_certificate_signing_request(
19 27
 ) -> BackendResult<GeneratedCertificateSigningRequest> {
20 28
     let machine_id = Uuid::new_v4().to_string().to_uppercase();
... ...
@@ -124,29 +132,50 @@ pub(crate) fn app_managed_private_key_fingerprints() -> Vec<String> {
124 132
         .collect()
125 133
 }
126 134
 
127
-#[cfg(target_os = "macos")]
128
-pub(crate) fn local_code_signing_identity_fingerprints() -> Vec<String> {
129
-    let output = match Command::new("security")
130
-        .args(["find-identity", "-v", "-p", "codesigning"])
131
-        .output()
132
-    {
133
-        Ok(output) if output.status.success() => output,
134
-        _ => return Vec::new(),
135
-    };
136
-
137
-    String::from_utf8_lossy(&output.stdout)
138
-        .split_whitespace()
139
-        .filter_map(|part| {
140
-            let fingerprint = part.trim();
141
-            (fingerprint.len() == 40 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()))
142
-                .then(|| fingerprint.to_ascii_uppercase())
135
+pub(crate) fn save_app_managed_certificate(
136
+    fingerprint: &str,
137
+    certificate_der: &[u8],
138
+) -> BackendResult<()> {
139
+    let certificates_dir = signing_certificates_dir()?;
140
+    fs::create_dir_all(&certificates_dir).map_err(|source| BackendError::Io {
141
+        action: "Create signing certificate folder",
142
+        path: certificates_dir.clone(),
143
+        source,
144
+    })?;
145
+    let certificate_path = certificates_dir.join(format!("{}.der", safe_fingerprint(fingerprint)?));
146
+    fs::write(&certificate_path, certificate_der).map_err(|source| BackendError::Io {
147
+        action: "Save signing certificate",
148
+        path: certificate_path,
149
+        source,
143 150
     })
144
-        .collect()
145 151
 }
146 152
 
147
-#[cfg(not(target_os = "macos"))]
148
-pub(crate) fn local_code_signing_identity_fingerprints() -> Vec<String> {
149
-    Vec::new()
153
+pub(crate) fn load_app_managed_signing_material(
154
+    certificate_fingerprint: &str,
155
+    public_key_fingerprint: &str,
156
+) -> BackendResult<AppManagedSigningMaterial> {
157
+    let certificate_path = signing_certificates_dir()?.join(format!(
158
+        "{}.der",
159
+        safe_fingerprint(certificate_fingerprint)?
160
+    ));
161
+    let certificate_der = read_signing_resource(
162
+        &certificate_path,
163
+        "Read signing certificate",
164
+        "The selected certificate data is not cached. Refresh Developer Settings, then try signing again.",
165
+    )?;
166
+
167
+    let private_key_path =
168
+        certificate_keys_dir()?.join(format!("{}.pem", safe_fingerprint(public_key_fingerprint)?));
169
+    let private_key_pem = read_signing_resource(
170
+        &private_key_path,
171
+        "Read certificate private key",
172
+        "The selected certificate has no Super Sideloader managed private key. Create a certificate or import its matching PEM key in Developer Settings.",
173
+    )?;
174
+
175
+    Ok(AppManagedSigningMaterial {
176
+        private_key_pem,
177
+        certificate_der,
178
+    })
150 179
 }
151 180
 
152 181
 fn public_key_der_from_private_key(key_path: &Path) -> BackendResult<Vec<u8>> {
... ...
@@ -205,14 +234,60 @@ pub(crate) fn save_app_managed_private_key(
205 234
         path: keys_dir.clone(),
206 235
         source,
207 236
     })?;
208
-    let key_path = keys_dir.join(format!("{fingerprint}.pem"));
237
+    let key_path = keys_dir.join(format!("{}.pem", safe_fingerprint(fingerprint)?));
209 238
     fs::write(&key_path, private_key_pem).map_err(|source| BackendError::Io {
210 239
         action: "Save certificate private key",
211
-        path: key_path,
240
+        path: key_path.clone(),
212 241
         source,
242
+    })?;
243
+    secure_private_key_file(&key_path)
244
+}
245
+
246
+#[cfg(unix)]
247
+fn secure_private_key_file(path: &Path) -> BackendResult<()> {
248
+    fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| {
249
+        BackendError::Io {
250
+            action: "Secure certificate private key",
251
+            path: path.to_path_buf(),
252
+            source,
253
+        }
254
+    })
255
+}
256
+
257
+#[cfg(not(unix))]
258
+fn secure_private_key_file(_: &Path) -> BackendResult<()> {
259
+    Ok(())
260
+}
261
+
262
+fn read_signing_resource(
263
+    path: &Path,
264
+    action: &'static str,
265
+    missing_message: &str,
266
+) -> BackendResult<Vec<u8>> {
267
+    fs::read(path).map_err(|source| {
268
+        if source.kind() == std::io::ErrorKind::NotFound {
269
+            BackendError::Message(missing_message.to_string())
270
+        } else {
271
+            BackendError::Io {
272
+                action,
273
+                path: path.to_path_buf(),
274
+                source,
275
+            }
276
+        }
213 277
     })
214 278
 }
215 279
 
280
+fn safe_fingerprint(fingerprint: &str) -> BackendResult<String> {
281
+    let fingerprint = fingerprint.trim().to_ascii_uppercase();
282
+    if fingerprint.len() == 40 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()) {
283
+        Ok(fingerprint)
284
+    } else {
285
+        Err(BackendError::Cache(
286
+            "Signing certificate cache contains an invalid fingerprint.".to_string(),
287
+        ))
288
+    }
289
+}
290
+
216 291
 fn certificate_keys_dir() -> BackendResult<PathBuf> {
217 292
     app_data_dir()
218 293
         .map(|path| path.join("certificates").join("keys"))
... ...
@@ -221,6 +296,14 @@ fn certificate_keys_dir() -> BackendResult<PathBuf> {
221 296
         })
222 297
 }
223 298
 
299
+fn signing_certificates_dir() -> BackendResult<PathBuf> {
300
+    app_data_dir()
301
+        .map(|path| path.join("certificates").join("certificates"))
302
+        .ok_or_else(|| {
303
+            BackendError::Unsupported("The application data folder is not available.".to_string())
304
+        })
305
+}
306
+
224 307
 fn certificate_temp_dir() -> BackendResult<PathBuf> {
225 308
     app_data_dir()
226 309
         .map(|path| path.join("certificates").join("tmp"))
... ...
@@ -1,12 +1,43 @@
1 1
 use crate::backend::{BackendError, BackendResult};
2 2
 use grandslam::{AuthToken, Token};
3 3
 use serde::{Deserialize, Serialize};
4
+use std::collections::HashMap;
5
+use std::sync::{Mutex, MutexGuard, OnceLock};
4 6
 use std::time::{SystemTime, UNIX_EPOCH};
5 7
 
6 8
 const KEYCHAIN_SESSION_VERSION: u32 = 1;
7 9
 const KEYRING_SERVICE: &str = "com.Dadoum.Super-Sideloader";
8 10
 const TOKEN_EXPIRY_GRACE_MILLIS: u64 = 5 * 60 * 1000;
9 11
 
12
+type KeychainResult<T> = Result<T, String>;
13
+type SessionCache = HashMap<String, CachedKeychainLookup>;
14
+type SessionCacheGuard = MutexGuard<'static, SessionCache>;
15
+
16
+static SESSION_CACHE: OnceLock<Mutex<SessionCache>> = OnceLock::new();
17
+
18
+#[derive(Clone, Debug)]
19
+struct CachedKeychainSession {
20
+    encoded_contents: String,
21
+    session: DeveloperAccountKeychainSession,
22
+}
23
+
24
+#[derive(Clone, Debug)]
25
+enum CachedKeychainLookup {
26
+    Session(CachedKeychainSession),
27
+    Missing,
28
+    Failed(String),
29
+}
30
+
31
+impl CachedKeychainLookup {
32
+    fn result(&self) -> BackendResult<Option<DeveloperAccountKeychainSession>> {
33
+        match self {
34
+            Self::Session(cached) => Ok(Some(cached.session.clone())),
35
+            Self::Missing => Ok(None),
36
+            Self::Failed(error) => Err(BackendError::Keychain(error.clone())),
37
+        }
38
+    }
39
+}
40
+
10 41
 #[derive(Clone, Debug, Deserialize, Serialize)]
11 42
 pub(crate) struct DeveloperAccountKeychainSession {
12 43
     pub(crate) version: u32,
... ...
@@ -34,44 +65,83 @@ pub(crate) fn save_keychain_session(
34 65
     account_id: &str,
35 66
     session: &DeveloperAccountKeychainSession,
36 67
 ) -> BackendResult<()> {
37
-    let contents = toml::to_string(session).map_err(|error| {
38
-        BackendError::Keychain(format!(
39
-            "Failed to encode account keychain payload: {error}"
40
-        ))
41
-    })?;
42
-    keyring_entry(account_id)?
68
+    let contents = encode_keychain_session(session).map_err(BackendError::Keychain)?;
69
+    let mut cache = lock_session_cache()?;
70
+    if matches!(
71
+        cache.get(account_id),
72
+        Some(CachedKeychainLookup::Session(cached))
73
+            if cached.encoded_contents == contents
74
+    ) {
75
+        return Ok(());
76
+    }
77
+
78
+    keyring_entry(account_id)
79
+        .map_err(BackendError::Keychain)?
43 80
         .set_password(&contents)
44 81
         .map_err(|error| {
45 82
             BackendError::Keychain(format!(
46 83
                 "Failed to save account session to the system keychain: {error}"
47 84
             ))
48
-        })
85
+        })?;
86
+    cache_keychain_session_locked(&mut cache, account_id, session.clone(), contents);
87
+    Ok(())
49 88
 }
50 89
 
51 90
 pub(crate) fn load_keychain_session(
52 91
     account_id: &str,
53 92
 ) -> BackendResult<Option<DeveloperAccountKeychainSession>> {
54
-    let contents = match keyring_entry(account_id)?.get_password() {
55
-        Ok(contents) => contents,
56
-        Err(keyring::Error::NoEntry) => return Ok(None),
57
-        Err(error) => {
58
-            return Err(BackendError::Keychain(format!(
93
+    load_keychain_session_once(account_id, || {
94
+        let entry = keyring_entry(account_id)?;
95
+        match entry.get_password() {
96
+            Ok(contents) => Ok(Some(contents)),
97
+            Err(keyring::Error::NoEntry) => Ok(None),
98
+            Err(error) => Err(format!(
59 99
                 "Failed to read account session from the system keychain: {error}"
60
-            )));
100
+            )),
101
+        }
102
+    })
61 103
 }
104
+
105
+fn load_keychain_session_once(
106
+    account_id: &str,
107
+    read: impl FnOnce() -> KeychainResult<Option<String>>,
108
+) -> BackendResult<Option<DeveloperAccountKeychainSession>> {
109
+    let mut cache = lock_session_cache()?;
110
+    if let Some(cached) = cache.get(account_id) {
111
+        return cached.result();
112
+    }
113
+
114
+    let lookup = match read() {
115
+        Ok(Some(contents)) => match decode_keychain_session(&contents) {
116
+            Ok(session) => CachedKeychainLookup::Session(session),
117
+            Err(error) => CachedKeychainLookup::Failed(error),
118
+        },
119
+        Ok(None) => CachedKeychainLookup::Missing,
120
+        Err(error) => CachedKeychainLookup::Failed(error),
62 121
     };
63
-    let mut session: DeveloperAccountKeychainSession =
64
-        toml::from_str(&contents).map_err(|error| {
65
-            BackendError::Keychain(format!(
66
-                "Failed to decode account keychain payload: {error}"
67
-            ))
68
-        })?;
69
-    session.version = KEYCHAIN_SESSION_VERSION;
70
-    Ok(Some(session))
122
+    let result = lookup.result();
123
+    cache.insert(account_id.to_string(), lookup);
124
+    result
125
+}
126
+
127
+pub(crate) fn cache_keychain_session(
128
+    account_id: &str,
129
+    session: &DeveloperAccountKeychainSession,
130
+) -> BackendResult<()> {
131
+    let contents = encode_keychain_session(session).map_err(BackendError::Keychain)?;
132
+    let mut cache = lock_session_cache()?;
133
+    cache_keychain_session_locked(&mut cache, account_id, session.clone(), contents);
134
+    Ok(())
71 135
 }
72 136
 
73 137
 pub(crate) fn delete_keychain_session(account_id: &str) -> BackendResult<()> {
74
-    match keyring_entry(account_id)?.delete_credential() {
138
+    let mut cache = lock_session_cache()?;
139
+    cache.remove(account_id);
140
+
141
+    match keyring_entry(account_id)
142
+        .map_err(BackendError::Keychain)?
143
+        .delete_credential()
144
+    {
75 145
         Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
76 146
         Err(error) => Err(BackendError::Keychain(format!(
77 147
             "Failed to remove account session from the system keychain: {error}"
... ...
@@ -103,15 +173,86 @@ pub(crate) fn current_epoch_millis() -> u64 {
103 173
         .unwrap_or(0)
104 174
 }
105 175
 
106
-fn keyring_entry(account_id: &str) -> BackendResult<keyring::Entry> {
107
-    keyring::Entry::new(keyring_service(), &keyring_account_name(account_id)).map_err(|error| {
108
-        BackendError::Keychain(format!("Failed to access the system keychain: {error}"))
176
+fn keyring_entry(account_id: &str) -> KeychainResult<keyring::Entry> {
177
+    keyring::Entry::new(keyring_service(), &keyring_account_name(account_id))
178
+        .map_err(|error| format!("Failed to access the system keychain: {error}"))
179
+}
180
+
181
+fn encode_keychain_session(session: &DeveloperAccountKeychainSession) -> KeychainResult<String> {
182
+    toml::to_string(session)
183
+        .map_err(|error| format!("Failed to encode account keychain payload: {error}"))
184
+}
185
+
186
+fn decode_keychain_session(contents: &str) -> KeychainResult<CachedKeychainSession> {
187
+    let mut session: DeveloperAccountKeychainSession = toml::from_str(contents)
188
+        .map_err(|error| format!("Failed to decode account keychain payload: {error}"))?;
189
+    session.version = KEYCHAIN_SESSION_VERSION;
190
+    let encoded_contents = encode_keychain_session(&session)?;
191
+    Ok(CachedKeychainSession {
192
+        encoded_contents,
193
+        session,
109 194
     })
110 195
 }
111 196
 
197
+fn session_cache() -> &'static Mutex<SessionCache> {
198
+    SESSION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
199
+}
200
+
201
+fn lock_session_cache() -> BackendResult<SessionCacheGuard> {
202
+    session_cache().lock().map_err(|_| {
203
+        BackendError::Keychain("Failed to lock account keychain session cache.".to_string())
204
+    })
205
+}
206
+
207
+fn cache_keychain_session_locked(
208
+    cache: &mut SessionCache,
209
+    account_id: &str,
210
+    session: DeveloperAccountKeychainSession,
211
+    encoded_contents: String,
212
+) {
213
+    cache.insert(
214
+        account_id.to_string(),
215
+        CachedKeychainLookup::Session(CachedKeychainSession {
216
+            encoded_contents,
217
+            session,
218
+        }),
219
+    );
220
+}
221
+
112 222
 #[cfg(test)]
113 223
 mod tests {
114 224
     use super::*;
225
+    use std::cell::Cell;
226
+    use std::sync::atomic::{AtomicUsize, Ordering};
227
+    use std::sync::{Arc, Barrier};
228
+    use std::thread;
229
+    use std::time::Duration;
230
+
231
+    fn test_account_id(label: &str) -> String {
232
+        format!("{label}-{}-{}", std::process::id(), current_epoch_millis())
233
+    }
234
+
235
+    fn test_session() -> DeveloperAccountKeychainSession {
236
+        DeveloperAccountKeychainSession::new(
237
+            AuthToken {
238
+                alt_dsid: "alt-dsid".to_string(),
239
+                idms_token: "idms-token".to_string(),
240
+                session_key: vec![1, 2, 3],
241
+                cookie: vec![4, 5, 6],
242
+                identity_token: "identity-token".to_string(),
243
+            },
244
+            Token {
245
+                duration: 3600,
246
+                expiry_epoch_millis: current_epoch_millis() + 3_600_000,
247
+                token: "heartbeat-token".to_string(),
248
+            },
249
+            Token {
250
+                duration: 3600,
251
+                expiry_epoch_millis: current_epoch_millis() + 3_600_000,
252
+                token: "xcode-token".to_string(),
253
+            },
254
+        )
255
+    }
115 256
 
116 257
     #[test]
117 258
     fn zero_expiry_is_treated_as_unknown_not_expired() {
... ...
@@ -122,4 +263,93 @@ mod tests {
122 263
     fn near_expiry_is_detected() {
123 264
         assert!(token_is_near_expiry(current_epoch_millis() + 1));
124 265
     }
266
+
267
+    #[test]
268
+    fn concurrent_successful_lookups_read_the_keychain_once() {
269
+        let account_id = Arc::new(test_account_id("concurrent-session"));
270
+        let contents = Arc::new(encode_keychain_session(&test_session()).unwrap());
271
+        let reads = Arc::new(AtomicUsize::new(0));
272
+        let barrier = Arc::new(Barrier::new(2));
273
+
274
+        let handles = (0..2)
275
+            .map(|_| {
276
+                let account_id = Arc::clone(&account_id);
277
+                let contents = Arc::clone(&contents);
278
+                let reads = Arc::clone(&reads);
279
+                let barrier = Arc::clone(&barrier);
280
+                thread::spawn(move || {
281
+                    barrier.wait();
282
+                    load_keychain_session_once(&account_id, || {
283
+                        reads.fetch_add(1, Ordering::SeqCst);
284
+                        thread::sleep(Duration::from_millis(25));
285
+                        Ok(Some(contents.as_ref().clone()))
286
+                    })
287
+                    .unwrap()
288
+                    .unwrap()
289
+                })
290
+            })
291
+            .collect::<Vec<_>>();
292
+
293
+        for handle in handles {
294
+            assert_eq!(handle.join().unwrap().auth_token.idms_token, "idms-token");
295
+        }
296
+        assert_eq!(reads.load(Ordering::SeqCst), 1);
297
+    }
298
+
299
+    #[test]
300
+    fn missing_keychain_session_is_memoized() {
301
+        let account_id = test_account_id("missing-session");
302
+        let reads = Cell::new(0);
303
+
304
+        for _ in 0..2 {
305
+            let loaded = load_keychain_session_once(&account_id, || {
306
+                reads.set(reads.get() + 1);
307
+                Ok(None)
308
+            })
309
+            .unwrap();
310
+            assert!(loaded.is_none());
311
+        }
312
+
313
+        assert_eq!(reads.get(), 1);
314
+    }
315
+
316
+    #[test]
317
+    fn malformed_keychain_session_is_memoized() {
318
+        let account_id = test_account_id("malformed-session");
319
+        let reads = Cell::new(0);
320
+
321
+        for _ in 0..2 {
322
+            let error = load_keychain_session_once(&account_id, || {
323
+                reads.set(reads.get() + 1);
324
+                Ok(Some("not valid TOML".to_string()))
325
+            })
326
+            .unwrap_err();
327
+            assert!(error
328
+                .user_message()
329
+                .contains("decode account keychain payload"));
330
+        }
331
+
332
+        assert_eq!(reads.get(), 1);
333
+    }
334
+
335
+    #[test]
336
+    fn failed_keychain_lookup_is_memoized_and_can_be_replaced() {
337
+        let account_id = test_account_id("failed-session");
338
+        let reads = Cell::new(0);
339
+
340
+        for _ in 0..2 {
341
+            let error = load_keychain_session_once(&account_id, || {
342
+                reads.set(reads.get() + 1);
343
+                Err("Keychain access was denied.".to_string())
344
+            })
345
+            .unwrap_err();
346
+            assert!(error.user_message().contains("access was denied"));
347
+        }
348
+        assert_eq!(reads.get(), 1);
349
+
350
+        let session = test_session();
351
+        cache_keychain_session(&account_id, &session).unwrap();
352
+        let loaded = load_keychain_session(&account_id).unwrap().unwrap();
353
+        assert_eq!(loaded.auth_token.idms_token, session.auth_token.idms_token);
354
+    }
125 355
 }
... ...
@@ -7,17 +7,17 @@ use crate::backend::developer::cache::{
7 7
 use crate::backend::developer::certificates::{
8 8
     app_managed_private_key_fingerprints, certificate_fingerprint,
9 9
     certificate_public_key_fingerprint, generate_development_certificate_signing_request,
10
-    import_app_managed_private_key, local_code_signing_identity_fingerprints,
11
-    save_app_managed_private_key, GeneratedCertificateSigningRequest,
10
+    import_app_managed_private_key, save_app_managed_certificate, save_app_managed_private_key,
11
+    GeneratedCertificateSigningRequest,
12 12
 };
13 13
 use crate::backend::developer::client::{with_developer_session, DeveloperSessionConfig};
14 14
 use crate::backend::developer::keychain::{
15
-    delete_keychain_session, load_keychain_session, save_keychain_session, token_is_near_expiry,
16
-    DeveloperAccountKeychainSession,
15
+    cache_keychain_session, delete_keychain_session, load_keychain_session, save_keychain_session,
16
+    token_is_near_expiry, DeveloperAccountKeychainSession,
17 17
 };
18 18
 use crate::backend::runtime as backend_runtime;
19 19
 use crate::backend::{BackendError, BackendResult};
20
-use crate::domain::{AdiBackendKind, DeveloperAccount, MachineIdentity};
20
+use crate::domain::{AdiBackendKind, DeveloperAccount, DeveloperDevice, MachineIdentity};
21 21
 use chrono::{DateTime, Local};
22 22
 use grandslam::http_session::AnisetteHTTPSession;
23 23
 use grandslam::{AuthOutcome, AuthenticatedHTTPSession, Token};
... ...
@@ -25,11 +25,11 @@ use plist::{Dictionary, Value};
25 25
 use std::path::PathBuf;
26 26
 use std::time::{Duration, UNIX_EPOCH};
27 27
 use xcode::{
28
-    AddAppIdAction, AppIdFeature, DeleteAppIdAction, DeveloperTeam,
29
-    DownloadTeamProvisioningProfileAction, IOSRequest, ListAllDevelopmentCertsAction,
30
-    ListAppIdsAction, ListTeamsAction, RevokeDevelopmentCertAction, SubmitDevelopmentCsrAction,
31
-    UpdateAppIdAction, ViewDeveloperAction, XcodeSession, XCODE_BUNDLE_INFORMATION,
32
-    XCODE_TOKEN_IDENTIFIER,
28
+    AddAppIdAction, AddDeviceAction, AppIdFeature, DeleteAppIdAction, DeleteDeviceAction,
29
+    DeveloperTeam, DownloadTeamProvisioningProfileAction, IOSRequest,
30
+    ListAllDevelopmentCertsAction, ListAppIdsAction, ListDevicesAction, ListTeamsAction,
31
+    RevokeDevelopmentCertAction, SubmitDevelopmentCsrAction, UpdateAppIdAction,
32
+    ViewDeveloperAction, XcodeSession, XCODE_BUNDLE_INFORMATION, XCODE_TOKEN_IDENTIFIER,
33 33
 };
34 34
 
35 35
 const HEARTBEAT_TOKEN_IDENTIFIER: &str = "com.apple.gs.idms.hb";
... ...
@@ -139,6 +139,39 @@ pub(crate) struct DeveloperProvisioningProfileDownloadRequest {
139 139
     pub(crate) android_adi_identifier: String,
140 140
 }
141 141
 
142
+#[derive(Clone, Debug)]
143
+pub(crate) struct DeveloperDeviceListRequest {
144
+    pub(crate) account_id: String,
145
+    pub(crate) email: String,
146
+    pub(crate) team_id: String,
147
+    pub(crate) adi_backend: AdiBackendKind,
148
+    pub(crate) machine_identity: MachineIdentity,
149
+    pub(crate) android_adi_identifier: String,
150
+}
151
+
152
+#[derive(Clone, Debug)]
153
+pub(crate) struct DeveloperDeviceAddRequest {
154
+    pub(crate) account_id: String,
155
+    pub(crate) email: String,
156
+    pub(crate) team_id: String,
157
+    pub(crate) name: String,
158
+    pub(crate) udid: String,
159
+    pub(crate) adi_backend: AdiBackendKind,
160
+    pub(crate) machine_identity: MachineIdentity,
161
+    pub(crate) android_adi_identifier: String,
162
+}
163
+
164
+#[derive(Clone, Debug)]
165
+pub(crate) struct DeveloperDeviceDeleteRequest {
166
+    pub(crate) account_id: String,
167
+    pub(crate) email: String,
168
+    pub(crate) team_id: String,
169
+    pub(crate) device_id: String,
170
+    pub(crate) adi_backend: AdiBackendKind,
171
+    pub(crate) machine_identity: MachineIdentity,
172
+    pub(crate) android_adi_identifier: String,
173
+}
174
+
142 175
 #[derive(Clone, Debug)]
143 176
 pub(crate) struct DownloadedProvisioningProfile {
144 177
     pub(crate) name: String,
... ...
@@ -350,6 +383,70 @@ pub(crate) async fn download_developer_provisioning_profile(
350 383
     .await?
351 384
 }
352 385
 
386
+pub(crate) async fn list_developer_devices(
387
+    request: DeveloperDeviceListRequest,
388
+) -> BackendResult<Vec<DeveloperDevice>> {
389
+    let account = refresh_account_seed(&request.account_id, &request.email);
390
+    let session = validated_keychain_session(&account)?;
391
+
392
+    backend_runtime::run("developer device list", move || async move {
393
+        let account_id = account.id.clone();
394
+        let result = with_developer_session(
395
+            session_config(
396
+                request.adi_backend,
397
+                request.machine_identity,
398
+                request.android_adi_identifier,
399
+            ),
400
+            session,
401
+            |xcode_session| {
402
+                Box::pin(
403
+                    async move { fetch_developer_devices(xcode_session, &request.team_id).await },
404
+                )
405
+            },
406
+        )
407
+        .await?;
408
+
409
+        save_keychain_session(&account_id, &result.keychain_session)?;
410
+        Ok(result.value)
411
+    })
412
+    .await?
413
+}
414
+
415
+pub(crate) async fn add_developer_device(
416
+    request: DeveloperDeviceAddRequest,
417
+) -> BackendResult<Vec<DeveloperDevice>> {
418
+    mutate_developer_devices(
419
+        request.account_id,
420
+        request.email,
421
+        request.adi_backend,
422
+        request.machine_identity,
423
+        request.android_adi_identifier,
424
+        request.team_id,
425
+        DeveloperDeviceOperation::Add {
426
+            name: request.name,
427
+            udid: request.udid,
428
+        },
429
+    )
430
+    .await
431
+}
432
+
433
+pub(crate) async fn delete_developer_device(
434
+    request: DeveloperDeviceDeleteRequest,
435
+) -> BackendResult<Vec<DeveloperDevice>> {
436
+    mutate_developer_devices(
437
+        request.account_id,
438
+        request.email,
439
+        request.adi_backend,
440
+        request.machine_identity,
441
+        request.android_adi_identifier,
442
+        request.team_id,
443
+        DeveloperDeviceOperation::Delete {
444
+            device_id: request.device_id,
445
+        },
446
+    )
447
+    .await
448
+}
449
+
353 450
 pub(crate) async fn login_developer_account(
354 451
     request: DeveloperLoginRequest,
355 452
 ) -> BackendResult<DeveloperLoginOutcome> {
... ...
@@ -392,6 +489,11 @@ enum DeveloperTeamOperation {
392 489
     },
393 490
 }
394 491
 
492
+enum DeveloperDeviceOperation {
493
+    Add { name: String, udid: String },
494
+    Delete { device_id: String },
495
+}
496
+
395 497
 async fn mutate_team_resources(
396 498
     account_id: &str,
397 499
     email: &str,
... ...
@@ -570,6 +672,129 @@ async fn perform_team_operation(
570 672
     }
571 673
 }
572 674
 
675
+async fn mutate_developer_devices(
676
+    account_id: String,
677
+    email: String,
678
+    adi_backend: AdiBackendKind,
679
+    machine_identity: MachineIdentity,
680
+    android_adi_identifier: String,
681
+    team_id: String,
682
+    operation: DeveloperDeviceOperation,
683
+) -> BackendResult<Vec<DeveloperDevice>> {
684
+    let account = refresh_account_seed(&account_id, &email);
685
+    let session = validated_keychain_session(&account)?;
686
+
687
+    backend_runtime::run("developer device update", move || async move {
688
+        let account_id = account.id.clone();
689
+        let result = with_developer_session(
690
+            session_config(adi_backend, machine_identity, android_adi_identifier),
691
+            session,
692
+            |xcode_session| {
693
+                Box::pin(async move {
694
+                    perform_device_operation(xcode_session, &team_id, operation).await?;
695
+                    fetch_developer_devices(xcode_session, &team_id).await
696
+                })
697
+            },
698
+        )
699
+        .await?;
700
+
701
+        save_keychain_session(&account_id, &result.keychain_session)?;
702
+        Ok(result.value)
703
+    })
704
+    .await?
705
+}
706
+
707
+async fn perform_device_operation(
708
+    xcode_session: &XcodeSession<'_, '_>,
709
+    team_id: &str,
710
+    operation: DeveloperDeviceOperation,
711
+) -> BackendResult<()> {
712
+    match operation {
713
+        DeveloperDeviceOperation::Add { name, udid } => {
714
+            if name.trim().is_empty() {
715
+                return Err(BackendError::Message(
716
+                    "Enter a device name before registering it.".to_string(),
717
+                ));
718
+            }
719
+            if udid.trim().is_empty() {
720
+                return Err(BackendError::Message(
721
+                    "Enter a device UDID before registering it.".to_string(),
722
+                ));
723
+            }
724
+            xcode_session
725
+                .perform_developer_action(IOSRequest::new(AddDeviceAction {
726
+                    team_id: team_id.into(),
727
+                    name,
728
+                    device_number: udid,
729
+                }))
730
+                .await
731
+                .map_err(|error| {
732
+                    BackendError::Network(format!("Failed to register device: {error}"))
733
+                })?
734
+                .map_err(|error| {
735
+                    BackendError::AppleAuth(format!(
736
+                        "Failed to parse device registration response: {error}"
737
+                    ))
738
+                })?;
739
+        }
740
+        DeveloperDeviceOperation::Delete { device_id } => {
741
+            xcode_session
742
+                .perform_developer_action(IOSRequest::new(DeleteDeviceAction {
743
+                    team_id: team_id.into(),
744
+                    device_id,
745
+                }))
746
+                .await
747
+                .map_err(|error| {
748
+                    BackendError::Network(format!("Failed to remove device: {error}"))
749
+                })?
750
+                .map_err(|error| {
751
+                    BackendError::AppleAuth(format!(
752
+                        "Failed to parse device removal response: {error}"
753
+                    ))
754
+                })?;
755
+        }
756
+    }
757
+    Ok(())
758
+}
759
+
760
+async fn fetch_developer_devices(
761
+    xcode_session: &XcodeSession<'_, '_>,
762
+    team_id: &str,
763
+) -> BackendResult<Vec<DeveloperDevice>> {
764
+    let mut devices = xcode_session
765
+        .perform_developer_action(IOSRequest::new(ListDevicesAction {
766
+            team_id: team_id.into(),
767
+        }))
768
+        .await
769
+        .map_err(|error| {
770
+            BackendError::Network(format!("Failed to fetch registered devices: {error}"))
771
+        })?
772
+        .map_err(|error| {
773
+            BackendError::AppleAuth(format!(
774
+                "Failed to parse registered devices response: {error}"
775
+            ))
776
+        })?
777
+        .devices
778
+        .into_iter()
779
+        .map(|device| DeveloperDevice {
780
+            id: device.device_id,
781
+            name: device.name,
782
+            udid: device.device_number,
783
+        })
784
+        .collect::<Vec<_>>();
785
+    sort_developer_devices(&mut devices);
786
+    Ok(devices)
787
+}
788
+
789
+fn sort_developer_devices(devices: &mut [DeveloperDevice]) {
790
+    devices.sort_by(|left, right| {
791
+        left.name
792
+            .to_ascii_lowercase()
793
+            .cmp(&right.name.to_ascii_lowercase())
794
+            .then_with(|| left.udid.cmp(&right.udid))
795
+    });
796
+}
797
+
573 798
 async fn refresh_cached_team_only(
574 799
     account_id: &str,
575 800
     email: &str,
... ...
@@ -762,6 +987,8 @@ async fn login_developer_account_async(
762 987
             }
763 988
             return Err(error);
764 989
         }
990
+    } else {
991
+        cache_keychain_session(&account_cache.id, &keychain_session)?;
765 992
     }
766 993
 
767 994
     Ok(DeveloperLoginOutcome::SignedIn(account_cache.into()))
... ...
@@ -847,7 +1074,6 @@ async fn fetch_team_resources(
847 1074
     team: CachedDeveloperTeam,
848 1075
     xcode_session: &XcodeSession<'_, '_>,
849 1076
 ) -> BackendResult<CachedDeveloperTeam> {
850
-    let local_identity_fingerprints = local_code_signing_identity_fingerprints();
851 1077
     let app_managed_key_fingerprints = app_managed_private_key_fingerprints();
852 1078
     let certificates = xcode_session
853 1079
         .perform_developer_action(IOSRequest::new(ListAllDevelopmentCertsAction {
... ...
@@ -868,27 +1094,26 @@ async fn fetch_team_resources(
868 1094
         })?
869 1095
         .certificates
870 1096
         .into_iter()
871
-        .map(|certificate| {
1097
+        .map(|certificate| -> BackendResult<_> {
872 1098
             let fingerprint = certificate_fingerprint(&certificate.cert_content);
873 1099
             let public_key_fingerprint =
874 1100
                 certificate_public_key_fingerprint(&certificate.cert_content);
875
-            CachedDevelopmentCertificate {
1101
+            save_app_managed_certificate(&fingerprint, &certificate.cert_content)?;
1102
+            Ok(CachedDevelopmentCertificate {
876 1103
                 id: certificate.certificate_id,
877 1104
                 name: certificate.name,
878 1105
                 serial_number: certificate.serial_number,
879 1106
                 machine_name: certificate.machine_name,
880
-                private_key_available: local_identity_fingerprints
881
-                    .iter()
882
-                    .any(|identity| identity == &fingerprint)
883
-                    || public_key_fingerprint.as_ref().is_some_and(|fingerprint| {
1107
+                private_key_available: public_key_fingerprint.as_ref().is_some_and(|fingerprint| {
884 1108
                     app_managed_key_fingerprints
885 1109
                         .iter()
886 1110
                         .any(|candidate| candidate == fingerprint)
887 1111
                 }),
1112
+                certificate_fingerprint: Some(fingerprint),
888 1113
                 public_key_fingerprint,
889
-            }
890 1114
             })
891
-        .collect();
1115
+        })
1116
+        .collect::<BackendResult<Vec<_>>>()?;
892 1117
 
893 1118
     let app_id_response = xcode_session
894 1119
         .perform_developer_action(IOSRequest::new(ListAppIdsAction {
... ...
@@ -1188,4 +1413,35 @@ mod tests {
1188 1413
         assert_eq!(seed.id, "TEAM");
1189 1414
         assert_eq!(seed.role, "Individual");
1190 1415
     }
1416
+
1417
+    #[test]
1418
+    fn developer_devices_are_sorted_by_name_then_udid() {
1419
+        let mut devices = vec![
1420
+            DeveloperDevice {
1421
+                id: "3".into(),
1422
+                name: "iPhone".into(),
1423
+                udid: "B".into(),
1424
+            },
1425
+            DeveloperDevice {
1426
+                id: "1".into(),
1427
+                name: "iPad".into(),
1428
+                udid: "Z".into(),
1429
+            },
1430
+            DeveloperDevice {
1431
+                id: "2".into(),
1432
+                name: "iphone".into(),
1433
+                udid: "A".into(),
1434
+            },
1435
+        ];
1436
+
1437
+        sort_developer_devices(&mut devices);
1438
+
1439
+        assert_eq!(
1440
+            devices
1441
+                .into_iter()
1442
+                .map(|device| device.id)
1443
+                .collect::<Vec<_>>(),
1444
+            vec!["1", "2", "3"]
1445
+        );
1446
+    }
1191 1447
 }
... ...
@@ -1,6 +1,7 @@
1 1
 use crate::backend::developer::accounts;
2
+use crate::backend::developer::certificates::{self, AppManagedSigningMaterial};
2 3
 use crate::backend::BackendResult;
3
-use crate::domain::{AdiBackendKind, DeveloperAccount, MachineIdentity};
4
+use crate::domain::{AdiBackendKind, DeveloperAccount, DeveloperDevice, MachineIdentity};
4 5
 use std::path::PathBuf;
5 6
 
6 7
 #[derive(Clone, Debug)]
... ...
@@ -66,6 +67,13 @@ pub(crate) fn delete_account_cache(account_id: &str) -> BackendResult<()> {
66 67
     accounts::delete_account_cache(account_id)
67 68
 }
68 69
 
70
+pub(crate) fn load_signing_material(
71
+    certificate_fingerprint: &str,
72
+    public_key_fingerprint: &str,
73
+) -> BackendResult<AppManagedSigningMaterial> {
74
+    certificates::load_app_managed_signing_material(certificate_fingerprint, public_key_fingerprint)
75
+}
76
+
69 77
 pub(crate) async fn login(request: DeveloperLoginRequest) -> BackendResult<DeveloperLoginOutcome> {
70 78
     accounts::login_developer_account(accounts::DeveloperLoginRequest {
71 79
         email: request.email,
... ...
@@ -154,6 +162,60 @@ pub(crate) async fn delete_app_id(
154 162
     .await
155 163
 }
156 164
 
165
+pub(crate) async fn list_developer_devices(
166
+    context: DeveloperSessionContext,
167
+    team_id: String,
168
+) -> BackendResult<Vec<DeveloperDevice>> {
169
+    let request = context.refresh_request();
170
+    accounts::list_developer_devices(accounts::DeveloperDeviceListRequest {
171
+        account_id: request.account_id,
172
+        email: request.email,
173
+        team_id,
174
+        adi_backend: request.adi_backend,
175
+        machine_identity: request.machine_identity,
176
+        android_adi_identifier: request.android_adi_identifier,
177
+    })
178
+    .await
179
+}
180
+
181
+pub(crate) async fn add_developer_device(
182
+    context: DeveloperSessionContext,
183
+    team_id: String,
184
+    name: String,
185
+    udid: String,
186
+) -> BackendResult<Vec<DeveloperDevice>> {
187
+    let request = context.refresh_request();
188
+    accounts::add_developer_device(accounts::DeveloperDeviceAddRequest {
189
+        account_id: request.account_id,
190
+        email: request.email,
191
+        team_id,
192
+        name,
193
+        udid,
194
+        adi_backend: request.adi_backend,
195
+        machine_identity: request.machine_identity,
196
+        android_adi_identifier: request.android_adi_identifier,
197
+    })
198
+    .await
199
+}
200
+
201
+pub(crate) async fn delete_developer_device(
202
+    context: DeveloperSessionContext,
203
+    team_id: String,
204
+    device_id: String,
205
+) -> BackendResult<Vec<DeveloperDevice>> {
206
+    let request = context.refresh_request();
207
+    accounts::delete_developer_device(accounts::DeveloperDeviceDeleteRequest {
208
+        account_id: request.account_id,
209
+        email: request.email,
210
+        team_id,
211
+        device_id,
212
+        adi_backend: request.adi_backend,
213
+        machine_identity: request.machine_identity,
214
+        android_adi_identifier: request.android_adi_identifier,
215
+    })
216
+    .await
217
+}
218
+
157 219
 pub(crate) async fn create_certificate(
158 220
     context: DeveloperSessionContext,
159 221
     team_id: String,
... ...
@@ -1,13 +1,22 @@
1
+use crate::backend::ipa::SignedAppBundle;
1 2
 use crate::backend::{runtime as backend_runtime, BackendError, BackendResult};
2
-use crate::domain::{Device, DeviceWatchEvent};
3
+use crate::domain::{Device, DeviceInstallProgress, DeviceWatchEvent};
3 4
 use futures::{channel::mpsc::UnboundedSender, StreamExt};
4 5
 use idevice::{
6
+    afc::{opcode::AfcFopenMode, AfcClient},
5 7
     provider::UsbmuxdProvider,
8
+    services::installation_proxy::InstallationProxyClient,
6 9
     services::lockdown::LockdownClient,
7 10
     usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdDevice},
8 11
     IdeviceService,
9 12
 };
10 13
 use plist::Value;
14
+use std::path::{Component, Path, PathBuf};
15
+use std::sync::{Arc, Mutex};
16
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
17
+use walkdir::WalkDir;
18
+
19
+const APP_UPLOAD_BUFFER_SIZE: usize = 1024 * 1024;
11 20
 
12 21
 pub(crate) async fn discover_devices() -> BackendResult<Vec<Device>> {
13 22
     backend_runtime::run_send("device discovery", discover_devices_async()).await?
... ...
@@ -19,6 +28,327 @@ pub(crate) async fn watch_device_changes(
19 28
     backend_runtime::run("device watcher", move || watch_device_changes_async(sender)).await?
20 29
 }
21 30
 
31
+pub(crate) async fn install_app(
32
+    udid: String,
33
+    signed_app: SignedAppBundle,
34
+    progress: impl FnMut(DeviceInstallProgress) + Send + 'static,
35
+) -> BackendResult<()> {
36
+    backend_runtime::run_send(
37
+        "app installation",
38
+        install_app_async(udid, signed_app, progress),
39
+    )
40
+    .await?
41
+}
42
+
43
+async fn install_app_async(
44
+    udid: String,
45
+    signed_app: SignedAppBundle,
46
+    progress: impl FnMut(DeviceInstallProgress) + Send + 'static,
47
+) -> BackendResult<()> {
48
+    let progress = Arc::new(Mutex::new(progress));
49
+    report_install_progress(&progress, DeviceInstallProgress::Connecting);
50
+
51
+    let provider = provider_for_udid(&udid).await?;
52
+    let app_path = signed_app.path().to_path_buf();
53
+    let upload_plan = app_upload_plan(&app_path)?;
54
+
55
+    let mut afc = AfcClient::connect(&provider).await.map_err(|error| {
56
+        BackendError::DeviceInstall(format!("Unable to open AFC on {udid}: {error}"))
57
+    })?;
58
+    if afc.get_file_info("PublicStaging").await.is_err() {
59
+        afc.mk_dir("PublicStaging").await.map_err(|error| {
60
+            BackendError::DeviceInstall(format!(
61
+                "Unable to create the device staging directory: {error}"
62
+            ))
63
+        })?;
64
+    }
65
+
66
+    let app_folder_name = app_path
67
+        .file_name()
68
+        .and_then(|name| name.to_str())
69
+        .filter(|name| name.ends_with(".app"))
70
+        .ok_or_else(|| {
71
+            BackendError::DeviceInstall(format!(
72
+                "Signed app path {} does not end in an .app folder.",
73
+                app_path.display()
74
+            ))
75
+        })?;
76
+    let remote_path = format!("PublicStaging/{app_folder_name}");
77
+    if afc.get_file_info(&remote_path).await.is_ok() {
78
+        afc.remove_all(&remote_path).await.map_err(|error| {
79
+            BackendError::DeviceInstall(format!(
80
+                "Unable to replace the existing staged app folder: {error}"
81
+            ))
82
+        })?;
83
+    }
84
+    afc.mk_dir(&remote_path).await.map_err(|error| {
85
+        BackendError::DeviceInstall(format!("Unable to create the staged app folder: {error}"))
86
+    })?;
87
+    if let Err(error) = upload_app_bundle(&mut afc, &remote_path, &upload_plan, &progress).await {
88
+        remove_staged_app(&mut afc, &remote_path).await;
89
+        return Err(error);
90
+    }
91
+
92
+    let mut options = plist::Dictionary::new();
93
+    options.insert(
94
+        "PackageType".to_string(),
95
+        Value::String("Developer".to_string()),
96
+    );
97
+    let mut installer = match InstallationProxyClient::connect(&provider).await {
98
+        Ok(installer) => installer,
99
+        Err(error) => {
100
+            remove_staged_app(&mut afc, &remote_path).await;
101
+            return Err(BackendError::DeviceInstall(format!(
102
+                "Unable to open InstallationProxy on {udid}: {error}"
103
+            )));
104
+        }
105
+    };
106
+    let install_progress = Arc::clone(&progress);
107
+    let result = installer
108
+        .install_with_callback(
109
+            remote_path.clone(),
110
+            Some(Value::Dictionary(options)),
111
+            move |(percent, progress)| async move {
112
+                report_install_progress(&progress, DeviceInstallProgress::Installing { percent });
113
+            },
114
+            install_progress,
115
+        )
116
+        .await;
117
+
118
+    report_install_progress(&progress, DeviceInstallProgress::Finalizing);
119
+    remove_staged_app(&mut afc, &remote_path).await;
120
+    drop(signed_app);
121
+
122
+    result.map_err(|error| {
123
+        BackendError::DeviceInstall(format!("Unable to install the app on {udid}: {error}"))
124
+    })
125
+}
126
+
127
+struct AppUploadPlan {
128
+    entries: Vec<AppUploadEntry>,
129
+    total_bytes: u64,
130
+    total_files: usize,
131
+}
132
+
133
+struct AppUploadEntry {
134
+    local_path: PathBuf,
135
+    relative_path: String,
136
+    is_directory: bool,
137
+}
138
+
139
+fn app_upload_plan(app_path: &Path) -> BackendResult<AppUploadPlan> {
140
+    let mut entries = Vec::new();
141
+    let mut total_bytes = 0_u64;
142
+    let mut total_files = 0_usize;
143
+
144
+    for entry in WalkDir::new(app_path).min_depth(1).sort_by_file_name() {
145
+        let entry = entry.map_err(|error| {
146
+            BackendError::DeviceInstall(format!("Unable to inspect signed app files: {error}"))
147
+        })?;
148
+        let relative = entry.path().strip_prefix(app_path).map_err(|error| {
149
+            BackendError::DeviceInstall(format!("Unable to stage signed app path: {error}"))
150
+        })?;
151
+        let relative_path = device_relative_path(relative)?;
152
+        let file_type = entry.file_type();
153
+        if file_type.is_dir() {
154
+            entries.push(AppUploadEntry {
155
+                local_path: entry.into_path(),
156
+                relative_path,
157
+                is_directory: true,
158
+            });
159
+        } else if file_type.is_file() {
160
+            let metadata = entry.metadata().map_err(|error| {
161
+                BackendError::DeviceInstall(format!(
162
+                    "Unable to inspect {}: {error}",
163
+                    entry.path().display()
164
+                ))
165
+            })?;
166
+            total_bytes = total_bytes.saturating_add(metadata.len());
167
+            total_files += 1;
168
+            entries.push(AppUploadEntry {
169
+                local_path: entry.into_path(),
170
+                relative_path,
171
+                is_directory: false,
172
+            });
173
+        } else {
174
+            return Err(BackendError::DeviceInstall(format!(
175
+                "Signed app contains unsupported file {}.",
176
+                entry.path().display()
177
+            )));
178
+        }
179
+    }
180
+
181
+    Ok(AppUploadPlan {
182
+        entries,
183
+        total_bytes,
184
+        total_files,
185
+    })
186
+}
187
+
188
+fn device_relative_path(path: &Path) -> BackendResult<String> {
189
+    let mut parts = Vec::new();
190
+    for component in path.components() {
191
+        let Component::Normal(component) = component else {
192
+            return Err(BackendError::DeviceInstall(format!(
193
+                "Signed app contains unsafe path {}.",
194
+                path.display()
195
+            )));
196
+        };
197
+        parts.push(component.to_string_lossy().into_owned());
198
+    }
199
+    if parts.is_empty() {
200
+        return Err(BackendError::DeviceInstall(
201
+            "Signed app contains an empty relative path.".to_string(),
202
+        ));
203
+    }
204
+    Ok(parts.join("/"))
205
+}
206
+
207
+async fn upload_app_bundle<P>(
208
+    afc: &mut AfcClient,
209
+    remote_root: &str,
210
+    plan: &AppUploadPlan,
211
+    progress: &Arc<Mutex<P>>,
212
+) -> BackendResult<()>
213
+where
214
+    P: FnMut(DeviceInstallProgress),
215
+{
216
+    let mut transferred_bytes = 0_u64;
217
+    let mut completed_files = 0_usize;
218
+    report_install_progress(
219
+        progress,
220
+        DeviceInstallProgress::Uploading {
221
+            transferred_bytes,
222
+            total_bytes: plan.total_bytes,
223
+            completed_files,
224
+            total_files: plan.total_files,
225
+        },
226
+    );
227
+
228
+    for entry in &plan.entries {
229
+        let remote_path = format!("{remote_root}/{}", entry.relative_path);
230
+        if entry.is_directory {
231
+            afc.mk_dir(&remote_path).await.map_err(|error| {
232
+                BackendError::DeviceInstall(format!(
233
+                    "Unable to create staged folder {remote_path}: {error}"
234
+                ))
235
+            })?;
236
+            continue;
237
+        }
238
+
239
+        let mut source = tokio::fs::File::open(&entry.local_path)
240
+            .await
241
+            .map_err(|error| {
242
+                BackendError::DeviceInstall(format!(
243
+                    "Unable to open signed app file {}: {error}",
244
+                    entry.local_path.display()
245
+                ))
246
+            })?;
247
+        let mut destination =
248
+            afc.open(&remote_path, AfcFopenMode::WrOnly)
249
+                .await
250
+                .map_err(|error| {
251
+                    BackendError::DeviceInstall(format!(
252
+                        "Unable to stage app file {remote_path}: {error}"
253
+                    ))
254
+                })?;
255
+        let mut buffer = vec![0; APP_UPLOAD_BUFFER_SIZE];
256
+        let upload_result = async {
257
+            loop {
258
+                let read = source.read(&mut buffer).await.map_err(|error| {
259
+                    BackendError::DeviceInstall(format!(
260
+                        "Unable to read signed app file {}: {error}",
261
+                        entry.local_path.display()
262
+                    ))
263
+                })?;
264
+                if read == 0 {
265
+                    return Ok(());
266
+                }
267
+                destination
268
+                    .write_all(&buffer[..read])
269
+                    .await
270
+                    .map_err(|error| {
271
+                        BackendError::DeviceInstall(format!(
272
+                            "Unable to upload app file {remote_path}: {error}"
273
+                        ))
274
+                    })?;
275
+                transferred_bytes = transferred_bytes.saturating_add(read as u64);
276
+                report_install_progress(
277
+                    progress,
278
+                    DeviceInstallProgress::Uploading {
279
+                        transferred_bytes,
280
+                        total_bytes: plan.total_bytes,
281
+                        completed_files,
282
+                        total_files: plan.total_files,
283
+                    },
284
+                );
285
+            }
286
+        }
287
+        .await;
288
+        let close_result = destination.close().await.map_err(|error| {
289
+            BackendError::DeviceInstall(format!("Unable to finish app file {remote_path}: {error}"))
290
+        });
291
+        upload_result.and(close_result)?;
292
+        completed_files += 1;
293
+        report_install_progress(
294
+            progress,
295
+            DeviceInstallProgress::Uploading {
296
+                transferred_bytes,
297
+                total_bytes: plan.total_bytes,
298
+                completed_files,
299
+                total_files: plan.total_files,
300
+            },
301
+        );
302
+    }
303
+
304
+    Ok(())
305
+}
306
+
307
+async fn remove_staged_app(afc: &mut AfcClient, remote_path: &str) {
308
+    if let Err(error) = afc.remove_all(remote_path).await {
309
+        log::warn!("Unable to remove staged app {remote_path}: {error}");
310
+    }
311
+}
312
+
313
+async fn provider_for_udid(udid: &str) -> BackendResult<UsbmuxdProvider> {
314
+    let addr = UsbmuxdAddr::from_env_var().map_err(|error| {
315
+        BackendError::DeviceInstall(format!("Invalid usbmuxd address: {error}"))
316
+    })?;
317
+    let mut usbmuxd = addr.connect(100).await.map_err(|error| {
318
+        BackendError::DeviceInstall(format!("Unable to connect to usbmuxd: {error}"))
319
+    })?;
320
+    let device = usbmuxd
321
+        .get_devices()
322
+        .await
323
+        .map_err(|error| {
324
+            BackendError::DeviceInstall(format!("Unable to list connected devices: {error}"))
325
+        })?
326
+        .into_iter()
327
+        .find(|device| device.udid == udid)
328
+        .ok_or_else(|| {
329
+            BackendError::DeviceInstall(format!(
330
+                "Device {udid} is no longer connected. Reconnect it and try again."
331
+            ))
332
+        })?;
333
+
334
+    Ok(UsbmuxdProvider {
335
+        addr,
336
+        tag: 101,
337
+        udid: device.udid,
338
+        device_id: device.device_id,
339
+        label: "super-sideloader.install".to_string(),
340
+    })
341
+}
342
+
343
+fn report_install_progress<P>(progress: &Arc<Mutex<P>>, event: DeviceInstallProgress)
344
+where
345
+    P: FnMut(DeviceInstallProgress),
346
+{
347
+    if let Ok(mut progress) = progress.lock() {
348
+        progress(event);
349
+    }
350
+}
351
+
22 352
 async fn watch_device_changes_async(
23 353
     sender: UnboundedSender<DeviceWatchEvent>,
24 354
 ) -> BackendResult<()> {
... ...
@@ -139,3 +469,31 @@ fn os_name(device_class: Option<&str>, model: &str) -> &'static str {
139 469
         _ => "iOS",
140 470
     }
141 471
 }
472
+
473
+#[cfg(test)]
474
+mod tests {
475
+    use super::*;
476
+    use std::fs;
477
+
478
+    #[test]
479
+    fn app_upload_plan_counts_files_bytes_and_device_paths() {
480
+        let temp = tempfile::tempdir().unwrap();
481
+        let app = temp.path().join("Example.app");
482
+        let plug_in = app.join("PlugIns").join("Widget.appex");
483
+        fs::create_dir_all(&plug_in).unwrap();
484
+        fs::write(app.join("Info.plist"), b"plist").unwrap();
485
+        fs::write(plug_in.join("Widget"), b"executable").unwrap();
486
+
487
+        let plan = app_upload_plan(&app).unwrap();
488
+
489
+        assert_eq!(plan.total_files, 2);
490
+        assert_eq!(plan.total_bytes, 15);
491
+        assert!(plan.entries.iter().any(|entry| {
492
+            entry.relative_path == "PlugIns/Widget.appex/Widget" && !entry.is_directory
493
+        }));
494
+        assert_eq!(
495
+            device_relative_path(Path::new("Frameworks/Example.framework/Example")).unwrap(),
496
+            "Frameworks/Example.framework/Example"
497
+        );
498
+    }
499
+}
... ...
@@ -36,6 +36,8 @@ pub(crate) enum BackendError {
36 36
     Adi(String),
37 37
     #[error("Device discovery failed: {0}")]
38 38
     DeviceDiscovery(String),
39
+    #[error("Device installation failed: {0}")]
40
+    DeviceInstall(String),
39 41
     #[error("Cache operation failed: {0}")]
40 42
     Cache(String),
41 43
     #[error("Preferences operation failed: {0}")]
... ...
@@ -112,6 +114,7 @@ mod tests {
112 114
             BackendError::AppleAuth("invalid password".to_string()).user_message(),
113 115
             BackendError::Adi("not provisioned".to_string()).user_message(),
114 116
             BackendError::DeviceDiscovery("usbmuxd stopped".to_string()).user_message(),
117
+            BackendError::DeviceInstall("verification failed".to_string()).user_message(),
115 118
             BackendError::Cache("cache corrupt".to_string()).user_message(),
116 119
             BackendError::Preferences("settings corrupt".to_string()).user_message(),
117 120
             BackendError::Unsupported("not on this platform".to_string()).user_message(),
... ...
@@ -0,0 +1,389 @@
1
+use crate::backend::{BackendError, BackendResult};
2
+use crc32fast::Hasher;
3
+use flate2::read::DeflateDecoder;
4
+use flate2::write::ZlibEncoder;
5
+use flate2::Compression;
6
+use image::imageops::FilterType;
7
+use image::{DynamicImage, ImageFormat, Rgba};
8
+use std::fs;
9
+use std::io::{Cursor, Read, Write};
10
+use std::path::Path;
11
+
12
+const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
13
+const GENERATED_ICON_PREFIX: &str = "SuperSideloaderIcon";
14
+
15
+pub(crate) const IPHONE_ICON_NAMES: &[&str] = &[
16
+    "SuperSideloaderIcon20",
17
+    "SuperSideloaderIcon29",
18
+    "SuperSideloaderIcon40",
19
+    "SuperSideloaderIcon60",
20
+];
21
+pub(crate) const IPAD_ICON_NAMES: &[&str] = &[
22
+    "SuperSideloaderIcon20",
23
+    "SuperSideloaderIcon29",
24
+    "SuperSideloaderIcon40",
25
+    "SuperSideloaderIcon76",
26
+    "SuperSideloaderIcon83_5",
27
+];
28
+pub(crate) const IPHONE_PRIMARY_ICON_NAME: &str = "SuperSideloaderIcon60";
29
+pub(crate) const IPAD_PRIMARY_ICON_NAME: &str = "SuperSideloaderIcon76";
30
+
31
+struct IconVariant {
32
+    file_name: &'static str,
33
+    pixels: u32,
34
+}
35
+
36
+const ICON_VARIANTS: &[IconVariant] = &[
37
+    IconVariant {
38
+        file_name: "SuperSideloaderIcon20.png",
39
+        pixels: 20,
40
+    },
41
+    IconVariant {
42
+        file_name: "SuperSideloaderIcon20@2x.png",
43
+        pixels: 40,
44
+    },
45
+    IconVariant {
46
+        file_name: "SuperSideloaderIcon20@3x.png",
47
+        pixels: 60,
48
+    },
49
+    IconVariant {
50
+        file_name: "SuperSideloaderIcon29.png",
51
+        pixels: 29,
52
+    },
53
+    IconVariant {
54
+        file_name: "SuperSideloaderIcon29@2x.png",
55
+        pixels: 58,
56
+    },
57
+    IconVariant {
58
+        file_name: "SuperSideloaderIcon29@3x.png",
59
+        pixels: 87,
60
+    },
61
+    IconVariant {
62
+        file_name: "SuperSideloaderIcon40.png",
63
+        pixels: 40,
64
+    },
65
+    IconVariant {
66
+        file_name: "SuperSideloaderIcon40@2x.png",
67
+        pixels: 80,
68
+    },
69
+    IconVariant {
70
+        file_name: "SuperSideloaderIcon40@3x.png",
71
+        pixels: 120,
72
+    },
73
+    IconVariant {
74
+        file_name: "SuperSideloaderIcon60.png",
75
+        pixels: 60,
76
+    },
77
+    IconVariant {
78
+        file_name: "SuperSideloaderIcon60@2x.png",
79
+        pixels: 120,
80
+    },
81
+    IconVariant {
82
+        file_name: "SuperSideloaderIcon60@3x.png",
83
+        pixels: 180,
84
+    },
85
+    IconVariant {
86
+        file_name: "SuperSideloaderIcon76.png",
87
+        pixels: 76,
88
+    },
89
+    IconVariant {
90
+        file_name: "SuperSideloaderIcon76@2x.png",
91
+        pixels: 152,
92
+    },
93
+    IconVariant {
94
+        file_name: "SuperSideloaderIcon83_5@2x.png",
95
+        pixels: 167,
96
+    },
97
+];
98
+
99
+pub(crate) fn normalize_png_for_display(bytes: &[u8]) -> Result<Vec<u8>, String> {
100
+    let image = decode_png(bytes)?;
101
+    encode_png(&image)
102
+}
103
+
104
+pub(crate) fn write_icon_variants(app_path: &Path, source_path: &Path) -> BackendResult<()> {
105
+    let source_bytes = fs::read(source_path).map_err(|source| BackendError::Io {
106
+        action: "Read app icon override",
107
+        path: source_path.to_path_buf(),
108
+        source,
109
+    })?;
110
+    let source = decode_png(&source_bytes).map_err(|error| {
111
+        BackendError::Message(format!(
112
+            "Failed to decode app icon override {}: {error}",
113
+            source_path.display()
114
+        ))
115
+    })?;
116
+    if source.width() != source.height() {
117
+        return Err(BackendError::Message(format!(
118
+            "The app icon override must be square, but it is {}x{} pixels.",
119
+            source.width(),
120
+            source.height()
121
+        )));
122
+    }
123
+
124
+    remove_previous_variants(app_path)?;
125
+    for variant in ICON_VARIANTS {
126
+        let destination = app_path.join(variant.file_name);
127
+        source
128
+            .resize_exact(variant.pixels, variant.pixels, FilterType::Lanczos3)
129
+            .save_with_format(&destination, ImageFormat::Png)
130
+            .map_err(|error| {
131
+                BackendError::Message(format!(
132
+                    "Failed to write app icon variant {}: {error}",
133
+                    destination.display()
134
+                ))
135
+            })?;
136
+    }
137
+    Ok(())
138
+}
139
+
140
+fn decode_png(bytes: &[u8]) -> Result<DynamicImage, String> {
141
+    let chunks = parse_png_chunks(bytes)?;
142
+    if chunks.iter().any(|chunk| chunk.kind == *b"CgBI") {
143
+        decode_cgbi_png(&chunks)
144
+    } else {
145
+        image::load_from_memory_with_format(bytes, ImageFormat::Png)
146
+            .map_err(|error| format!("invalid PNG: {error}"))
147
+    }
148
+}
149
+
150
+fn decode_cgbi_png(chunks: &[PngChunk<'_>]) -> Result<DynamicImage, String> {
151
+    let ihdr = chunks
152
+        .iter()
153
+        .find(|chunk| chunk.kind == *b"IHDR")
154
+        .ok_or_else(|| "CgBI PNG has no IHDR chunk".to_string())?;
155
+    if ihdr.data.len() != 13 || ihdr.data[8] != 8 || ihdr.data[9] != 6 {
156
+        return Err("CgBI PNG must use 8-bit RGBA pixels".to_string());
157
+    }
158
+
159
+    let compressed = chunks
160
+        .iter()
161
+        .filter(|chunk| chunk.kind == *b"IDAT")
162
+        .flat_map(|chunk| chunk.data.iter().copied())
163
+        .collect::<Vec<_>>();
164
+    if compressed.is_empty() {
165
+        return Err("CgBI PNG has no IDAT data".to_string());
166
+    }
167
+
168
+    let mut filtered_pixels = Vec::new();
169
+    DeflateDecoder::new(compressed.as_slice())
170
+        .read_to_end(&mut filtered_pixels)
171
+        .map_err(|error| format!("failed to inflate CgBI pixel data: {error}"))?;
172
+    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
173
+    encoder
174
+        .write_all(&filtered_pixels)
175
+        .map_err(|error| format!("failed to recompress CgBI pixel data: {error}"))?;
176
+    let standard_idat = encoder
177
+        .finish()
178
+        .map_err(|error| format!("failed to finish CgBI pixel data: {error}"))?;
179
+
180
+    let mut standard_png = PNG_SIGNATURE.to_vec();
181
+    let mut wrote_idat = false;
182
+    for chunk in chunks {
183
+        match &chunk.kind {
184
+            b"CgBI" => {}
185
+            b"IDAT" if !wrote_idat => {
186
+                write_png_chunk(&mut standard_png, *b"IDAT", &standard_idat)?;
187
+                wrote_idat = true;
188
+            }
189
+            b"IDAT" => {}
190
+            _ => write_png_chunk(&mut standard_png, chunk.kind, chunk.data)?,
191
+        }
192
+    }
193
+
194
+    let mut image = image::load_from_memory_with_format(&standard_png, ImageFormat::Png)
195
+        .map_err(|error| format!("failed to decode normalized CgBI PNG: {error}"))?
196
+        .to_rgba8();
197
+    for pixel in image.pixels_mut() {
198
+        let [blue, green, red, alpha] = pixel.0;
199
+        *pixel = Rgba([
200
+            unpremultiply(red, alpha),
201
+            unpremultiply(green, alpha),
202
+            unpremultiply(blue, alpha),
203
+            alpha,
204
+        ]);
205
+    }
206
+    Ok(DynamicImage::ImageRgba8(image))
207
+}
208
+
209
+fn encode_png(image: &DynamicImage) -> Result<Vec<u8>, String> {
210
+    let mut output = Cursor::new(Vec::new());
211
+    image
212
+        .write_to(&mut output, ImageFormat::Png)
213
+        .map_err(|error| format!("failed to encode PNG: {error}"))?;
214
+    Ok(output.into_inner())
215
+}
216
+
217
+fn unpremultiply(value: u8, alpha: u8) -> u8 {
218
+    if alpha == 0 {
219
+        return 0;
220
+    }
221
+    ((u32::from(value) * 255 + u32::from(alpha) / 2) / u32::from(alpha)).min(255) as u8
222
+}
223
+
224
+struct PngChunk<'a> {
225
+    kind: [u8; 4],
226
+    data: &'a [u8],
227
+}
228
+
229
+fn parse_png_chunks(bytes: &[u8]) -> Result<Vec<PngChunk<'_>>, String> {
230
+    if !bytes.starts_with(PNG_SIGNATURE) {
231
+        return Err("file is not a PNG".to_string());
232
+    }
233
+
234
+    let mut chunks = Vec::new();
235
+    let mut offset = PNG_SIGNATURE.len();
236
+    while offset < bytes.len() {
237
+        let header_end = offset
238
+            .checked_add(8)
239
+            .filter(|end| *end <= bytes.len())
240
+            .ok_or_else(|| "PNG has a truncated chunk header".to_string())?;
241
+        let length = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize;
242
+        let kind = bytes[offset + 4..header_end].try_into().unwrap();
243
+        let data_start = header_end;
244
+        let data_end = data_start
245
+            .checked_add(length)
246
+            .filter(|end| {
247
+                end.checked_add(4)
248
+                    .is_some_and(|crc_end| crc_end <= bytes.len())
249
+            })
250
+            .ok_or_else(|| "PNG has a truncated chunk".to_string())?;
251
+        chunks.push(PngChunk {
252
+            kind,
253
+            data: &bytes[data_start..data_end],
254
+        });
255
+        offset = data_end + 4;
256
+        if kind == *b"IEND" {
257
+            break;
258
+        }
259
+    }
260
+
261
+    if !chunks.iter().any(|chunk| chunk.kind == *b"IHDR") {
262
+        return Err("PNG has no IHDR chunk".to_string());
263
+    }
264
+    if !chunks.iter().any(|chunk| chunk.kind == *b"IEND") {
265
+        return Err("PNG has no IEND chunk".to_string());
266
+    }
267
+    Ok(chunks)
268
+}
269
+
270
+fn write_png_chunk(output: &mut Vec<u8>, kind: [u8; 4], data: &[u8]) -> Result<(), String> {
271
+    let length = u32::try_from(data.len()).map_err(|_| "PNG chunk is too large".to_string())?;
272
+    output.extend_from_slice(&length.to_be_bytes());
273
+    output.extend_from_slice(&kind);
274
+    output.extend_from_slice(data);
275
+    let mut crc = Hasher::new();
276
+    crc.update(&kind);
277
+    crc.update(data);
278
+    output.extend_from_slice(&crc.finalize().to_be_bytes());
279
+    Ok(())
280
+}
281
+
282
+fn remove_previous_variants(app_path: &Path) -> BackendResult<()> {
283
+    let entries = fs::read_dir(app_path).map_err(|source| BackendError::Io {
284
+        action: "Read app bundle while replacing icons",
285
+        path: app_path.to_path_buf(),
286
+        source,
287
+    })?;
288
+    for entry in entries {
289
+        let entry = entry.map_err(|source| BackendError::Io {
290
+            action: "Read app bundle entry while replacing icons",
291
+            path: app_path.to_path_buf(),
292
+            source,
293
+        })?;
294
+        let path = entry.path();
295
+        let generated_png = path
296
+            .file_name()
297
+            .and_then(|name| name.to_str())
298
+            .is_some_and(|name| {
299
+                name.starts_with(GENERATED_ICON_PREFIX)
300
+                    && name.to_ascii_lowercase().ends_with(".png")
301
+            });
302
+        if generated_png {
303
+            fs::remove_file(&path).map_err(|source| BackendError::Io {
304
+                action: "Remove previous app icon variant",
305
+                path,
306
+                source,
307
+            })?;
308
+        }
309
+    }
310
+    Ok(())
311
+}
312
+
313
+#[cfg(test)]
314
+mod tests {
315
+    use super::*;
316
+    use flate2::write::DeflateEncoder;
317
+    use image::{GenericImageView, RgbaImage};
318
+
319
+    #[test]
320
+    fn cgbi_png_is_normalized_to_standard_rgba_png() {
321
+        let cgbi = cgbi_test_png();
322
+
323
+        let normalized = normalize_png_for_display(&cgbi).unwrap();
324
+        assert!(!parse_png_chunks(&normalized)
325
+            .unwrap()
326
+            .iter()
327
+            .any(|chunk| chunk.kind == *b"CgBI"));
328
+
329
+        let image = image::load_from_memory_with_format(&normalized, ImageFormat::Png).unwrap();
330
+        assert_eq!(image.dimensions(), (2, 1));
331
+        assert_eq!(image.to_rgba8().get_pixel(0, 0).0, [128, 64, 32, 128]);
332
+        assert_eq!(image.to_rgba8().get_pixel(1, 0).0, [20, 40, 80, 255]);
333
+    }
334
+
335
+    #[test]
336
+    fn icon_variants_have_the_expected_pixel_sizes() {
337
+        let temp = tempfile::tempdir().unwrap();
338
+        let app_path = temp.path().join("Example.app");
339
+        fs::create_dir(&app_path).unwrap();
340
+        let source_path = temp.path().join("source.png");
341
+        RgbaImage::from_pixel(180, 180, Rgba([20, 40, 80, 255]))
342
+            .save(&source_path)
343
+            .unwrap();
344
+
345
+        write_icon_variants(&app_path, &source_path).unwrap();
346
+
347
+        for variant in ICON_VARIANTS {
348
+            let image = image::open(app_path.join(variant.file_name)).unwrap();
349
+            assert_eq!(
350
+                image.dimensions(),
351
+                (variant.pixels, variant.pixels),
352
+                "{}",
353
+                variant.file_name
354
+            );
355
+        }
356
+    }
357
+
358
+    #[test]
359
+    fn icon_override_must_be_square() {
360
+        let temp = tempfile::tempdir().unwrap();
361
+        let app_path = temp.path().join("Example.app");
362
+        fs::create_dir(&app_path).unwrap();
363
+        let source_path = temp.path().join("source.png");
364
+        RgbaImage::new(180, 120).save(&source_path).unwrap();
365
+
366
+        let error = write_icon_variants(&app_path, &source_path).unwrap_err();
367
+        assert!(error.user_message().contains("must be square"));
368
+    }
369
+
370
+    fn cgbi_test_png() -> Vec<u8> {
371
+        let mut raw_pixels = vec![0];
372
+        raw_pixels.extend_from_slice(&[16, 32, 64, 128]);
373
+        raw_pixels.extend_from_slice(&[80, 40, 20, 255]);
374
+        let mut deflater = DeflateEncoder::new(Vec::new(), Compression::default());
375
+        deflater.write_all(&raw_pixels).unwrap();
376
+        let idat = deflater.finish().unwrap();
377
+
378
+        let mut png = PNG_SIGNATURE.to_vec();
379
+        write_png_chunk(&mut png, *b"CgBI", &[0x40, 0, 0, 0]).unwrap();
380
+        let mut ihdr = Vec::new();
381
+        ihdr.extend_from_slice(&2u32.to_be_bytes());
382
+        ihdr.extend_from_slice(&1u32.to_be_bytes());
383
+        ihdr.extend_from_slice(&[8, 6, 0, 0, 0]);
384
+        write_png_chunk(&mut png, *b"IHDR", &ihdr).unwrap();
385
+        write_png_chunk(&mut png, *b"IDAT", &idat).unwrap();
386
+        write_png_chunk(&mut png, *b"IEND", &[]).unwrap();
387
+        png
388
+    }
389
+}
... ...
@@ -1,20 +1,95 @@
1
+use crate::backend::icon::{
2
+    normalize_png_for_display, write_icon_variants, IPAD_ICON_NAMES, IPAD_PRIMARY_ICON_NAME,
3
+    IPHONE_ICON_NAMES, IPHONE_PRIMARY_ICON_NAME,
4
+};
1 5
 use crate::backend::paths::app_data_dir;
2 6
 use crate::backend::{runtime as backend_runtime, BackendError, BackendResult};
3 7
 use crate::domain::{
4
-    AppEntitlement, AppMetadata, EntitlementValue, EntitlementsSource, IpaApp, Patch,
8
+    AppEntitlement, AppMetadata, EntitlementValue, EntitlementsSource, IpaApp, NestedBundle, Patch,
5 9
     SupportedDeviceFamily,
6 10
 };
11
+use apple_codesign::{
12
+    sign_bundle, BundleSigningSettings, ProvisioningProfile, RustCryptoCmsSigner,
13
+};
14
+use async_zip::base::read::mem::ZipFileReader as MemoryZipFileReader;
15
+use async_zip::base::write::ZipFileWriter;
7 16
 use async_zip::tokio::read::fs::ZipFileReader;
17
+use async_zip::{Compression, ZipEntry, ZipEntryBuilder};
18
+use flate2::write::DeflateEncoder;
19
+use flate2::Compression as FlateCompression;
8 20
 use futures_lite::io::AsyncReadExt;
9 21
 use plist::{Dictionary, Value};
22
+use std::collections::BTreeMap;
10 23
 use std::fs;
11
-use std::io::Cursor;
12
-use std::path::PathBuf;
24
+use std::io::{Cursor, Write as _};
25
+use std::path::{Component, Path, PathBuf};
26
+use tempfile::{Builder as TempBuilder, TempDir};
27
+use tokio::io::AsyncWriteExt;
28
+use tokio::task::JoinSet;
29
+use walkdir::WalkDir;
30
+
31
+#[cfg(unix)]
32
+use std::os::unix::fs::PermissionsExt;
33
+
34
+const MAX_EXTRACTION_WORKERS: usize = 4;
35
+const MAX_PACKAGING_WORKERS: usize = 4;
36
+const FAST_DEFLATE_LEVEL: u32 = 4;
37
+const IN_MEMORY_EXTRACTION_LIMIT: u64 = 256 * 1024 * 1024;
38
+
39
+pub(crate) struct IpaSigningRequest {
40
+    pub(crate) source_path: PathBuf,
41
+    pub(crate) destination: SigningDestination,
42
+    pub(crate) metadata: AppMetadata,
43
+    pub(crate) bundle_id_replacements: BTreeMap<String, String>,
44
+    pub(crate) icon_override_path: Option<PathBuf>,
45
+    pub(crate) team_id: String,
46
+    pub(crate) provisioning_profiles: BTreeMap<String, Vec<u8>>,
47
+    pub(crate) private_key_pem: Vec<u8>,
48
+    pub(crate) certificate_der: Vec<u8>,
49
+}
50
+
51
+pub(crate) enum SigningDestination {
52
+    Ipa(PathBuf),
53
+    AppBundle,
54
+}
55
+
56
+pub(crate) enum SigningArtifact {
57
+    Ipa(PathBuf),
58
+    AppBundle(SignedAppBundle),
59
+}
60
+
61
+pub(crate) struct SignedAppBundle {
62
+    app_path: PathBuf,
63
+    _work_dir: TempDir,
64
+}
65
+
66
+impl SignedAppBundle {
67
+    pub(crate) fn path(&self) -> &Path {
68
+        &self.app_path
69
+    }
70
+}
71
+
72
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73
+pub(crate) enum IpaSigningProgress {
74
+    Extracting { completed: usize, total: usize },
75
+    Patching,
76
+    Signing,
77
+    Packaging { completed: usize, total: usize },
78
+    Saving,
79
+    Ready,
80
+}
13 81
 
14 82
 pub(crate) async fn read_ipa(path: PathBuf, patches: Vec<Patch>) -> BackendResult<IpaApp> {
15 83
     backend_runtime::run_send("IPA reader", read_ipa_async(path, patches)).await?
16 84
 }
17 85
 
86
+pub(crate) async fn sign_ipa(
87
+    request: IpaSigningRequest,
88
+    progress: impl FnMut(IpaSigningProgress) + Send + 'static,
89
+) -> BackendResult<SigningArtifact> {
90
+    backend_runtime::run_send("IPA signer", sign_ipa_async(request, progress)).await?
91
+}
92
+
18 93
 async fn read_ipa_async(path: PathBuf, patches: Vec<Patch>) -> BackendResult<IpaApp> {
19 94
     let reader = ZipFileReader::new(&path)
20 95
         .await
... ...
@@ -48,6 +123,7 @@ async fn read_ipa_async(path: PathBuf, patches: Vec<Patch>) -> BackendResult<Ipa
48 123
         .or_else(|| plist_string(&info, "CFBundleName"))
49 124
         .unwrap_or(executable);
50 125
     let supported_devices = supported_devices(&info);
126
+    let nested_bundles = read_nested_signable_bundles(&reader, &app_prefix).await?;
51 127
 
52 128
     let icon_path = extract_icon(&reader, &app_prefix, &info, bundle_id).await;
53 129
     let (entitlements_source, entitlements) = match extract_entitlements(&reader, &app_prefix).await
... ...
@@ -66,6 +142,7 @@ async fn read_ipa_async(path: PathBuf, patches: Vec<Patch>) -> BackendResult<Ipa
66 142
             minimum_os: minimum_os.to_string(),
67 143
             supported_devices,
68 144
         },
145
+        nested_bundles,
69 146
         path: path.to_string_lossy().to_string(),
70 147
         icon_path: icon_path.map(|path| path.to_string_lossy().to_string()),
71 148
         entitlements,
... ...
@@ -74,10 +151,60 @@ async fn read_ipa_async(path: PathBuf, patches: Vec<Patch>) -> BackendResult<Ipa
74 151
     })
75 152
 }
76 153
 
154
+async fn read_nested_signable_bundles(
155
+    reader: &ZipFileReader,
156
+    app_prefix: &str,
157
+) -> BackendResult<Vec<NestedBundle>> {
158
+    let candidates = reader
159
+        .file()
160
+        .entries()
161
+        .iter()
162
+        .enumerate()
163
+        .filter_map(|(index, entry)| {
164
+            let path = entry_name(entry)?;
165
+            is_nested_signable_info_plist(path, app_prefix).then(|| (index, path.to_string()))
166
+        })
167
+        .collect::<Vec<_>>();
168
+    let mut bundles = Vec::with_capacity(candidates.len());
169
+
170
+    for (index, path) in candidates {
171
+        let bytes = read_entry(reader, index, &path).await?;
172
+        let info = plist::from_bytes::<Dictionary>(&bytes)
173
+            .map_err(|error| BackendError::Plist(format!("{path}: {error}")))?;
174
+        if plist_string(&info, "CFBundleExecutable").is_none() {
175
+            continue;
176
+        }
177
+        let bundle_id = required_plist_string(&info, "CFBundleIdentifier")?.to_string();
178
+        let name = plist_string(&info, "CFBundleDisplayName")
179
+            .or_else(|| plist_string(&info, "CFBundleName"))
180
+            .unwrap_or(&bundle_id)
181
+            .to_string();
182
+        bundles.push(NestedBundle { name, bundle_id });
183
+    }
184
+
185
+    bundles.sort_by(|left, right| left.bundle_id.cmp(&right.bundle_id));
186
+    bundles.dedup_by(|left, right| left.bundle_id == right.bundle_id);
187
+    Ok(bundles)
188
+}
189
+
190
+fn is_nested_signable_info_plist(path: &str, app_prefix: &str) -> bool {
191
+    let Some(relative) = path
192
+        .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"))
202
+}
203
+
77 204
 fn is_app_info_plist(path: &str) -> bool {
78
-    path.starts_with("Payload/")
79
-        && path.ends_with(".app/Info.plist")
80
-        && path["Payload/".len()..].contains(".app/")
205
+    path.strip_prefix("Payload/")
206
+        .and_then(|path| path.strip_suffix("/Info.plist"))
207
+        .is_some_and(|bundle| bundle.ends_with(".app") && !bundle.contains('/'))
81 208
 }
82 209
 
83 210
 fn entry_name(entry: &async_zip::ZipEntry) -> Option<&str> {
... ...
@@ -226,10 +353,17 @@ fn find_icon_entry<'a>(
226 353
 }
227 354
 
228 355
 fn cache_icon(bundle_id: &str, bytes: &[u8]) -> Option<PathBuf> {
356
+    let normalized = match normalize_png_for_display(bytes) {
357
+        Ok(normalized) => normalized,
358
+        Err(error) => {
359
+            log::warn!("Failed to normalize app icon for {bundle_id}: {error}");
360
+            return None;
361
+        }
362
+    };
229 363
     let mut path = app_data_dir()?.join("icons");
230 364
     fs::create_dir_all(&path).ok()?;
231 365
     path.push(format!("{}.png", cache_safe_name(bundle_id)));
232
-    fs::write(&path, bytes).ok()?;
366
+    fs::write(&path, normalized).ok()?;
233 367
     Some(path)
234 368
 }
235 369
 
... ...
@@ -308,18 +442,1056 @@ fn entitlement_value(value: &Value) -> EntitlementValue {
308 442
     }
309 443
 }
310 444
 
445
+async fn sign_ipa_async(
446
+    request: IpaSigningRequest,
447
+    mut progress: impl FnMut(IpaSigningProgress) + Send,
448
+) -> BackendResult<SigningArtifact> {
449
+    let work_parent = app_data_dir()
450
+        .ok_or_else(|| {
451
+            BackendError::Unsupported(
452
+                "The application data folder is not available for IPA signing.".to_string(),
453
+            )
454
+        })?
455
+        .join("signing");
456
+    fs::create_dir_all(&work_parent).map_err(|source| BackendError::Io {
457
+        action: "Create IPA signing work folder",
458
+        path: work_parent.clone(),
459
+        source,
460
+    })?;
461
+    let work_dir = TempBuilder::new()
462
+        .prefix("sign-")
463
+        .tempdir_in(&work_parent)
464
+        .map_err(|source| BackendError::Io {
465
+            action: "Create temporary IPA signing folder",
466
+            path: work_parent,
467
+            source,
468
+        })?;
469
+
470
+    extract_ipa(&request.source_path, work_dir.path(), &mut progress).await?;
471
+    let app_path = root_app_bundle(work_dir.path())?;
472
+    progress(IpaSigningProgress::Patching);
473
+    patch_app_bundle(
474
+        &app_path,
475
+        &request.metadata,
476
+        request.icon_override_path.as_deref(),
477
+    )?;
478
+    patch_nested_bundle_identifiers(&app_path, &request.bundle_id_replacements)?;
479
+    progress(IpaSigningProgress::Signing);
480
+    sign_staged_app(&app_path, &request)?;
481
+
482
+    if matches!(&request.destination, SigningDestination::AppBundle) {
483
+        progress(IpaSigningProgress::Ready);
484
+        return Ok(SigningArtifact::AppBundle(SignedAppBundle {
485
+            app_path,
486
+            _work_dir: work_dir,
487
+        }));
488
+    }
489
+
490
+    let SigningDestination::Ipa(output_path) = request.destination else {
491
+        unreachable!("app bundle destination returned above");
492
+    };
493
+
494
+    let output_parent = output_path
495
+        .parent()
496
+        .filter(|path| !path.as_os_str().is_empty())
497
+        .unwrap_or_else(|| Path::new("."));
498
+    fs::create_dir_all(output_parent).map_err(|source| BackendError::Io {
499
+        action: "Create signed IPA output folder",
500
+        path: output_parent.to_path_buf(),
501
+        source,
502
+    })?;
503
+    let output_temp = TempBuilder::new()
504
+        .prefix(".super-sideloader-")
505
+        .suffix(".ipa.tmp")
506
+        .tempfile_in(output_parent)
507
+        .map_err(|source| BackendError::Io {
508
+            action: "Create temporary signed IPA",
509
+            path: output_parent.to_path_buf(),
510
+            source,
511
+        })?
512
+        .into_temp_path();
513
+
514
+    package_ipa(work_dir.path(), output_temp.as_ref(), &mut progress).await?;
515
+    progress(IpaSigningProgress::Saving);
516
+    output_temp
517
+        .persist(&output_path)
518
+        .map_err(|error| BackendError::Io {
519
+            action: "Save signed IPA",
520
+            path: output_path.clone(),
521
+            source: error.error,
522
+        })?;
523
+    Ok(SigningArtifact::Ipa(output_path))
524
+}
525
+
526
+async fn extract_ipa(
527
+    source_path: &Path,
528
+    destination: &Path,
529
+    progress: &mut impl FnMut(IpaSigningProgress),
530
+) -> BackendResult<()> {
531
+    let reader = ExtractionReader::open(source_path).await?;
532
+    let total = reader.file().entries().len();
533
+    progress(IpaSigningProgress::Extracting {
534
+        completed: 0,
535
+        total,
536
+    });
537
+
538
+    let mut files = Vec::new();
539
+    let mut completed = 0;
540
+    for index in 0..total {
541
+        let entry = &reader.file().entries()[index];
542
+        let name = entry_name(entry)
543
+            .ok_or_else(|| BackendError::Zip("IPA contains an invalid entry name.".to_string()))?
544
+            .to_string();
545
+        let relative_path = safe_zip_entry_path(&name)?;
546
+        let output_path = destination.join(&relative_path);
547
+        let unix_permissions = entry.unix_permissions();
548
+        if unix_permissions.is_some_and(is_symlink_mode) {
549
+            return Err(BackendError::Zip(format!(
550
+                "IPA contains unsupported symbolic link entry {name}."
551
+            )));
552
+        }
553
+
554
+        if name.ends_with('/') {
555
+            fs::create_dir_all(&output_path).map_err(|source| BackendError::Io {
556
+                action: "Create extracted IPA folder",
557
+                path: output_path,
558
+                source,
559
+            })?;
560
+            completed += 1;
561
+            report_extracting_progress(progress, completed, total);
562
+            continue;
563
+        }
564
+
565
+        if let Some(parent) = output_path.parent() {
566
+            fs::create_dir_all(parent).map_err(|source| BackendError::Io {
567
+                action: "Create extracted IPA folder",
568
+                path: parent.to_path_buf(),
569
+                source,
570
+            })?;
571
+        }
572
+        files.push(ExtractionPlan {
573
+            index,
574
+            name,
575
+            output_path,
576
+            unix_permissions,
577
+        });
578
+    }
579
+
580
+    let mut files = files.into_iter();
581
+    let mut workers = JoinSet::new();
582
+    let concurrency = archive_worker_count(MAX_EXTRACTION_WORKERS);
583
+    for _ in 0..concurrency {
584
+        let Some(plan) = files.next() else {
585
+            break;
586
+        };
587
+        spawn_extraction_worker(&mut workers, reader.clone(), plan);
588
+    }
589
+
590
+    while let Some(result) = workers.join_next().await {
591
+        result.map_err(|error| {
592
+            BackendError::Zip(format!("IPA extraction worker failed: {error}"))
593
+        })??;
594
+        completed += 1;
595
+        report_extracting_progress(progress, completed, total);
596
+
597
+        if let Some(plan) = files.next() {
598
+            spawn_extraction_worker(&mut workers, reader.clone(), plan);
599
+        }
600
+    }
601
+
602
+    Ok(())
603
+}
604
+
605
+struct ExtractionPlan {
606
+    index: usize,
607
+    name: String,
608
+    output_path: PathBuf,
609
+    unix_permissions: Option<u16>,
610
+}
611
+
612
+fn spawn_extraction_worker(
613
+    workers: &mut JoinSet<BackendResult<()>>,
614
+    reader: ExtractionReader,
615
+    plan: ExtractionPlan,
616
+) {
617
+    workers.spawn(async move {
618
+        let data = reader.read_entry(plan.index, &plan.name).await?;
619
+        tokio::fs::write(&plan.output_path, data)
620
+            .await
621
+            .map_err(|source| BackendError::Io {
622
+                action: "Extract IPA file",
623
+                path: plan.output_path.clone(),
624
+                source,
625
+            })?;
626
+        set_extracted_permissions(&plan.output_path, plan.unix_permissions)
627
+    });
628
+}
629
+
630
+#[derive(Clone)]
631
+enum ExtractionReader {
632
+    FileSystem(ZipFileReader),
633
+    Memory(MemoryZipFileReader),
634
+}
635
+
636
+impl ExtractionReader {
637
+    async fn open(path: &Path) -> BackendResult<Self> {
638
+        let metadata = tokio::fs::metadata(path)
639
+            .await
640
+            .map_err(|source| BackendError::Io {
641
+                action: "Inspect IPA before extraction",
642
+                path: path.to_path_buf(),
643
+                source,
644
+            })?;
645
+        if metadata.len() <= IN_MEMORY_EXTRACTION_LIMIT {
646
+            let data = tokio::fs::read(path)
647
+                .await
648
+                .map_err(|source| BackendError::Io {
649
+                    action: "Read IPA for extraction",
650
+                    path: path.to_path_buf(),
651
+                    source,
652
+                })?;
653
+            let reader = MemoryZipFileReader::new(data)
654
+                .await
655
+                .map_err(|error| BackendError::Zip(format!("{}: {error}", path.display())))?;
656
+            Ok(Self::Memory(reader))
657
+        } else {
658
+            let reader = ZipFileReader::new(path)
659
+                .await
660
+                .map_err(|error| BackendError::Zip(format!("{}: {error}", path.display())))?;
661
+            Ok(Self::FileSystem(reader))
662
+        }
663
+    }
664
+
665
+    fn file(&self) -> &async_zip::ZipFile {
666
+        match self {
667
+            Self::FileSystem(reader) => reader.file(),
668
+            Self::Memory(reader) => reader.file(),
669
+        }
670
+    }
671
+
672
+    async fn read_entry(&self, index: usize, label: &str) -> BackendResult<Vec<u8>> {
673
+        match self {
674
+            Self::FileSystem(reader) => read_entry(reader, index, label).await,
675
+            Self::Memory(reader) => {
676
+                let mut entry_reader =
677
+                    reader.reader_without_entry(index).await.map_err(|error| {
678
+                        BackendError::Zip(format!("Failed to read {label} from IPA: {error}"))
679
+                    })?;
680
+                let mut data = Vec::new();
681
+                entry_reader.read_to_end(&mut data).await.map_err(|error| {
682
+                    BackendError::Zip(format!("Failed to extract {label} from IPA: {error}"))
683
+                })?;
684
+                Ok(data)
685
+            }
686
+        }
687
+    }
688
+}
689
+
690
+fn archive_worker_count(maximum: usize) -> usize {
691
+    std::thread::available_parallelism()
692
+        .map(|parallelism| parallelism.get())
693
+        .unwrap_or(1)
694
+        .clamp(1, maximum)
695
+}
696
+
697
+fn safe_zip_entry_path(name: &str) -> BackendResult<PathBuf> {
698
+    if name.is_empty() || name.contains('\\') {
699
+        return Err(unsafe_zip_path_error(name));
700
+    }
701
+
702
+    let mut result = PathBuf::new();
703
+    for component in Path::new(name).components() {
704
+        match component {
705
+            Component::Normal(component) => result.push(component),
706
+            _ => return Err(unsafe_zip_path_error(name)),
707
+        }
708
+    }
709
+    if result.as_os_str().is_empty() {
710
+        Err(unsafe_zip_path_error(name))
711
+    } else {
712
+        Ok(result)
713
+    }
714
+}
715
+
716
+fn unsafe_zip_path_error(name: &str) -> BackendError {
717
+    BackendError::Zip(format!("IPA contains unsafe entry path {name:?}."))
718
+}
719
+
720
+fn is_symlink_mode(mode: u16) -> bool {
721
+    mode & 0o170000 == 0o120000
722
+}
723
+
724
+#[cfg(unix)]
725
+fn set_extracted_permissions(path: &Path, mode: Option<u16>) -> BackendResult<()> {
726
+    let Some(mode) = mode else {
727
+        return Ok(());
728
+    };
729
+    let mode = mode & 0o7777;
730
+    if mode == 0 {
731
+        return Ok(());
732
+    }
733
+    fs::set_permissions(path, fs::Permissions::from_mode(u32::from(mode))).map_err(|source| {
734
+        BackendError::Io {
735
+            action: "Restore extracted IPA permissions",
736
+            path: path.to_path_buf(),
737
+            source,
738
+        }
739
+    })
740
+}
741
+
742
+#[cfg(not(unix))]
743
+fn set_extracted_permissions(_: &Path, _: Option<u16>) -> BackendResult<()> {
744
+    Ok(())
745
+}
746
+
747
+fn root_app_bundle(staging_root: &Path) -> BackendResult<PathBuf> {
748
+    let payload = staging_root.join("Payload");
749
+    let entries = fs::read_dir(&payload).map_err(|source| BackendError::Io {
750
+        action: "Read extracted IPA Payload folder",
751
+        path: payload.clone(),
752
+        source,
753
+    })?;
754
+    let mut apps = entries
755
+        .filter_map(Result::ok)
756
+        .filter_map(|entry| {
757
+            let path = entry.path();
758
+            entry
759
+                .file_type()
760
+                .ok()
761
+                .filter(|kind| kind.is_dir())
762
+                .and_then(|_| {
763
+                    (path.extension().and_then(|value| value.to_str()) == Some("app"))
764
+                        .then_some(path)
765
+                })
766
+        })
767
+        .collect::<Vec<_>>();
768
+    apps.sort();
769
+
770
+    match apps.len() {
771
+        1 => Ok(apps.remove(0)),
772
+        0 => Err(BackendError::Zip(
773
+            "IPA does not contain a root Payload/*.app bundle.".to_string(),
774
+        )),
775
+        count => Err(BackendError::Zip(format!(
776
+            "IPA contains {count} root app bundles; exactly one is required."
777
+        ))),
778
+    }
779
+}
780
+
781
+fn patch_app_bundle(
782
+    app_path: &Path,
783
+    metadata: &AppMetadata,
784
+    icon_override_path: Option<&Path>,
785
+) -> BackendResult<()> {
786
+    validate_metadata(metadata)?;
787
+    let info_path = app_path.join("Info.plist");
788
+    let mut info = match Value::from_file(&info_path).map_err(|error| {
789
+        BackendError::Plist(format!("Failed to read {}: {error}", info_path.display()))
790
+    })? {
791
+        Value::Dictionary(info) => info,
792
+        _ => {
793
+            return Err(BackendError::Plist(format!(
794
+                "{} is not a dictionary.",
795
+                info_path.display()
796
+            )));
797
+        }
798
+    };
799
+
800
+    let old_executable = required_plist_string(&info, "CFBundleExecutable")?.to_string();
801
+    validate_executable_name(&old_executable)?;
802
+    validate_executable_name(&metadata.executable)?;
803
+    if old_executable != metadata.executable {
804
+        let old_path = app_path.join(&old_executable);
805
+        let new_path = app_path.join(&metadata.executable);
806
+        if new_path.exists() {
807
+            return Err(BackendError::Message(format!(
808
+                "Cannot rename the app executable to {} because that file already exists.",
809
+                metadata.executable
810
+            )));
811
+        }
812
+        fs::rename(&old_path, &new_path).map_err(|source| BackendError::Io {
813
+            action: "Rename app executable",
814
+            path: old_path,
815
+            source,
816
+        })?;
817
+    }
818
+
819
+    set_info_string(&mut info, "CFBundleIdentifier", &metadata.bundle_id);
820
+    set_info_string(&mut info, "CFBundleDisplayName", &metadata.name);
821
+    set_info_string(&mut info, "CFBundleName", &metadata.name);
822
+    set_info_string(&mut info, "CFBundleShortVersionString", &metadata.version);
823
+    set_info_string(&mut info, "CFBundleVersion", &metadata.build);
824
+    set_info_string(&mut info, "CFBundleExecutable", &metadata.executable);
825
+    set_info_string(&mut info, "MinimumOSVersion", &metadata.minimum_os);
826
+    if metadata.supported_devices.is_empty() {
827
+        info.remove("UIDeviceFamily");
828
+    } else {
829
+        info.insert(
830
+            "UIDeviceFamily".to_string(),
831
+            Value::Array(
832
+                metadata
833
+                    .supported_devices
834
+                    .iter()
835
+                    .map(|family| Value::Integer(device_family_id(*family).into()))
836
+                    .collect(),
837
+            ),
838
+        );
839
+    }
840
+
841
+    if let Some(icon_path) = icon_override_path {
842
+        patch_icon(app_path, &mut info, icon_path, &metadata.supported_devices)?;
843
+    }
844
+
845
+    Value::Dictionary(info)
846
+        .to_file_binary(&info_path)
847
+        .map_err(|error| {
848
+            BackendError::Plist(format!("Failed to write {}: {error}", info_path.display()))
849
+        })
850
+}
851
+
852
+fn patch_nested_bundle_identifiers(
853
+    app_path: &Path,
854
+    replacements: &BTreeMap<String, String>,
855
+) -> BackendResult<()> {
856
+    for entry in WalkDir::new(app_path).follow_links(false) {
857
+        let entry = entry.map_err(|error| {
858
+            BackendError::Message(format!(
859
+                "Failed to inspect nested bundles in {}: {error}",
860
+                app_path.display()
861
+            ))
862
+        })?;
863
+        let info_path = entry.path();
864
+        if !entry.file_type().is_file() || entry.file_name() != "Info.plist" {
865
+            continue;
866
+        }
867
+        let mut info = match Value::from_file(info_path).map_err(|error| {
868
+            BackendError::Plist(format!("Failed to read {}: {error}", info_path.display()))
869
+        })? {
870
+            Value::Dictionary(info) => info,
871
+            _ => continue,
872
+        };
873
+        let Some(bundle_id) = plist_string(&info, "CFBundleIdentifier") else {
874
+            continue;
875
+        };
876
+        let Some(replacement) = replacements.get(bundle_id) else {
877
+            continue;
878
+        };
879
+        if replacement == bundle_id {
880
+            continue;
881
+        }
882
+        info.insert(
883
+            "CFBundleIdentifier".to_string(),
884
+            Value::String(replacement.clone()),
885
+        );
886
+        Value::Dictionary(info)
887
+            .to_file_binary(info_path)
888
+            .map_err(|error| {
889
+                BackendError::Plist(format!("Failed to write {}: {error}", info_path.display()))
890
+            })?;
891
+    }
892
+    Ok(())
893
+}
894
+
895
+fn validate_metadata(metadata: &AppMetadata) -> BackendResult<()> {
896
+    for (label, value) in [
897
+        ("app name", metadata.name.as_str()),
898
+        ("bundle identifier", metadata.bundle_id.as_str()),
899
+        ("version", metadata.version.as_str()),
900
+        ("build", metadata.build.as_str()),
901
+        ("executable", metadata.executable.as_str()),
902
+        ("minimum OS version", metadata.minimum_os.as_str()),
903
+    ] {
904
+        if value.trim().is_empty() {
905
+            return Err(BackendError::Message(format!(
906
+                "The {label} cannot be empty when signing."
907
+            )));
908
+        }
909
+    }
910
+    Ok(())
911
+}
912
+
913
+fn validate_executable_name(name: &str) -> BackendResult<()> {
914
+    let mut components = Path::new(name).components();
915
+    if matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none() {
916
+        Ok(())
917
+    } else {
918
+        Err(BackendError::Message(format!(
919
+            "The app executable name {name:?} must be a file name, not a path."
920
+        )))
921
+    }
922
+}
923
+
924
+fn set_info_string(info: &mut Dictionary, key: &str, value: &str) {
925
+    info.insert(key.to_string(), Value::String(value.to_string()));
926
+}
927
+
928
+fn device_family_id(family: SupportedDeviceFamily) -> i64 {
929
+    match family {
930
+        SupportedDeviceFamily::IPhone => 1,
931
+        SupportedDeviceFamily::IPad => 2,
932
+        SupportedDeviceFamily::AppleTv => 3,
933
+        SupportedDeviceFamily::AppleWatch => 4,
934
+        SupportedDeviceFamily::Mac => 6,
935
+    }
936
+}
937
+
938
+fn patch_icon(
939
+    app_path: &Path,
940
+    info: &mut Dictionary,
941
+    icon_path: &Path,
942
+    supported_devices: &[SupportedDeviceFamily],
943
+) -> BackendResult<()> {
944
+    if !icon_path.is_file() {
945
+        return Err(BackendError::Message(format!(
946
+            "The app icon override does not exist: {}",
947
+            icon_path.display()
948
+        )));
949
+    }
950
+    if !icon_path
951
+        .extension()
952
+        .and_then(|value| value.to_str())
953
+        .is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
954
+    {
955
+        return Err(BackendError::Message(
956
+            "The app icon override must be a PNG file.".to_string(),
957
+        ));
958
+    }
959
+
960
+    write_icon_variants(app_path, icon_path)?;
961
+
962
+    let supports_ipad = supported_devices.contains(&SupportedDeviceFamily::IPad);
963
+    let supports_iphone = supported_devices.contains(&SupportedDeviceFamily::IPhone);
964
+    let (primary_icon_name, primary_icon_names) = if supports_iphone || !supports_ipad {
965
+        (IPHONE_PRIMARY_ICON_NAME, IPHONE_ICON_NAMES)
966
+    } else {
967
+        (IPAD_PRIMARY_ICON_NAME, IPAD_ICON_NAMES)
968
+    };
969
+    info.remove("CFBundleIconName");
970
+    info.insert(
971
+        "CFBundleIconFile".to_string(),
972
+        Value::String(primary_icon_name.to_string()),
973
+    );
974
+    info.insert(
975
+        "CFBundleIconFiles".to_string(),
976
+        icon_file_names(primary_icon_names),
977
+    );
978
+    info.insert(
979
+        "CFBundleIcons".to_string(),
980
+        Value::Dictionary(icon_dictionary(primary_icon_names)),
981
+    );
982
+    if supports_ipad {
983
+        info.insert(
984
+            "CFBundleIconFiles~ipad".to_string(),
985
+            icon_file_names(IPAD_ICON_NAMES),
986
+        );
987
+        info.insert(
988
+            "CFBundleIcons~ipad".to_string(),
989
+            Value::Dictionary(icon_dictionary(IPAD_ICON_NAMES)),
990
+        );
991
+    } else {
992
+        info.remove("CFBundleIconFiles~ipad");
993
+        info.remove("CFBundleIcons~ipad");
994
+    }
995
+    Ok(())
996
+}
997
+
998
+fn icon_file_names(names: &[&str]) -> Value {
999
+    Value::Array(
1000
+        names
1001
+            .iter()
1002
+            .map(|name| Value::String((*name).to_string()))
1003
+            .collect(),
1004
+    )
1005
+}
1006
+
1007
+fn icon_dictionary(names: &[&str]) -> Dictionary {
1008
+    let mut primary = Dictionary::new();
1009
+    primary.insert("CFBundleIconFiles".to_string(), icon_file_names(names));
1010
+    let mut icons = Dictionary::new();
1011
+    icons.insert(
1012
+        "CFBundlePrimaryIcon".to_string(),
1013
+        Value::Dictionary(primary),
1014
+    );
1015
+    icons
1016
+}
1017
+
1018
+fn sign_staged_app(app_path: &Path, request: &IpaSigningRequest) -> BackendResult<()> {
1019
+    let root_profile_data = request
1020
+        .provisioning_profiles
1021
+        .get(&request.metadata.bundle_id)
1022
+        .ok_or_else(|| {
1023
+            BackendError::Message(format!(
1024
+                "No provisioning profile was downloaded for {}.",
1025
+                request.metadata.bundle_id
1026
+            ))
1027
+        })?;
1028
+    let root_profile = ProvisioningProfile::parse(root_profile_data).map_err(|error| {
1029
+        BackendError::Message(format!(
1030
+            "Failed to parse the downloaded provisioning profile: {error}"
1031
+        ))
1032
+    })?;
1033
+    validate_signing_profile(&root_profile, &request.metadata.bundle_id, request)?;
1034
+
1035
+    let private_key_der = decode_private_key(&request.private_key_pem)?;
1036
+    let signer = RustCryptoCmsSigner::from_der(
1037
+        &private_key_der,
1038
+        &request.certificate_der,
1039
+        root_profile
1040
+            .certificate_chain_der()
1041
+            .iter()
1042
+            .map(Vec::as_slice),
1043
+    )
1044
+    .map_err(|error| {
1045
+        BackendError::Message(format!("Failed to prepare the code signer: {error}"))
1046
+    })?;
1047
+    let mut settings = BundleSigningSettings::new(
1048
+        request.team_id.clone(),
1049
+        root_profile.entitlements().clone(),
1050
+        Some(&signer),
1051
+    );
1052
+    settings.embedded_mobileprovisions_by_bundle_id = request
1053
+        .provisioning_profiles
1054
+        .iter()
1055
+        .map(|(bundle_id, data)| (bundle_id.clone(), data.as_slice()))
1056
+        .collect();
1057
+    for (bundle_id, data) in &request.provisioning_profiles {
1058
+        if bundle_id == &request.metadata.bundle_id {
1059
+            continue;
1060
+        }
1061
+        let profile = ProvisioningProfile::parse(data).map_err(|error| {
1062
+            BackendError::Message(format!(
1063
+                "Failed to parse the provisioning profile for {bundle_id}: {error}"
1064
+            ))
1065
+        })?;
1066
+        validate_signing_profile(&profile, bundle_id, request)?;
1067
+        settings
1068
+            .entitlements_by_bundle_id
1069
+            .insert(bundle_id.clone(), profile.entitlements().clone());
1070
+    }
1071
+    sign_bundle(app_path, &settings)
1072
+        .map_err(|error| BackendError::Message(format!("Failed to sign the app bundle: {error}")))
1073
+}
1074
+
1075
+fn validate_signing_profile(
1076
+    profile: &ProvisioningProfile,
1077
+    bundle_id: &str,
1078
+    request: &IpaSigningRequest,
1079
+) -> BackendResult<()> {
1080
+    if profile.team_id() != request.team_id {
1081
+        return Err(BackendError::Message(format!(
1082
+            "The provisioning profile for {bundle_id} belongs to team {}, not selected team {}.",
1083
+            profile.team_id(),
1084
+            request.team_id
1085
+        )));
1086
+    }
1087
+    if !profile_contains_certificate(profile, &request.certificate_der) {
1088
+        return Err(BackendError::Message(format!(
1089
+            "The provisioning profile for {bundle_id} does not include the selected certificate. Refresh Developer Settings and try again."
1090
+        )));
1091
+    }
1092
+
1093
+    let expected_identifier = if bundle_id
1094
+        .strip_prefix(&request.team_id)
1095
+        .is_some_and(|suffix| suffix.starts_with('.'))
1096
+    {
1097
+        bundle_id.to_string()
1098
+    } else {
1099
+        format!("{}.{bundle_id}", request.team_id)
1100
+    };
1101
+    let profile_identifier = profile
1102
+        .entitlements()
1103
+        .get("application-identifier")
1104
+        .and_then(Value::as_string)
1105
+        .ok_or_else(|| {
1106
+            BackendError::Message(format!(
1107
+                "The provisioning profile for {bundle_id} has no application identifier."
1108
+            ))
1109
+        })?;
1110
+    if profile_identifier != expected_identifier
1111
+        && !profile_identifier
1112
+            .strip_suffix('*')
1113
+            .is_some_and(|prefix| expected_identifier.starts_with(prefix))
1114
+    {
1115
+        return Err(BackendError::Message(format!(
1116
+            "Provisioning profile {profile_identifier} does not match bundle {bundle_id}."
1117
+        )));
1118
+    }
1119
+    Ok(())
1120
+}
1121
+
1122
+fn profile_contains_certificate(profile: &ProvisioningProfile, certificate_der: &[u8]) -> bool {
1123
+    profile
1124
+        .plist()
1125
+        .get("DeveloperCertificates")
1126
+        .and_then(Value::as_array)
1127
+        .is_some_and(|certificates| {
1128
+            certificates
1129
+                .iter()
1130
+                .filter_map(Value::as_data)
1131
+                .any(|candidate| candidate == certificate_der)
1132
+        })
1133
+}
1134
+
1135
+fn decode_private_key(data: &[u8]) -> BackendResult<Vec<u8>> {
1136
+    if !data.starts_with(b"-----BEGIN ") {
1137
+        return Ok(data.to_vec());
1138
+    }
1139
+    let (label, der) = pem_rfc7468::decode_vec(data).map_err(|error| {
1140
+        BackendError::Message(format!(
1141
+            "Failed to decode the managed PEM private key: {error}"
1142
+        ))
1143
+    })?;
1144
+    if matches!(label, "PRIVATE KEY" | "RSA PRIVATE KEY") {
1145
+        Ok(der)
1146
+    } else {
1147
+        Err(BackendError::Message(format!(
1148
+            "Unsupported managed private key PEM label {label:?}."
1149
+        )))
1150
+    }
1151
+}
1152
+
1153
+async fn package_ipa(
1154
+    staging_root: &Path,
1155
+    output_path: &Path,
1156
+    progress: &mut impl FnMut(IpaSigningProgress),
1157
+) -> BackendResult<()> {
1158
+    let mut entries = WalkDir::new(staging_root)
1159
+        .follow_links(false)
1160
+        .into_iter()
1161
+        .skip(1)
1162
+        .map(|entry| {
1163
+            entry.map_err(|error| {
1164
+                BackendError::Zip(format!(
1165
+                    "Failed to enumerate staged IPA files under {}: {error}",
1166
+                    staging_root.display()
1167
+                ))
1168
+            })
1169
+        })
1170
+        .collect::<BackendResult<Vec<_>>>()?;
1171
+    entries.sort_by_key(|entry| entry.path().to_path_buf());
1172
+
1173
+    let output = tokio::fs::File::create(output_path)
1174
+        .await
1175
+        .map_err(|source| BackendError::Io {
1176
+            action: "Create signed IPA archive",
1177
+            path: output_path.to_path_buf(),
1178
+            source,
1179
+        })?;
1180
+    let mut writer = ZipFileWriter::with_tokio(output);
1181
+    let total = entries.len();
1182
+    progress(IpaSigningProgress::Packaging {
1183
+        completed: 0,
1184
+        total,
1185
+    });
1186
+
1187
+    let mut directories = Vec::new();
1188
+    let mut files = Vec::new();
1189
+    for entry in entries {
1190
+        let path = entry.path();
1191
+        let relative = path.strip_prefix(staging_root).map_err(|_| {
1192
+            BackendError::Zip(format!(
1193
+                "Failed to create a relative path for {}.",
1194
+                path.display()
1195
+            ))
1196
+        })?;
1197
+        let mut name = relative
1198
+            .to_string_lossy()
1199
+            .replace(std::path::MAIN_SEPARATOR, "/");
1200
+        let metadata = entry.metadata().map_err(|error| {
1201
+            BackendError::Zip(format!("Failed to inspect {}: {error}", path.display()))
1202
+        })?;
1203
+        if metadata.file_type().is_symlink() {
1204
+            return Err(BackendError::Zip(format!(
1205
+                "Staged IPA contains unsupported symbolic link {}.",
1206
+                path.display()
1207
+            )));
1208
+        }
1209
+
1210
+        if metadata.is_dir() {
1211
+            name.push('/');
1212
+            directories.push(PackageDirectory {
1213
+                path: path.to_path_buf(),
1214
+                name,
1215
+                unix_mode: zip_mode(&metadata, true),
1216
+            });
1217
+        } else if metadata.is_file() {
1218
+            files.push(PackageFilePlan {
1219
+                path: path.to_path_buf(),
1220
+                name,
1221
+                unix_mode: zip_mode(&metadata, false),
1222
+            });
1223
+        }
1224
+    }
1225
+
1226
+    let mut completed = 0;
1227
+    for directory in directories {
1228
+        let builder = ZipEntryBuilder::new(directory.name.into(), Compression::Stored)
1229
+            .unix_permissions(directory.unix_mode);
1230
+        writer
1231
+            .write_entry_whole(builder, &[])
1232
+            .await
1233
+            .map_err(|error| {
1234
+                BackendError::Zip(format!("Failed to package {:?}: {error}", directory.path))
1235
+            })?;
1236
+        completed += 1;
1237
+        report_packaging_progress(progress, completed, total);
1238
+    }
1239
+
1240
+    let mut files = files.into_iter();
1241
+    let mut workers = JoinSet::new();
1242
+    let concurrency = archive_worker_count(MAX_PACKAGING_WORKERS);
1243
+    for _ in 0..concurrency {
1244
+        let Some(plan) = files.next() else {
1245
+            break;
1246
+        };
1247
+        workers.spawn_blocking(move || prepare_zip_file(plan));
1248
+    }
1249
+
1250
+    while let Some(result) = workers.join_next().await {
1251
+        let prepared = result.map_err(|error| {
1252
+            BackendError::Zip(format!("IPA packaging worker failed: {error}"))
1253
+        })??;
1254
+        writer
1255
+            .write_entry_whole_precompressed(prepared.entry, &prepared.data)
1256
+            .await
1257
+            .map_err(|error| {
1258
+                BackendError::Zip(format!(
1259
+                    "Failed to package {:?}: {error}",
1260
+                    prepared.source_path
1261
+                ))
1262
+            })?;
1263
+        completed += 1;
1264
+        report_packaging_progress(progress, completed, total);
1265
+
1266
+        if let Some(plan) = files.next() {
1267
+            workers.spawn_blocking(move || prepare_zip_file(plan));
1268
+        }
1269
+    }
1270
+
1271
+    let mut output = writer
1272
+        .close()
1273
+        .await
1274
+        .map_err(|error| BackendError::Zip(format!("Failed to finish signed IPA: {error}")))?
1275
+        .into_inner();
1276
+    output.flush().await.map_err(|source| BackendError::Io {
1277
+        action: "Flush signed IPA archive",
1278
+        path: output_path.to_path_buf(),
1279
+        source,
1280
+    })?;
1281
+    output.sync_all().await.map_err(|source| BackendError::Io {
1282
+        action: "Sync signed IPA archive",
1283
+        path: output_path.to_path_buf(),
1284
+        source,
1285
+    })
1286
+}
1287
+
1288
+struct PackageDirectory {
1289
+    path: PathBuf,
1290
+    name: String,
1291
+    unix_mode: u16,
1292
+}
1293
+
1294
+struct PackageFilePlan {
1295
+    path: PathBuf,
1296
+    name: String,
1297
+    unix_mode: u16,
1298
+}
1299
+
1300
+struct PreparedZipFile {
1301
+    source_path: PathBuf,
1302
+    entry: ZipEntry,
1303
+    data: Vec<u8>,
1304
+}
1305
+
1306
+fn prepare_zip_file(plan: PackageFilePlan) -> BackendResult<PreparedZipFile> {
1307
+    let source_data = fs::read(&plan.path).map_err(|source| BackendError::Io {
1308
+        action: "Read staged IPA file",
1309
+        path: plan.path.clone(),
1310
+        source,
1311
+    })?;
1312
+    let source_size = source_data.len() as u64;
1313
+    let crc32 = crc32fast::hash(&source_data);
1314
+    let preferred_compression = compression_for_path(&plan.path);
1315
+    let (compression, data) = if preferred_compression == Compression::Deflate {
1316
+        let mut encoder =
1317
+            DeflateEncoder::new(Vec::new(), FlateCompression::new(FAST_DEFLATE_LEVEL));
1318
+        encoder
1319
+            .write_all(&source_data)
1320
+            .map_err(|source| BackendError::Io {
1321
+                action: "Compress staged IPA file",
1322
+                path: plan.path.clone(),
1323
+                source,
1324
+            })?;
1325
+        let compressed = encoder.finish().map_err(|source| BackendError::Io {
1326
+            action: "Finish compressing staged IPA file",
1327
+            path: plan.path.clone(),
1328
+            source,
1329
+        })?;
1330
+        if compressed.len() < source_data.len() {
1331
+            (Compression::Deflate, compressed)
1332
+        } else {
1333
+            (Compression::Stored, source_data)
1334
+        }
1335
+    } else {
1336
+        (Compression::Stored, source_data)
1337
+    };
1338
+    let compressed_size = data.len() as u64;
1339
+    let entry = ZipEntryBuilder::new(plan.name.into(), compression)
1340
+        .unix_permissions(plan.unix_mode)
1341
+        .crc32(crc32)
1342
+        .uncompressed_size(source_size)
1343
+        .compressed_size(compressed_size)
1344
+        .build();
1345
+
1346
+    Ok(PreparedZipFile {
1347
+        source_path: plan.path,
1348
+        entry,
1349
+        data,
1350
+    })
1351
+}
1352
+
1353
+fn compression_for_path(path: &Path) -> Compression {
1354
+    let already_compressed = path
1355
+        .extension()
1356
+        .and_then(|extension| extension.to_str())
1357
+        .is_some_and(|extension| {
1358
+            matches!(
1359
+                extension.to_ascii_lowercase().as_str(),
1360
+                "7z" | "aac"
1361
+                    | "bz2"
1362
+                    | "car"
1363
+                    | "gif"
1364
+                    | "gz"
1365
+                    | "heic"
1366
+                    | "heif"
1367
+                    | "ipa"
1368
+                    | "jpeg"
1369
+                    | "jpg"
1370
+                    | "m4a"
1371
+                    | "m4v"
1372
+                    | "mov"
1373
+                    | "mp3"
1374
+                    | "mp4"
1375
+                    | "pdf"
1376
+                    | "png"
1377
+                    | "rar"
1378
+                    | "webp"
1379
+                    | "xz"
1380
+                    | "zip"
1381
+                    | "zst"
1382
+            )
1383
+        });
1384
+    if already_compressed {
1385
+        Compression::Stored
1386
+    } else {
1387
+        Compression::Deflate
1388
+    }
1389
+}
1390
+
1391
+fn report_extracting_progress(
1392
+    progress: &mut impl FnMut(IpaSigningProgress),
1393
+    completed: usize,
1394
+    total: usize,
1395
+) {
1396
+    if should_report_item_progress(completed, total) {
1397
+        progress(IpaSigningProgress::Extracting { completed, total });
1398
+    }
1399
+}
1400
+
1401
+fn report_packaging_progress(
1402
+    progress: &mut impl FnMut(IpaSigningProgress),
1403
+    completed: usize,
1404
+    total: usize,
1405
+) {
1406
+    if should_report_item_progress(completed, total) {
1407
+        progress(IpaSigningProgress::Packaging { completed, total });
1408
+    }
1409
+}
1410
+
1411
+fn should_report_item_progress(completed: usize, total: usize) -> bool {
1412
+    completed == 0
1413
+        || completed >= total
1414
+        || (completed.saturating_mul(100) / total.max(1))
1415
+            != (completed.saturating_sub(1).saturating_mul(100) / total.max(1))
1416
+}
1417
+
1418
+#[cfg(unix)]
1419
+fn zip_mode(metadata: &fs::Metadata, _: bool) -> u16 {
1420
+    (metadata.permissions().mode() & 0xffff) as u16
1421
+}
1422
+
1423
+#[cfg(not(unix))]
1424
+fn zip_mode(_: &fs::Metadata, directory: bool) -> u16 {
1425
+    if directory {
1426
+        0o755
1427
+    } else {
1428
+        0o644
1429
+    }
1430
+}
1431
+
311 1432
 #[cfg(test)]
312 1433
 mod tests {
313 1434
     use super::*;
314 1435
 
1436
+    fn signing_metadata(executable: &str) -> AppMetadata {
1437
+        AppMetadata {
1438
+            name: "Patched App".to_string(),
1439
+            bundle_id: "com.example.patched".to_string(),
1440
+            version: "2.0".to_string(),
1441
+            build: "42".to_string(),
1442
+            executable: executable.to_string(),
1443
+            minimum_os: "16.0".to_string(),
1444
+            supported_devices: vec![SupportedDeviceFamily::IPhone, SupportedDeviceFamily::IPad],
1445
+        }
1446
+    }
1447
+
1448
+    fn write_test_app(app_path: &Path, executable: &str) {
1449
+        fs::create_dir_all(app_path).unwrap();
1450
+        fs::write(app_path.join(executable), b"executable").unwrap();
1451
+        let mut info = Dictionary::new();
1452
+        info.insert(
1453
+            "CFBundleIdentifier".to_string(),
1454
+            Value::String("com.example.original".to_string()),
1455
+        );
1456
+        info.insert(
1457
+            "CFBundleExecutable".to_string(),
1458
+            Value::String(executable.to_string()),
1459
+        );
1460
+        Value::Dictionary(info)
1461
+            .to_file_binary(app_path.join("Info.plist"))
1462
+            .unwrap();
1463
+    }
1464
+
315 1465
     #[test]
316 1466
     fn info_plist_paths_require_payload_app_prefix() {
317 1467
         assert!(is_app_info_plist("Payload/App.app/Info.plist"));
318
-        assert!(is_app_info_plist("Payload/Nested/App.app/Info.plist"));
1468
+        assert!(!is_app_info_plist("Payload/Nested/App.app/Info.plist"));
319 1469
         assert!(!is_app_info_plist("App.app/Info.plist"));
320 1470
         assert!(!is_app_info_plist("Payload/App.app/Other.plist"));
321 1471
     }
322 1472
 
1473
+    #[test]
1474
+    fn nested_signable_bundle_paths_include_extensions_and_nested_apps() {
1475
+        let root = "Payload/App.app/";
1476
+
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(
1486
+            "Payload/App.app/Frameworks/Example.framework/Info.plist",
1487
+            root
1488
+        ));
1489
+        assert!(!is_nested_signable_info_plist(
1490
+            "Payload/App.app/Info.plist",
1491
+            root
1492
+        ));
1493
+    }
1494
+
323 1495
     #[test]
324 1496
     fn required_plist_string_reports_missing_key() {
325 1497
         let mut info = Dictionary::new();
... ...
@@ -340,6 +1512,24 @@ mod tests {
340 1512
         );
341 1513
     }
342 1514
 
1515
+    #[test]
1516
+    fn item_progress_is_sampled_at_percent_boundaries_and_completion() {
1517
+        assert!(should_report_item_progress(0, 1_000));
1518
+        assert!(!should_report_item_progress(1, 1_000));
1519
+        assert!(should_report_item_progress(10, 1_000));
1520
+        assert!(should_report_item_progress(1_000, 1_000));
1521
+    }
1522
+
1523
+    #[test]
1524
+    fn packaging_skips_deflate_for_already_compressed_formats() {
1525
+        for path in ["Icon.PNG", "Assets.car", "Video.mp4", "Nested/archive.zip"] {
1526
+            assert_eq!(compression_for_path(Path::new(path)), Compression::Stored);
1527
+        }
1528
+        for path in ["Info.plist", "Executable", "Framework.dylib"] {
1529
+            assert_eq!(compression_for_path(Path::new(path)), Compression::Deflate);
1530
+        }
1531
+    }
1532
+
343 1533
     #[test]
344 1534
     fn supported_device_family_values_are_normalized() {
345 1535
         let mut info = Dictionary::new();
... ...
@@ -379,4 +1569,241 @@ mod tests {
379 1569
                 && entitlement.value == EntitlementValue::Boolean(true)
380 1570
         }));
381 1571
     }
1572
+
1573
+    #[test]
1574
+    fn zip_entry_paths_reject_traversal_and_platform_separators() {
1575
+        assert_eq!(
1576
+            safe_zip_entry_path("Payload/App.app/Info.plist").unwrap(),
1577
+            PathBuf::from("Payload/App.app/Info.plist")
1578
+        );
1579
+        for path in [
1580
+            "../outside",
1581
+            "Payload/../../outside",
1582
+            "/absolute/path",
1583
+            "Payload\\..\\outside",
1584
+        ] {
1585
+            assert!(safe_zip_entry_path(path).is_err(), "accepted {path}");
1586
+        }
1587
+    }
1588
+
1589
+    #[test]
1590
+    fn plist_patching_updates_metadata_and_renames_executable() {
1591
+        let temp = tempfile::tempdir().unwrap();
1592
+        let app_path = temp.path().join("Payload").join("Test.app");
1593
+        write_test_app(&app_path, "OriginalExecutable");
1594
+
1595
+        patch_app_bundle(&app_path, &signing_metadata("PatchedExecutable"), None).unwrap();
1596
+
1597
+        assert!(!app_path.join("OriginalExecutable").exists());
1598
+        assert!(app_path.join("PatchedExecutable").exists());
1599
+        let info = Value::from_file(app_path.join("Info.plist"))
1600
+            .unwrap()
1601
+            .into_dictionary()
1602
+            .unwrap();
1603
+        assert_eq!(
1604
+            info.get("CFBundleIdentifier").and_then(Value::as_string),
1605
+            Some("com.example.patched")
1606
+        );
1607
+        assert_eq!(
1608
+            info.get("CFBundleDisplayName").and_then(Value::as_string),
1609
+            Some("Patched App")
1610
+        );
1611
+        assert_eq!(
1612
+            info.get("CFBundleExecutable").and_then(Value::as_string),
1613
+            Some("PatchedExecutable")
1614
+        );
1615
+        assert_eq!(
1616
+            info.get("UIDeviceFamily")
1617
+                .and_then(Value::as_array)
1618
+                .map(Vec::len),
1619
+            Some(2)
1620
+        );
1621
+    }
1622
+
1623
+    #[test]
1624
+    fn nested_bundle_identifiers_are_patched_from_provisioning_map() {
1625
+        let temp = tempfile::tempdir().unwrap();
1626
+        let app_path = temp.path().join("Payload").join("Test.app");
1627
+        let extension_path = app_path.join("PlugIns").join("Widget.appex");
1628
+        let framework_path = app_path.join("Frameworks").join("Example.framework");
1629
+        for (path, bundle_id) in [
1630
+            (&extension_path, "com.example.app.widget"),
1631
+            (&framework_path, "com.example.framework"),
1632
+        ] {
1633
+            fs::create_dir_all(path).unwrap();
1634
+            let mut info = Dictionary::new();
1635
+            info.insert(
1636
+                "CFBundleIdentifier".to_string(),
1637
+                Value::String(bundle_id.to_string()),
1638
+            );
1639
+            Value::Dictionary(info)
1640
+                .to_file_binary(path.join("Info.plist"))
1641
+                .unwrap();
1642
+        }
1643
+
1644
+        patch_nested_bundle_identifiers(
1645
+            &app_path,
1646
+            &BTreeMap::from([(
1647
+                "com.example.app.widget".to_string(),
1648
+                "com.example.app.TEAM.widget".to_string(),
1649
+            )]),
1650
+        )
1651
+        .unwrap();
1652
+
1653
+        let extension_info = Value::from_file(extension_path.join("Info.plist"))
1654
+            .unwrap()
1655
+            .into_dictionary()
1656
+            .unwrap();
1657
+        let framework_info = Value::from_file(framework_path.join("Info.plist"))
1658
+            .unwrap()
1659
+            .into_dictionary()
1660
+            .unwrap();
1661
+        assert_eq!(
1662
+            plist_string(&extension_info, "CFBundleIdentifier"),
1663
+            Some("com.example.app.TEAM.widget")
1664
+        );
1665
+        assert_eq!(
1666
+            plist_string(&framework_info, "CFBundleIdentifier"),
1667
+            Some("com.example.framework")
1668
+        );
1669
+    }
1670
+
1671
+    #[test]
1672
+    fn icon_override_generates_variants_and_updates_info_plist() {
1673
+        let temp = tempfile::tempdir().unwrap();
1674
+        let app_path = temp.path().join("Payload").join("Test.app");
1675
+        write_test_app(&app_path, "TestExecutable");
1676
+        let icon_path = temp.path().join("override.png");
1677
+        image::RgbaImage::from_pixel(180, 180, image::Rgba([20, 40, 80, 255]))
1678
+            .save(&icon_path)
1679
+            .unwrap();
1680
+
1681
+        patch_app_bundle(
1682
+            &app_path,
1683
+            &signing_metadata("TestExecutable"),
1684
+            Some(&icon_path),
1685
+        )
1686
+        .unwrap();
1687
+
1688
+        assert!(app_path.join("SuperSideloaderIcon60@3x.png").exists());
1689
+        assert!(app_path.join("SuperSideloaderIcon76@2x.png").exists());
1690
+        assert!(app_path.join("SuperSideloaderIcon83_5@2x.png").exists());
1691
+        let info = Value::from_file(app_path.join("Info.plist"))
1692
+            .unwrap()
1693
+            .into_dictionary()
1694
+            .unwrap();
1695
+        assert_eq!(
1696
+            info.get("CFBundleIconFiles")
1697
+                .and_then(Value::as_array)
1698
+                .and_then(|files| files.first())
1699
+                .and_then(Value::as_string),
1700
+            Some("SuperSideloaderIcon20")
1701
+        );
1702
+        assert_eq!(
1703
+            info.get("CFBundleIconFiles")
1704
+                .and_then(Value::as_array)
1705
+                .map(Vec::len),
1706
+            Some(IPHONE_ICON_NAMES.len())
1707
+        );
1708
+        assert_eq!(
1709
+            info.get("CFBundleIconFiles~ipad")
1710
+                .and_then(Value::as_array)
1711
+                .map(Vec::len),
1712
+            Some(IPAD_ICON_NAMES.len())
1713
+        );
1714
+        assert!(info.contains_key("CFBundleIcons~ipad"));
1715
+    }
1716
+
1717
+    #[tokio::test]
1718
+    async fn ipa_packaging_round_trips_files_permissions_and_compression() {
1719
+        let temp = tempfile::tempdir().unwrap();
1720
+        let staging = temp.path().join("staging");
1721
+        let executable = staging.join("Payload/Test.app/TestExecutable");
1722
+        fs::create_dir_all(executable.parent().unwrap()).unwrap();
1723
+        fs::write(&executable, b"executable").unwrap();
1724
+        let text = executable.parent().unwrap().join("Config.txt");
1725
+        let text_contents = vec![b'A'; 64 * 1024];
1726
+        fs::write(&text, &text_contents).unwrap();
1727
+        let png = executable.parent().unwrap().join("Icon.png");
1728
+        let png_contents = b"already-compressed-image-data";
1729
+        fs::write(&png, png_contents).unwrap();
1730
+        #[cfg(unix)]
1731
+        fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
1732
+
1733
+        let ipa_path = temp.path().join("signed.ipa");
1734
+        let mut progress_events = Vec::new();
1735
+        package_ipa(&staging, &ipa_path, &mut |event| {
1736
+            progress_events.push(event)
1737
+        })
1738
+        .await
1739
+        .unwrap();
1740
+
1741
+        assert!(matches!(
1742
+            progress_events.first(),
1743
+            Some(IpaSigningProgress::Packaging { completed: 0, .. })
1744
+        ));
1745
+        assert!(matches!(
1746
+            progress_events.last(),
1747
+            Some(IpaSigningProgress::Packaging { completed, total }) if completed == total
1748
+        ));
1749
+
1750
+        let reader = ZipFileReader::new(&ipa_path).await.unwrap();
1751
+        let find_entry = |name: &str| {
1752
+            reader
1753
+                .file()
1754
+                .entries()
1755
+                .iter()
1756
+                .find(|entry| entry_name(entry) == Some(name))
1757
+                .unwrap()
1758
+        };
1759
+        let entry = find_entry("Payload/Test.app/TestExecutable");
1760
+        #[cfg(unix)]
1761
+        assert_ne!(entry.unix_permissions().unwrap_or_default() & 0o111, 0);
1762
+        assert_eq!(
1763
+            find_entry("Payload/Test.app/Config.txt").compression(),
1764
+            Compression::Deflate
1765
+        );
1766
+        assert_eq!(
1767
+            find_entry("Payload/Test.app/Icon.png").compression(),
1768
+            Compression::Stored
1769
+        );
1770
+
1771
+        let extracted = temp.path().join("round-trip");
1772
+        fs::create_dir(&extracted).unwrap();
1773
+        let mut extraction_progress = Vec::new();
1774
+        extract_ipa(&ipa_path, &extracted, &mut |event| {
1775
+            extraction_progress.push(event)
1776
+        })
1777
+        .await
1778
+        .unwrap();
1779
+        assert_eq!(
1780
+            fs::read(extracted.join("Payload/Test.app/TestExecutable")).unwrap(),
1781
+            b"executable"
1782
+        );
1783
+        assert_eq!(
1784
+            fs::read(extracted.join("Payload/Test.app/Config.txt")).unwrap(),
1785
+            text_contents
1786
+        );
1787
+        assert_eq!(
1788
+            fs::read(extracted.join("Payload/Test.app/Icon.png")).unwrap(),
1789
+            png_contents
1790
+        );
1791
+        assert!(matches!(
1792
+            extraction_progress.first(),
1793
+            Some(IpaSigningProgress::Extracting { completed: 0, .. })
1794
+        ));
1795
+        assert!(matches!(
1796
+            extraction_progress.last(),
1797
+            Some(IpaSigningProgress::Extracting { completed, total }) if completed == total
1798
+        ));
1799
+        #[cfg(unix)]
1800
+        assert_ne!(
1801
+            fs::metadata(extracted.join("Payload/Test.app/TestExecutable"))
1802
+                .unwrap()
1803
+                .permissions()
1804
+                .mode()
1805
+                & 0o111,
1806
+            0
1807
+        );
1808
+    }
382 1809
 }
... ...
@@ -2,6 +2,7 @@ pub(crate) mod adi;
2 2
 pub(crate) mod adi_service;
3 3
 pub(crate) mod device;
4 4
 pub(crate) mod error;
5
+mod icon;
5 6
 pub(crate) mod ipa;
6 7
 pub(crate) mod paths;
7 8
 pub(crate) mod preferences;
... ...
@@ -44,5 +44,13 @@ pub(crate) struct DeveloperCertificate {
44 44
     pub(crate) serial_number: String,
45 45
     pub(crate) machine_name: String,
46 46
     pub(crate) private_key_available: bool,
47
+    pub(crate) certificate_fingerprint: Option<String>,
47 48
     pub(crate) public_key_fingerprint: Option<String>,
48 49
 }
50
+
51
+#[derive(Clone, Debug, Eq, PartialEq)]
52
+pub(crate) struct DeveloperDevice {
53
+    pub(crate) id: String,
54
+    pub(crate) name: String,
55
+    pub(crate) udid: String,
56
+}
... ...
@@ -12,3 +12,18 @@ pub(crate) enum DeviceWatchEvent {
12 12
     Changed,
13 13
     Failed(String),
14 14
 }
15
+
16
+#[derive(Clone, Debug, Eq, PartialEq)]
17
+pub(crate) enum DeviceInstallProgress {
18
+    Connecting,
19
+    Uploading {
20
+        transferred_bytes: u64,
21
+        total_bytes: u64,
22
+        completed_files: usize,
23
+        total_files: usize,
24
+    },
25
+    Installing {
26
+        percent: u64,
27
+    },
28
+    Finalizing,
29
+}
... ...
@@ -37,6 +37,12 @@ pub(crate) struct AppMetadata {
37 37
     pub(crate) supported_devices: Vec<SupportedDeviceFamily>,
38 38
 }
39 39
 
40
+#[derive(Clone, Debug, Eq, PartialEq)]
41
+pub(crate) struct NestedBundle {
42
+    pub(crate) name: String,
43
+    pub(crate) bundle_id: String,
44
+}
45
+
40 46
 #[derive(Clone, Debug, PartialEq)]
41 47
 pub(crate) struct AppEntitlement {
42 48
     pub(crate) key: String,
... ...
@@ -66,6 +72,7 @@ pub(crate) enum EntitlementsSource {
66 72
 #[derive(Clone, Debug, PartialEq)]
67 73
 pub(crate) struct IpaApp {
68 74
     pub(crate) metadata: AppMetadata,
75
+    pub(crate) nested_bundles: Vec<NestedBundle>,
69 76
     pub(crate) path: String,
70 77
     pub(crate) icon_path: Option<String>,
71 78
     pub(crate) entitlements: Vec<AppEntitlement>,
... ...
@@ -6,7 +6,8 @@ pub(crate) mod ipa;
6 6
 
7 7
 pub(crate) use adi::*;
8 8
 pub(crate) use developer::{
9
-    DeveloperAccount, DeveloperAppId, DeveloperAppIdCapability, DeveloperCertificate, DeveloperTeam,
9
+    DeveloperAccount, DeveloperAppId, DeveloperAppIdCapability, DeveloperCertificate,
10
+    DeveloperDevice, DeveloperTeam,
10 11
 };
11 12
 pub(crate) use device::*;
12 13
 pub(crate) use identity::*;
... ...
@@ -18,11 +18,34 @@ use crate::ui::widgets::{
18 18
 use futures::{channel::mpsc, StreamExt};
19 19
 use gpui::{
20 20
     div, prelude::*, px, App, ClickEvent, Context, ExternalPaths, FocusHandle, FontWeight,
21
-    InteractiveElement, IntoElement, ParentElement, PathPromptOptions, Render, Styled, Window,
21
+    InteractiveElement, IntoElement, ParentElement, PathPromptOptions, PromptButton, PromptLevel,
22
+    Render, Styled, Window,
22 23
 };
23 24
 use gpui_component::button::Button;
25
+use gpui_component::text::TextView;
24 26
 use std::ops::{Deref, DerefMut};
25
-use std::path::PathBuf;
27
+use std::path::{Path, PathBuf};
28
+
29
+const SIDELOAD_SIGNING_WEIGHT: f32 = 0.72;
30
+
31
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32
+enum SigningAction {
33
+    Save,
34
+    Sideload,
35
+}
36
+
37
+impl SigningAction {
38
+    fn installs_after_signing(self) -> bool {
39
+        self == Self::Sideload
40
+    }
41
+
42
+    fn confirmation_label(self) -> &'static str {
43
+        match self {
44
+            Self::Save => "Create and Sign",
45
+            Self::Sideload => "Create and Sideload",
46
+        }
47
+    }
48
+}
26 49
 
27 50
 pub(crate) struct SideloaderView {
28 51
     pub(crate) focus_handle: FocusHandle,
... ...
@@ -300,6 +323,7 @@ impl SideloaderView {
300 323
         if self.device_selection.select(index) {
301 324
             self.open_picker = None;
302 325
             self.save_preferences();
326
+            self.sync_developer_settings_window(cx);
303 327
             cx.notify();
304 328
         }
305 329
     }
... ...
@@ -448,33 +472,508 @@ impl SideloaderView {
448 472
         .detach();
449 473
     }
450 474
 
451
-    fn sideload(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
475
+    fn sideload(&mut self, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
476
+        self.start_signing(SigningAction::Sideload, event, window, cx);
477
+    }
478
+
479
+    fn sign(&mut self, event: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
480
+        self.start_signing(SigningAction::Save, event, window, cx);
481
+    }
482
+
483
+    fn start_signing(
484
+        &mut self,
485
+        action: SigningAction,
486
+        event: &ClickEvent,
487
+        window: &mut Window,
488
+        cx: &mut Context<Self>,
489
+    ) {
452 490
         window.focus(&self.focus_handle, cx);
453 491
         if self.is_busy()
454 492
             || self.selected_account().is_none()
455 493
             || self.selected_app().is_none()
456
-            || self.selected_device().is_none()
494
+            || (action.installs_after_signing() && self.selected_device().is_none())
457 495
         {
458 496
             return;
459 497
         }
460
-        self.start_signing_operation(cx);
498
+
499
+        let Some(account) = self.selected_account().cloned() else {
500
+            return;
501
+        };
502
+        let Some(team) = account.teams.get(self.selected_team).cloned() else {
503
+            self.fail_signing_and_open_developer_settings(
504
+                "Select a developer team before signing.",
505
+                event,
506
+                window,
507
+                cx,
508
+            );
509
+            return;
510
+        };
511
+        let Some(certificate) = team.certificates.get(self.selected_certificate).cloned() else {
512
+            self.fail_signing_and_open_developer_settings(
513
+                "Create a development certificate in Developer Settings before signing.",
514
+                event,
515
+                window,
516
+                cx,
517
+            );
518
+            return;
519
+        };
520
+        if !certificate.private_key_available {
521
+            self.fail_signing_and_open_developer_settings(
522
+                "Import the selected certificate's PEM private key in Developer Settings before signing.",
523
+                event,
524
+                window,
525
+                cx,
526
+            );
527
+            return;
461 528
         }
529
+        let Some(certificate_fingerprint) = certificate.certificate_fingerprint.clone() else {
530
+            self.fail_signing_and_open_developer_settings(
531
+                "Refresh Developer Settings to cache the selected certificate before signing.",
532
+                event,
533
+                window,
534
+                cx,
535
+            );
536
+            return;
537
+        };
538
+        let Some(public_key_fingerprint) = certificate.public_key_fingerprint.clone() else {
539
+            self.fail_signing_and_open_developer_settings(
540
+                "Refresh Developer Settings to inspect the selected certificate before signing.",
541
+                event,
542
+                window,
543
+                cx,
544
+            );
545
+            return;
546
+        };
547
+        let selected_app_id = team.app_ids.get(self.selected_app_id).cloned();
548
+        if !self.auto_app_id && selected_app_id.is_none() {
549
+            self.fail_signing_and_open_developer_settings(
550
+                "Select an App ID in Developer Settings before signing.",
551
+                event,
552
+                window,
553
+                cx,
554
+            );
555
+            return;
556
+        }
557
+        let developer_context = match self.selected_developer_context() {
558
+            Ok(context) => context,
559
+            Err(error) => {
560
+                self.sideload_operation = SideloadOperation::Failed { message: error };
561
+                cx.notify();
562
+                return;
563
+            }
564
+        };
565
+        let app = self
566
+            .selected_app()
567
+            .cloned()
568
+            .expect("selected app checked above");
569
+        let selected_device = action
570
+            .installs_after_signing()
571
+            .then(|| self.selected_device().cloned())
572
+            .flatten();
573
+        let app_name = app.name().to_string();
574
+        let (directory, suggested_name) = signed_ipa_destination(&app.path);
575
+        let destination_receiver = if action == SigningAction::Save {
576
+            Some(cx.prompt_for_new_path(&directory, Some(&suggested_name)))
577
+        } else {
578
+            None
579
+        };
580
+        let auto_app_id = self.auto_app_id;
581
+        let team_id = team.identifier;
582
+        let selected_app_id_identifier = selected_app_id
583
+            .as_ref()
584
+            .map(|app_id| app_id.identifier.clone());
585
+        self.open_picker = None;
586
+        let (progress_sender, mut progress_receiver) = mpsc::unbounded();
587
+        cx.spawn(async move |view, cx| {
588
+            while let Some(event) = progress_receiver.next().await {
589
+                let _ = view.update(cx, |view, cx| {
590
+                    view.set_signing_progress(event, action.installs_after_signing());
591
+                    cx.notify();
592
+                });
593
+            }
594
+        })
595
+        .detach();
596
+        let (install_progress_sender, mut install_progress_receiver) = mpsc::unbounded();
597
+        cx.spawn(async move |view, cx| {
598
+            while let Some(event) = install_progress_receiver.next().await {
599
+                let _ = view.update(cx, |view, cx| {
600
+                    view.set_installing_progress(event);
601
+                    cx.notify();
602
+                });
603
+            }
604
+        })
605
+        .detach();
606
+        cx.notify();
462 607
 
463
-    fn sign(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
464
-        window.focus(&self.focus_handle, cx);
465
-        if self.is_busy() || self.selected_account().is_none() || self.selected_app().is_none() {
608
+        cx.spawn_in(window, async move |view, cx| {
609
+            let signing_output = if let Some(receiver) = destination_receiver {
610
+                match receiver.await {
611
+                    Ok(Ok(Some(path))) => app_effects::SignIpaOutput::Ipa(path),
612
+                    Ok(Ok(None)) | Err(_) => return,
613
+                    Ok(Err(error)) => {
614
+                        let _ = view.update(cx, |view, cx| {
615
+                            view.sideload_operation = SideloadOperation::Failed {
616
+                                message: format!(
617
+                                    "Failed to choose a signed IPA destination: {error}"
618
+                                ),
619
+                            };
620
+                            cx.notify();
621
+                        });
466 622
                         return;
467 623
                     }
468
-        self.start_signing_operation(cx);
469 624
                 }
625
+            } else {
626
+                app_effects::SignIpaOutput::AppBundle
627
+            };
470 628
 
471
-    fn start_signing_operation(&mut self, cx: &mut Context<Self>) {
472
-        self.open_picker = None;
473
-        self.sideload_operation = SideloadOperation::Running {
629
+            let _ = view.update(cx, |view, cx| {
630
+                view.sideload_operation = SideloadOperation::Running {
631
+                    phase: SideloadPhase::Signing,
632
+                    progress: 0.02,
633
+                    detail: "Refreshing App IDs".to_string(),
634
+                };
635
+                cx.notify();
636
+            });
637
+
638
+            let refreshed_account = match cx
639
+                .background_spawn(app_effects::refresh_account(developer_context.clone()))
640
+                .await
641
+            {
642
+                Ok(account) => account,
643
+                Err(error) => {
644
+                    let _ = view.update(cx, |view, cx| {
645
+                        view.sideload_operation = SideloadOperation::Failed {
646
+                            message: format!(
647
+                                "Could not refresh App IDs before signing: {}",
648
+                                error.user_message()
649
+                            ),
650
+                        };
651
+                        cx.notify();
652
+                    });
653
+                    return;
654
+                }
655
+            };
656
+            let Some(refreshed_team) = refreshed_account
657
+                .teams
658
+                .iter()
659
+                .find(|team| team.identifier == team_id)
660
+                .cloned()
661
+            else {
662
+                let _ = view.update(cx, |view, cx| {
663
+                    view.sideload_operation = SideloadOperation::Failed {
664
+                        message: "The selected developer team was not returned by Apple."
665
+                            .to_string(),
666
+                    };
667
+                    cx.notify();
668
+                });
669
+                return;
670
+            };
671
+            if let Some(device) = selected_device.as_ref() {
672
+                let _ = view.update(cx, |view, cx| {
673
+                    view.sideload_operation = SideloadOperation::Running {
674
+                        phase: SideloadPhase::Signing,
675
+                        progress: 0.03,
676
+                        detail: "Checking device registration".to_string(),
677
+                    };
678
+                    cx.notify();
679
+                });
680
+                let registered_devices = match cx
681
+                    .background_spawn(app_effects::list_developer_devices(
682
+                        developer_context.clone(),
683
+                        team_id.clone(),
684
+                    ))
685
+                    .await
686
+                {
687
+                    Ok(devices) => devices,
688
+                    Err(error) => {
689
+                        let _ = view.update(cx, |view, cx| {
690
+                            view.sideload_operation = SideloadOperation::Failed {
691
+                                message: format!(
692
+                                    "Could not check whether {} is registered with Apple: {}",
693
+                                    device.name,
694
+                                    error.user_message()
695
+                                ),
696
+                            };
697
+                            cx.notify();
698
+                        });
699
+                        return;
700
+                    }
701
+                };
702
+                let device_is_registered = registered_devices.iter().any(|registered| {
703
+                    registered
704
+                        .udid
705
+                        .trim()
706
+                        .eq_ignore_ascii_case(device.udid.trim())
707
+                });
708
+                if !device_is_registered {
709
+                    let _ = view.update(cx, |view, cx| {
710
+                        view.sideload_operation = SideloadOperation::Running {
474 711
                             phase: SideloadPhase::Signing,
475
-            progress: 0.34,
712
+                            progress: 0.035,
713
+                            detail: format!("Registering {} with Apple", device.name),
476 714
                         };
477 715
                         cx.notify();
716
+                    });
717
+                    if let Err(error) = cx
718
+                        .background_spawn(app_effects::add_developer_device(
719
+                            developer_context.clone(),
720
+                            team_id.clone(),
721
+                            device.name.clone(),
722
+                            device.udid.clone(),
723
+                        ))
724
+                        .await
725
+                    {
726
+                        let _ = view.update(cx, |view, cx| {
727
+                            view.sideload_operation = SideloadOperation::Failed {
728
+                                message: format!(
729
+                                    "Could not register {} with Apple: {}",
730
+                                    device.name,
731
+                                    error.user_message()
732
+                                ),
733
+                            };
734
+                            cx.notify();
735
+                        });
736
+                        return;
737
+                    }
738
+                }
739
+            }
740
+            let refreshed_selected_app_id = selected_app_id_identifier.as_deref().and_then(|id| {
741
+                refreshed_team
742
+                    .app_ids
743
+                    .iter()
744
+                    .find(|app_id| app_id.identifier == id)
745
+                    .cloned()
746
+            });
747
+            let plan = match app_effects::app_id_provisioning_plan(
748
+                &refreshed_team,
749
+                auto_app_id,
750
+                refreshed_selected_app_id.clone(),
751
+                &app,
752
+            ) {
753
+                Ok(plan) => plan,
754
+                Err(error) => {
755
+                    let _ = view.update(cx, |view, cx| {
756
+                        view.state
757
+                            .replace_developer_account_preserving_selection(refreshed_account);
758
+                        view.save_preferences();
759
+                        view.sync_settings_windows(cx);
760
+                        view.sideload_operation = SideloadOperation::Failed {
761
+                            message: error.user_message(),
762
+                        };
763
+                        cx.notify();
764
+                    });
765
+                    return;
766
+                }
767
+            };
768
+
769
+            let _ = view.update(cx, |view, cx| {
770
+                view.state
771
+                    .replace_developer_account_preserving_selection(refreshed_account);
772
+                view.save_preferences();
773
+                view.sync_settings_windows(cx);
774
+                cx.notify();
775
+            });
776
+
777
+            if plan.app_ids.len() > 1 {
778
+                let (message, detail) = app_id_provisioning_prompt(&plan);
779
+                let prompt = match view.update_in(cx, |_, window, cx| {
780
+                    window.prompt(
781
+                        PromptLevel::Warning,
782
+                        &message,
783
+                        Some(&detail),
784
+                        &[
785
+                            PromptButton::new(action.confirmation_label()),
786
+                            PromptButton::Cancel("Cancel".into()),
787
+                        ],
788
+                        cx,
789
+                    )
790
+                }) {
791
+                    Ok(prompt) => prompt,
792
+                    Err(_) => return,
793
+                };
794
+                if prompt.await != Ok(0) {
795
+                    let _ = view.update(cx, |view, cx| {
796
+                        view.sideload_operation = SideloadOperation::Idle;
797
+                        cx.notify();
798
+                    });
799
+                    return;
800
+                }
801
+            }
802
+
803
+            let _ = view.update(cx, |view, cx| {
804
+                view.sideload_operation = SideloadOperation::Running {
805
+                    phase: SideloadPhase::Signing,
806
+                    progress: 0.04,
807
+                    detail: "Preparing signing resources".to_string(),
808
+                };
809
+                cx.notify();
810
+            });
811
+            let request = app_effects::SignIpaRequest {
812
+                developer_context,
813
+                team_id,
814
+                team_app_ids: refreshed_team.app_ids,
815
+                certificate_fingerprint,
816
+                public_key_fingerprint,
817
+                auto_app_id,
818
+                selected_app_id: refreshed_selected_app_id,
819
+                app,
820
+                output: signing_output,
821
+            };
822
+            let result = cx
823
+                .background_spawn(async move {
824
+                    app_effects::sign_ipa(request, move |event| {
825
+                        let _ = progress_sender.unbounded_send(event);
826
+                    })
827
+                    .await
828
+                    .map_err(|error| error.user_message())
829
+                })
830
+                .await;
831
+            let outcome = match result {
832
+                Ok(outcome) => outcome,
833
+                Err(message) => {
834
+                    let _ = view.update(cx, |view, cx| {
835
+                        view.sideload_operation = SideloadOperation::Failed { message };
836
+                        cx.notify();
837
+                    });
838
+                    return;
839
+                }
840
+            };
841
+            let app_effects::SignIpaOutcome {
842
+                artifact,
843
+                updated_account,
844
+            } = outcome;
845
+
846
+            let _ = view.update(cx, |view, cx| {
847
+                if let Some(account) = updated_account {
848
+                    view.state
849
+                        .replace_developer_account_preserving_selection(account);
850
+                    view.save_preferences();
851
+                    view.sync_settings_windows(cx);
852
+                }
853
+                cx.notify();
854
+            });
855
+            if action == SigningAction::Save {
856
+                let app_effects::SignIpaArtifact::Ipa(output_path) = artifact else {
857
+                    let _ = view.update(cx, |view, cx| {
858
+                        view.sideload_operation = SideloadOperation::Failed {
859
+                            message: "Signing completed without producing an IPA.".to_string(),
860
+                        };
861
+                        cx.notify();
862
+                    });
863
+                    return;
864
+                };
865
+                let file_name = output_path
866
+                    .file_name()
867
+                    .and_then(|name| name.to_str())
868
+                    .unwrap_or("signed IPA");
869
+                let _ = view.update(cx, |view, cx| {
870
+                    view.sideload_operation = SideloadOperation::Finished {
871
+                        message: format!("Signed {file_name}"),
872
+                    };
873
+                    cx.notify();
874
+                });
875
+                return;
876
+            }
877
+
878
+            let app_effects::SignIpaArtifact::AppBundle(signed_app) = artifact else {
879
+                let _ = view.update(cx, |view, cx| {
880
+                    view.sideload_operation = SideloadOperation::Failed {
881
+                        message: "Signing unexpectedly produced an IPA for sideloading."
882
+                            .to_string(),
883
+                    };
884
+                    cx.notify();
885
+                });
886
+                return;
887
+            };
888
+
889
+            let device = selected_device.expect("sideload device selected above");
890
+            let _ = view.update(cx, |view, cx| {
891
+                view.sideload_operation = SideloadOperation::Running {
892
+                    phase: SideloadPhase::Installing,
893
+                    progress: SIDELOAD_SIGNING_WEIGHT,
894
+                    detail: format!("Connecting to {}", device.name),
895
+                };
896
+                cx.notify();
897
+            });
898
+            let install_result = cx
899
+                .background_spawn(app_effects::install_app(
900
+                    device.udid.clone(),
901
+                    signed_app,
902
+                    move |event| {
903
+                        let _ = install_progress_sender.unbounded_send(event);
904
+                    },
905
+                ))
906
+                .await
907
+                .map_err(|error| error.user_message());
908
+
909
+            let _ = view.update(cx, |view, cx| {
910
+                view.sideload_operation = match install_result {
911
+                    Ok(()) => SideloadOperation::Finished {
912
+                        message: format!("Installed {app_name} on {}", device.name),
913
+                    },
914
+                    Err(message) => SideloadOperation::Failed { message },
915
+                };
916
+                cx.notify();
917
+            });
918
+        })
919
+        .detach();
920
+    }
921
+
922
+    fn fail_signing_and_open_developer_settings(
923
+        &mut self,
924
+        message: &str,
925
+        event: &ClickEvent,
926
+        window: &mut Window,
927
+        cx: &mut Context<Self>,
928
+    ) {
929
+        self.sideload_operation = SideloadOperation::Failed {
930
+            message: message.to_string(),
931
+        };
932
+        self.open_team_settings(event, window, cx);
933
+    }
934
+
935
+    fn set_signing_progress(
936
+        &mut self,
937
+        event: app_effects::SignIpaProgress,
938
+        installs_after_signing: bool,
939
+    ) {
940
+        let SideloadOperation::Running {
941
+            phase: SideloadPhase::Signing,
942
+            progress: current_progress,
943
+            ..
944
+        } = &self.sideload_operation
945
+        else {
946
+            return;
947
+        };
948
+        let next_progress = if installs_after_signing {
949
+            event.progress() * SIDELOAD_SIGNING_WEIGHT
950
+        } else {
951
+            event.progress()
952
+        };
953
+        let progress = next_progress.max(*current_progress);
954
+        self.sideload_operation = SideloadOperation::Running {
955
+            phase: SideloadPhase::Signing,
956
+            progress,
957
+            detail: event.label(),
958
+        };
959
+    }
960
+
961
+    fn set_installing_progress(&mut self, event: app_effects::InstallAppProgress) {
962
+        let SideloadOperation::Running {
963
+            phase: SideloadPhase::Installing,
964
+            progress: current_progress,
965
+            ..
966
+        } = &self.sideload_operation
967
+        else {
968
+            return;
969
+        };
970
+        let next_progress =
971
+            SIDELOAD_SIGNING_WEIGHT + event.progress() * (1. - SIDELOAD_SIGNING_WEIGHT);
972
+        self.sideload_operation = SideloadOperation::Running {
973
+            phase: SideloadPhase::Installing,
974
+            progress: next_progress.max(*current_progress),
975
+            detail: event.label(),
976
+        };
478 977
     }
479 978
 
480 979
     fn refresh_devices_from_button(
... ...
@@ -544,6 +1043,7 @@ impl SideloaderView {
544 1043
                 .await;
545 1044
             let _ = view.update(cx, |view, cx| {
546 1045
                 view.finish_device_refresh(generation, result);
1046
+                view.sync_developer_settings_window(cx);
547 1047
                 cx.notify();
548 1048
             });
549 1049
         })
... ...
@@ -1200,6 +1700,26 @@ impl SideloaderView {
1200 1700
             selected_app,
1201 1701
             self.selected_device(),
1202 1702
         );
1703
+        let status_label = if matches!(self.sideload_operation, SideloadOperation::Failed { .. }) {
1704
+            TextView::markdown("sideload-error-text", escape_markdown_text(&status_text))
1705
+                .selectable(true)
1706
+                .w(px(SIDELOAD_BUTTON_WIDTH))
1707
+                .text_center()
1708
+                .text_xs()
1709
+                .line_height(px(16.))
1710
+                .text_color(rgb(status_color))
1711
+                .into_any_element()
1712
+        } else {
1713
+            div()
1714
+                .w(px(SIDELOAD_BUTTON_WIDTH))
1715
+                .text_center()
1716
+                .text_xs()
1717
+                .line_height(px(16.))
1718
+                .text_color(rgb(status_color))
1719
+                .whitespace_normal()
1720
+                .child(status_text)
1721
+                .into_any_element()
1722
+        };
1203 1723
 
1204 1724
         div()
1205 1725
             .w(px(SIDELOAD_CONNECTOR_WIDTH))
... ...
@@ -1245,7 +1765,7 @@ impl SideloaderView {
1245 1765
             )
1246 1766
             .child(
1247 1767
                 div()
1248
-                    .h(px(SIDELOAD_STATUS_HEIGHT))
1768
+                    .min_h(px(SIDELOAD_STATUS_HEIGHT))
1249 1769
                     .flex()
1250 1770
                     .items_center()
1251 1771
                     .gap_2()
... ...
@@ -1270,15 +1790,7 @@ impl SideloaderView {
1270 1790
                                             .bg(rgb(status_color)),
1271 1791
                                     ),
1272 1792
                             )
1273
-                            .child(
1274
-                                div()
1275
-                                    .w(px(SIDELOAD_BUTTON_WIDTH))
1276
-                                    .text_center()
1277
-                                    .text_xs()
1278
-                                    .text_color(rgb(status_color))
1279
-                                    .text_ellipsis()
1280
-                                    .child(status_text),
1281
-                            ),
1793
+                            .child(status_label),
1282 1794
                     )
1283 1795
                     .child(div().flex_none().w_6()),
1284 1796
             )
... ...
@@ -1509,7 +2021,7 @@ fn sideload_top_label(operation: &SideloadOperation) -> &'static str {
1509 2021
     match operation {
1510 2022
         SideloadOperation::Idle => "Draft",
1511 2023
         SideloadOperation::Running { phase, .. } => sideload_phase_label(*phase),
1512
-        SideloadOperation::Finished => "Done",
2024
+        SideloadOperation::Finished { .. } => "Done",
1513 2025
         SideloadOperation::Failed { .. } => "Failed",
1514 2026
     }
1515 2027
 }
... ...
@@ -1518,7 +2030,7 @@ fn sideload_status_color(operation: &SideloadOperation) -> u32 {
1518 2030
     match operation {
1519 2031
         SideloadOperation::Idle => 0x53666d,
1520 2032
         SideloadOperation::Running { .. } => 0x173f45,
1521
-        SideloadOperation::Finished => 0x1d6b45,
2033
+        SideloadOperation::Finished { .. } => 0x1d6b45,
1522 2034
         SideloadOperation::Failed { .. } => 0x9a302b,
1523 2035
     }
1524 2036
 }
... ...
@@ -1526,7 +2038,7 @@ fn sideload_status_color(operation: &SideloadOperation) -> u32 {
1526 2038
 fn sideload_button_label(operation: &SideloadOperation) -> &'static str {
1527 2039
     match operation {
1528 2040
         SideloadOperation::Idle
1529
-        | SideloadOperation::Finished
2041
+        | SideloadOperation::Finished { .. }
1530 2042
         | SideloadOperation::Failed { .. } => "Sideload",
1531 2043
         SideloadOperation::Running { phase, .. } => sideload_phase_label(*phase),
1532 2044
     }
... ...
@@ -1550,7 +2062,15 @@ fn sideload_status_text(
1550 2062
                 "Ready to sign".to_string()
1551 2063
             }
1552 2064
         }
1553
-        SideloadOperation::Running { phase, .. } => match phase {
2065
+        SideloadOperation::Running {
2066
+            phase,
2067
+            progress,
2068
+            detail,
2069
+        } => {
2070
+            if !detail.is_empty() {
2071
+                format!("{detail} - {:.0}%", progress.clamp(0., 1.) * 100.)
2072
+            } else {
2073
+                match phase {
1554 2074
                     SideloadPhase::Signing => {
1555 2075
                         let app_name = app.map(|app| app.name().as_str()).unwrap_or("app");
1556 2076
                         format!("Signing {app_name}")
... ...
@@ -1561,17 +2081,43 @@ fn sideload_status_text(
1561 2081
                             .unwrap_or("device");
1562 2082
                         format!("Installing to {device_name}")
1563 2083
                     }
1564
-        },
1565
-        SideloadOperation::Finished => {
1566
-            let device_name = device
1567
-                .map(|device| device.name.as_str())
1568
-                .unwrap_or("device");
1569
-            format!("Installed on {device_name}")
1570 2084
                 }
2085
+            }
2086
+        }
2087
+        SideloadOperation::Finished { message } => message.clone(),
1571 2088
         SideloadOperation::Failed { message } => message.clone(),
1572 2089
     }
1573 2090
 }
1574 2091
 
2092
+fn escape_markdown_text(text: &str) -> String {
2093
+    text.chars()
2094
+        .flat_map(|character| {
2095
+            if matches!(
2096
+                character,
2097
+                '\\' | '`'
2098
+                    | '*'
2099
+                    | '_'
2100
+                    | '{'
2101
+                    | '}'
2102
+                    | '['
2103
+                    | ']'
2104
+                    | '<'
2105
+                    | '>'
2106
+                    | '#'
2107
+                    | '+'
2108
+                    | '-'
2109
+                    | '.'
2110
+                    | '!'
2111
+                    | '|'
2112
+            ) {
2113
+                vec!['\\', character]
2114
+            } else {
2115
+                vec![character]
2116
+            }
2117
+        })
2118
+        .collect()
2119
+}
2120
+
1575 2121
 fn sideload_phase_label(phase: SideloadPhase) -> &'static str {
1576 2122
     match phase {
1577 2123
         SideloadPhase::Signing => "Signing",
... ...
@@ -1587,6 +2133,43 @@ fn first_ipa_path(paths: &ExternalPaths) -> Option<PathBuf> {
1587 2133
         .cloned()
1588 2134
 }
1589 2135
 
2136
+fn signed_ipa_destination(app_path: &str) -> (PathBuf, String) {
2137
+    let path = Path::new(app_path);
2138
+    let directory = path
2139
+        .parent()
2140
+        .filter(|parent| !parent.as_os_str().is_empty())
2141
+        .unwrap_or_else(|| Path::new("."))
2142
+        .to_path_buf();
2143
+    let stem = path
2144
+        .file_stem()
2145
+        .and_then(|value| value.to_str())
2146
+        .filter(|value| !value.is_empty())
2147
+        .unwrap_or("app");
2148
+    (directory, format!("{stem}-signed.ipa"))
2149
+}
2150
+
2151
+fn app_id_provisioning_prompt(plan: &app_effects::AppIdProvisioningPlan) -> (String, String) {
2152
+    let count = plan.app_ids.len();
2153
+    let message = format!("Create {count} App IDs before signing?");
2154
+    let mut detail = format!(
2155
+        "Super Sideloader needs to add these App IDs:\n\n{}",
2156
+        plan.app_ids
2157
+            .iter()
2158
+            .map(|app_id| format!("- {} ({})", app_id.name, app_id.identifier))
2159
+            .collect::<Vec<_>>()
2160
+            .join("\n")
2161
+    );
2162
+    if let Some(available) = plan.available_quantity {
2163
+        let remaining = plan.remaining_after_signing().unwrap_or_default();
2164
+        detail.push_str(&format!(
2165
+            "\n\nApple currently reports {available} App IDs remaining. {remaining} will remain after these are created."
2166
+        ));
2167
+    } else {
2168
+        detail.push_str("\n\nApple did not report the remaining App ID quota.");
2169
+    }
2170
+    (message, detail)
2171
+}
2172
+
1590 2173
 fn paths_include_ipa(paths: &ExternalPaths) -> bool {
1591 2174
     first_ipa_path(paths).is_some()
1592 2175
 }
... ...
@@ -8,8 +8,8 @@ use crate::app::effects::{
8 8
 };
9 9
 use crate::app::models::{
10 10
     AccountOption, AdiBackendKind, AdiBackendOption, AdiProvisioningState, AppEntitlement,
11
-    AppIdOption, AppMetadataField, AppOption, DevelopmentCertificateOption, EntitlementValue,
12
-    MachineIdentity, SupportedDeviceFamily, TeamOption,
11
+    AppIdOption, AppMetadataField, AppOption, DeveloperDeviceOption, DevelopmentCertificateOption,
12
+    DeviceOption, EntitlementValue, MachineIdentity, SupportedDeviceFamily, TeamOption,
13 13
 };
14 14
 use crate::app::preferences::ThemePreference;
15 15
 use crate::app::state::SideloaderState;
... ...
@@ -62,6 +62,7 @@ struct TeamSettingsSnapshot {
62 62
     selected_certificate: usize,
63 63
     auto_app_id: bool,
64 64
     selected_app_id: usize,
65
+    selected_device: Option<DeviceOption>,
65 66
 }
66 67
 
67 68
 #[derive(Clone)]
... ...
@@ -108,6 +109,12 @@ pub(crate) struct SettingsWindow {
108 109
     app_detail_edit: Option<AppDetailEdit>,
109 110
     app_id_add_form: Option<AppIdAddForm>,
110 111
     app_id_edit_form: Option<AppIdEditForm>,
112
+    developer_device_add_form: Option<DeveloperDeviceAddForm>,
113
+    developer_devices: Vec<DeveloperDeviceOption>,
114
+    developer_devices_team_id: Option<String>,
115
+    developer_devices_loading: bool,
116
+    developer_devices_error: Option<SharedString>,
117
+    developer_devices_request_generation: u64,
111 118
     selected_entitlement: Option<usize>,
112 119
     entitlement_edit: Option<EntitlementEdit>,
113 120
     entitlement_type_picker_open: bool,
... ...
@@ -313,6 +320,13 @@ struct AppIdEditForm {
313 320
     capabilities: Vec<AppIdCapabilityEdit>,
314 321
 }
315 322
 
323
+#[derive(Clone)]
324
+struct DeveloperDeviceAddForm {
325
+    team_id: String,
326
+    name: Entity<InputState>,
327
+    udid: Entity<InputState>,
328
+}
329
+
316 330
 #[derive(Clone)]
317 331
 struct AppIdCapabilityEdit {
318 332
     key: String,
... ...
@@ -460,6 +474,12 @@ fn settings_task_is_current(
460 474
     current_generation == task_generation && current_mode == expected_mode
461 475
 }
462 476
 
477
+fn developer_device_form_defaults(selected_device: Option<&DeviceOption>) -> (String, String) {
478
+    selected_device
479
+        .map(|device| (device.name.clone(), device.udid.clone()))
480
+        .unwrap_or_default()
481
+}
482
+
463 483
 impl SettingsSnapshot {
464 484
     fn from_state(mode: &SettingsMode, state: &SideloaderState) -> Option<Self> {
465 485
         match mode {
... ...
@@ -483,6 +503,7 @@ impl SettingsSnapshot {
483 503
                     selected_certificate: state.selected_certificate,
484 504
                     auto_app_id: state.auto_app_id,
485 505
                     selected_app_id: state.selected_app_id,
506
+                    selected_device: state.selected_device().cloned(),
486 507
                 }))
487 508
             }
488 509
             SettingsMode::AppSettings { app_index } => {
... ...
@@ -520,7 +541,13 @@ impl SettingsWindow {
520 541
     ) -> Self {
521 542
         let focus_handle = cx.focus_handle();
522 543
         window.focus(&focus_handle, cx);
523
-        cx.defer_in(window, |_, _, cx| cx.notify());
544
+        cx.defer_in(window, |settings, window, cx| {
545
+            if matches!(settings.mode, SettingsMode::Team) {
546
+                settings.load_developer_devices(window, cx);
547
+            } else {
548
+                cx.notify();
549
+            }
550
+        });
524 551
         cx.observe_window_appearance(window, |settings, window, cx| {
525 552
             sync_window_theme(window, cx, settings.theme_preference);
526 553
             cx.notify();
... ...
@@ -545,6 +572,12 @@ impl SettingsWindow {
545 572
             app_detail_edit: None,
546 573
             app_id_add_form: None,
547 574
             app_id_edit_form: None,
575
+            developer_device_add_form: None,
576
+            developer_devices: Vec::new(),
577
+            developer_devices_team_id: None,
578
+            developer_devices_loading: false,
579
+            developer_devices_error: None,
580
+            developer_devices_request_generation: 0,
548 581
             selected_entitlement: None,
549 582
             entitlement_edit: None,
550 583
             entitlement_type_picker_open: false,
... ...
@@ -858,6 +891,13 @@ impl SettingsWindow {
858 891
         self.app_detail_edit = None;
859 892
         self.app_id_add_form = None;
860 893
         self.app_id_edit_form = None;
894
+        self.developer_device_add_form = None;
895
+        self.developer_devices.clear();
896
+        self.developer_devices_team_id = None;
897
+        self.developer_devices_loading = false;
898
+        self.developer_devices_error = None;
899
+        self.developer_devices_request_generation =
900
+            self.developer_devices_request_generation.wrapping_add(1);
861 901
         self.selected_entitlement = None;
862 902
         self.entitlement_edit = None;
863 903
         self.entitlement_type_picker_open = false;
... ...
@@ -871,6 +911,9 @@ impl SettingsWindow {
871 911
         window.set_window_title(title);
872 912
         window.activate_window();
873 913
         window.focus(&self.focus_handle, cx);
914
+        if matches!(self.mode, SettingsMode::Team) {
915
+            self.load_developer_devices(window, cx);
916
+        }
874 917
         cx.defer_in(window, |_, _, cx| cx.notify());
875 918
         cx.notify();
876 919
     }
... ...
@@ -1058,7 +1101,7 @@ impl SettingsWindow {
1058 1101
         &mut self,
1059 1102
         index: usize,
1060 1103
         _: &ClickEvent,
1061
-        _: &mut Window,
1104
+        window: &mut Window,
1062 1105
         cx: &mut Context<Self>,
1063 1106
     ) {
1064 1107
         let Some(snapshot) = self.team_snapshot(cx) else {
... ...
@@ -1074,7 +1117,323 @@ impl SettingsWindow {
1074 1117
         self.app_id_picker_open = false;
1075 1118
         self.app_id_add_form = None;
1076 1119
         self.app_id_edit_form = None;
1077
-        let _ = self.dispatch_parent_action(SettingsParentAction::SelectTeam(index), cx);
1120
+        self.developer_device_add_form = None;
1121
+        if self
1122
+            .dispatch_parent_action(SettingsParentAction::SelectTeam(index), cx)
1123
+            .is_ok()
1124
+        {
1125
+            self.load_developer_devices(window, cx);
1126
+        }
1127
+        cx.notify();
1128
+    }
1129
+
1130
+    fn current_developer_team_id(&self, cx: &mut Context<Self>) -> Option<String> {
1131
+        let snapshot = self.team_snapshot(cx)?;
1132
+        snapshot
1133
+            .teams
1134
+            .get(snapshot.selected_team)
1135
+            .map(|team| team.identifier.clone())
1136
+    }
1137
+
1138
+    fn accepts_developer_device_result(
1139
+        &self,
1140
+        task_generation: u64,
1141
+        task_mode: &SettingsMode,
1142
+        device_request_generation: u64,
1143
+        team_id: &str,
1144
+        cx: &mut Context<Self>,
1145
+    ) -> bool {
1146
+        self.accepts_task_result(task_generation, task_mode)
1147
+            && self.developer_devices_request_generation == device_request_generation
1148
+            && self.current_developer_team_id(cx).as_deref() == Some(team_id)
1149
+    }
1150
+
1151
+    fn begin_developer_device_request(&mut self, team_id: &str) -> u64 {
1152
+        self.developer_devices_request_generation =
1153
+            self.developer_devices_request_generation.wrapping_add(1);
1154
+        self.developer_devices_team_id = Some(team_id.to_string());
1155
+        self.developer_devices_loading = true;
1156
+        self.developer_devices_error = None;
1157
+        self.developer_devices_request_generation
1158
+    }
1159
+
1160
+    fn apply_developer_device_result(
1161
+        &mut self,
1162
+        result: Result<Vec<DeveloperDeviceOption>, AppError>,
1163
+        team_id: String,
1164
+        cx: &mut Context<Self>,
1165
+    ) {
1166
+        self.developer_devices_loading = false;
1167
+        self.developer_devices_team_id = Some(team_id);
1168
+        match result {
1169
+            Ok(devices) => self.developer_devices = devices,
1170
+            Err(error) => self.developer_devices_error = Some(error.user_message().into()),
1171
+        }
1172
+        cx.notify();
1173
+    }
1174
+
1175
+    fn load_developer_devices(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1176
+        if !matches!(self.mode, SettingsMode::Team) {
1177
+            return;
1178
+        }
1179
+        let Some(team_id) = self.current_developer_team_id(cx) else {
1180
+            self.developer_devices_request_generation =
1181
+                self.developer_devices_request_generation.wrapping_add(1);
1182
+            self.developer_devices.clear();
1183
+            self.developer_devices_team_id = None;
1184
+            self.developer_devices_loading = false;
1185
+            self.developer_devices_error = None;
1186
+            cx.notify();
1187
+            return;
1188
+        };
1189
+        if self.developer_devices_loading
1190
+            && self.developer_devices_team_id.as_deref() == Some(team_id.as_str())
1191
+        {
1192
+            return;
1193
+        }
1194
+        if self.developer_devices_loading {
1195
+            self.developer_devices_request_generation =
1196
+                self.developer_devices_request_generation.wrapping_add(1);
1197
+            self.developer_devices_loading = false;
1198
+            self.developer_devices.clear();
1199
+            self.developer_devices_team_id = Some(team_id.clone());
1200
+        }
1201
+        let developer_context = match self.selected_developer_context(cx) {
1202
+            Ok(context) => context,
1203
+            Err(error) => {
1204
+                self.developer_devices_request_generation =
1205
+                    self.developer_devices_request_generation.wrapping_add(1);
1206
+                self.developer_devices.clear();
1207
+                self.developer_devices_team_id = Some(team_id);
1208
+                self.developer_devices_loading = false;
1209
+                self.developer_devices_error = Some(error.into());
1210
+                cx.notify();
1211
+                return;
1212
+            }
1213
+        };
1214
+
1215
+        let task_generation = self.task_generation();
1216
+        let task_mode = self.task_mode();
1217
+        let device_request_generation = self.begin_developer_device_request(&team_id);
1218
+        self.developer_devices.clear();
1219
+        let team_id_for_operation = team_id.clone();
1220
+        cx.spawn_in(window, async move |settings, cx| {
1221
+            let result = cx
1222
+                .background_spawn(async move {
1223
+                    app_effects::list_developer_devices(developer_context, team_id_for_operation)
1224
+                        .await
1225
+                })
1226
+                .await;
1227
+            let _ = settings.update_in(cx, |settings, _, cx| {
1228
+                if !settings.accepts_developer_device_result(
1229
+                    task_generation,
1230
+                    &task_mode,
1231
+                    device_request_generation,
1232
+                    &team_id,
1233
+                    cx,
1234
+                ) {
1235
+                    return;
1236
+                }
1237
+                settings.apply_developer_device_result(result, team_id, cx);
1238
+            });
1239
+        })
1240
+        .detach();
1241
+        cx.notify();
1242
+    }
1243
+
1244
+    fn set_developer_device_add_popover(
1245
+        &mut self,
1246
+        open: bool,
1247
+        window: &mut Window,
1248
+        cx: &mut Context<Self>,
1249
+    ) {
1250
+        window.focus(&self.focus_handle, cx);
1251
+        if self.developer_devices_loading || self.team_refreshing {
1252
+            return;
1253
+        }
1254
+        if !open {
1255
+            self.developer_device_add_form = None;
1256
+            cx.notify();
1257
+            return;
1258
+        }
1259
+        let selected_device = self
1260
+            .team_snapshot(cx)
1261
+            .and_then(|snapshot| snapshot.selected_device);
1262
+        let Some(team_id) = self.current_developer_team_id(cx) else {
1263
+            self.developer_devices_error = Some("No developer team is selected.".into());
1264
+            cx.notify();
1265
+            return;
1266
+        };
1267
+        let (default_name, default_udid) = developer_device_form_defaults(selected_device.as_ref());
1268
+        let name = cx.new(|cx| {
1269
+            InputState::new(window, cx)
1270
+                .default_value(default_name)
1271
+                .placeholder("Development iPhone")
1272
+                .clean_on_escape()
1273
+        });
1274
+        let udid = cx.new(|cx| {
1275
+            InputState::new(window, cx)
1276
+                .default_value(default_udid)
1277
+                .placeholder("0000000000000000000000000000000000000000")
1278
+                .clean_on_escape()
1279
+        });
1280
+        self.developer_device_add_form = Some(DeveloperDeviceAddForm {
1281
+            team_id,
1282
+            name: name.clone(),
1283
+            udid,
1284
+        });
1285
+        self.developer_devices_error = None;
1286
+        name.update(cx, |name, cx| name.focus(window, cx));
1287
+        cx.notify();
1288
+    }
1289
+
1290
+    fn cancel_developer_device_add(
1291
+        &mut self,
1292
+        _: &ClickEvent,
1293
+        window: &mut Window,
1294
+        cx: &mut Context<Self>,
1295
+    ) {
1296
+        window.focus(&self.focus_handle, cx);
1297
+        self.developer_device_add_form = None;
1298
+        cx.notify();
1299
+    }
1300
+
1301
+    fn submit_developer_device_add(
1302
+        &mut self,
1303
+        _: &ClickEvent,
1304
+        window: &mut Window,
1305
+        cx: &mut Context<Self>,
1306
+    ) {
1307
+        window.focus(&self.focus_handle, cx);
1308
+        if self.developer_devices_loading || self.team_refreshing {
1309
+            return;
1310
+        }
1311
+        let Some(form) = self.developer_device_add_form.clone() else {
1312
+            return;
1313
+        };
1314
+        if self.current_developer_team_id(cx).as_deref() != Some(form.team_id.as_str()) {
1315
+            self.developer_device_add_form = None;
1316
+            self.developer_devices_error =
1317
+                Some("The selected developer team changed. Add the device again.".into());
1318
+            cx.notify();
1319
+            return;
1320
+        }
1321
+        let name = form.name.read(cx).value().trim().to_string();
1322
+        let udid = form.udid.read(cx).value().trim().to_string();
1323
+        if name.is_empty() {
1324
+            self.developer_devices_error = Some("Enter a device name.".into());
1325
+            cx.notify();
1326
+            return;
1327
+        }
1328
+        if udid.is_empty() {
1329
+            self.developer_devices_error = Some("Enter a device UDID.".into());
1330
+            cx.notify();
1331
+            return;
1332
+        }
1333
+        let developer_context = match self.selected_developer_context(cx) {
1334
+            Ok(context) => context,
1335
+            Err(error) => {
1336
+                self.developer_devices_error = Some(error.into());
1337
+                cx.notify();
1338
+                return;
1339
+            }
1340
+        };
1341
+
1342
+        let team_id = form.team_id;
1343
+        let task_generation = self.task_generation();
1344
+        let task_mode = self.task_mode();
1345
+        let device_request_generation = self.begin_developer_device_request(&team_id);
1346
+        self.developer_device_add_form = None;
1347
+        let team_id_for_operation = team_id.clone();
1348
+        cx.spawn_in(window, async move |settings, cx| {
1349
+            let result = cx
1350
+                .background_spawn(async move {
1351
+                    app_effects::add_developer_device(
1352
+                        developer_context,
1353
+                        team_id_for_operation,
1354
+                        name,
1355
+                        udid,
1356
+                    )
1357
+                    .await
1358
+                })
1359
+                .await;
1360
+            let _ = settings.update_in(cx, |settings, _, cx| {
1361
+                if !settings.accepts_developer_device_result(
1362
+                    task_generation,
1363
+                    &task_mode,
1364
+                    device_request_generation,
1365
+                    &team_id,
1366
+                    cx,
1367
+                ) {
1368
+                    return;
1369
+                }
1370
+                settings.apply_developer_device_result(result, team_id, cx);
1371
+            });
1372
+        })
1373
+        .detach();
1374
+        cx.notify();
1375
+    }
1376
+
1377
+    fn remove_developer_device(
1378
+        &mut self,
1379
+        device_id: String,
1380
+        _: &ClickEvent,
1381
+        window: &mut Window,
1382
+        cx: &mut Context<Self>,
1383
+    ) {
1384
+        window.focus(&self.focus_handle, cx);
1385
+        if self.developer_devices_loading || self.team_refreshing {
1386
+            return;
1387
+        }
1388
+        let Some(team_id) = self.current_developer_team_id(cx) else {
1389
+            self.developer_devices_error = Some("No developer team is selected.".into());
1390
+            cx.notify();
1391
+            return;
1392
+        };
1393
+        if device_id.trim().is_empty() {
1394
+            self.developer_devices_error =
1395
+                Some("The selected device has no portal identifier.".into());
1396
+            cx.notify();
1397
+            return;
1398
+        }
1399
+        let developer_context = match self.selected_developer_context(cx) {
1400
+            Ok(context) => context,
1401
+            Err(error) => {
1402
+                self.developer_devices_error = Some(error.into());
1403
+                cx.notify();
1404
+                return;
1405
+            }
1406
+        };
1407
+
1408
+        let task_generation = self.task_generation();
1409
+        let task_mode = self.task_mode();
1410
+        let device_request_generation = self.begin_developer_device_request(&team_id);
1411
+        let team_id_for_operation = team_id.clone();
1412
+        cx.spawn_in(window, async move |settings, cx| {
1413
+            let result = cx
1414
+                .background_spawn(async move {
1415
+                    app_effects::delete_developer_device(
1416
+                        developer_context,
1417
+                        team_id_for_operation,
1418
+                        device_id,
1419
+                    )
1420
+                    .await
1421
+                })
1422
+                .await;
1423
+            let _ = settings.update_in(cx, |settings, _, cx| {
1424
+                if !settings.accepts_developer_device_result(
1425
+                    task_generation,
1426
+                    &task_mode,
1427
+                    device_request_generation,
1428
+                    &team_id,
1429
+                    cx,
1430
+                ) {
1431
+                    return;
1432
+                }
1433
+                settings.apply_developer_device_result(result, team_id, cx);
1434
+            });
1435
+        })
1436
+        .detach();
1078 1437
         cx.notify();
1079 1438
     }
1080 1439
 
... ...
@@ -1238,6 +1597,11 @@ impl SettingsWindow {
1238 1597
         self.app_id_picker_open = false;
1239 1598
         self.app_id_add_form = None;
1240 1599
         self.app_id_edit_form = None;
1600
+        self.developer_device_add_form = None;
1601
+        self.developer_devices_request_generation =
1602
+            self.developer_devices_request_generation.wrapping_add(1);
1603
+        self.developer_devices_loading = false;
1604
+        self.developer_devices_error = None;
1241 1605
         self.team_refreshing = true;
1242 1606
         self.team_refresh_error = None;
1243 1607
         self.certificate_error = None;
... ...
@@ -3062,6 +3426,7 @@ mod tests {
3062 3426
                 "16.0",
3063 3427
                 vec![SupportedDeviceFamily::IPhone],
3064 3428
             ),
3429
+            nested_bundles: Vec::new(),
3065 3430
             path: format!("/tmp/{name}.ipa"),
3066 3431
             icon_path: None,
3067 3432
             icon_override_path: None,
... ...
@@ -3092,6 +3457,7 @@ mod tests {
3092 3457
                 serial_number: "SERIAL".to_string(),
3093 3458
                 machine_name: "Mac".to_string(),
3094 3459
                 private_key_available: true,
3460
+                certificate_fingerprint: Some("certificate-fingerprint".to_string()),
3095 3461
                 public_key_fingerprint: Some("fingerprint".to_string()),
3096 3462
             }],
3097 3463
         }
... ...
@@ -3166,6 +3532,26 @@ mod tests {
3166 3532
         ));
3167 3533
     }
3168 3534
 
3535
+    #[test]
3536
+    fn developer_device_form_uses_selected_device_defaults() {
3537
+        let device = DeviceOption {
3538
+            name: "Development iPhone".to_string(),
3539
+            model: "iPhone".to_string(),
3540
+            os: "18.0".to_string(),
3541
+            udid: "00008110-001234567890001E".to_string(),
3542
+            connection: "USB".to_string(),
3543
+        };
3544
+
3545
+        assert_eq!(
3546
+            developer_device_form_defaults(Some(&device)),
3547
+            (device.name.clone(), device.udid.clone())
3548
+        );
3549
+        assert_eq!(
3550
+            developer_device_form_defaults(None),
3551
+            (String::new(), String::new())
3552
+        );
3553
+    }
3554
+
3169 3555
     #[test]
3170 3556
     fn settings_snapshots_are_derived_from_latest_parent_state() {
3171 3557
         let mut state = sample_state();
... ...
@@ -3208,7 +3594,7 @@ impl Render for SettingsWindow {
3208 3594
         let theme_preference = self.theme_preference(cx);
3209 3595
         sync_window_theme(window, cx, theme_preference);
3210 3596
 
3211
-        if self.adi_operation.is_some() || self.team_refreshing {
3597
+        if self.adi_operation.is_some() || self.team_refreshing || self.developer_devices_loading {
3212 3598
             self.spinner_turns = (self.spinner_turns + 0.035) % 1.;
3213 3599
             window.request_animation_frame();
3214 3600
         } else {
... ...
@@ -3231,6 +3617,10 @@ impl Render for SettingsWindow {
3231 3617
         let app_settings_error = self.app_settings_error.clone();
3232 3618
         let app_id_add_form = self.app_id_add_form.clone();
3233 3619
         let app_id_edit_form = self.app_id_edit_form.clone();
3620
+        let developer_device_add_form = self.developer_device_add_form.clone();
3621
+        let developer_devices = self.developer_devices.clone();
3622
+        let developer_devices_loading = self.developer_devices_loading;
3623
+        let developer_devices_error = self.developer_devices_error.clone();
3234 3624
         let selected_entitlement = self.selected_entitlement;
3235 3625
         let entitlement_edit = self.entitlement_edit.clone();
3236 3626
         let entitlement_type_picker_open = self.entitlement_type_picker_open;
... ...
@@ -3257,6 +3647,10 @@ impl Render for SettingsWindow {
3257 3647
                     app_id_picker_open,
3258 3648
                     app_id_add_form: app_id_add_form.as_ref(),
3259 3649
                     app_id_edit_form: app_id_edit_form.as_ref(),
3650
+                    developer_devices: &developer_devices,
3651
+                    developer_devices_loading,
3652
+                    developer_device_add_form: developer_device_add_form.as_ref(),
3653
+                    developer_devices_error,
3260 3654
                     team_refreshing,
3261 3655
                     team_refresh_error,
3262 3656
                     certificate_error,
... ...
@@ -70,11 +70,14 @@ pub(super) fn render(props: AppViewProps<'_>, cx: &mut Context<SettingsWindow>)
70 70
                                     app_detail_edit,
71 71
                                     cx,
72 72
                                 ))
73
-                                .child(app_detail_row(
73
+                                .child(app_detail_row_with_display_value(
74 74
                                     AppMetadataField::BundleId,
75 75
                                     app,
76 76
                                     true,
77 77
                                     app_detail_edit,
78
+                                    crate::app::entitlements::effective_bundle_identifier_for_app(
79
+                                        app, team_id,
80
+                                    ),
78 81
                                     cx,
79 82
                                 ))
80 83
                                 .child(app_detail_row(
... ...
@@ -210,9 +213,34 @@ fn app_detail_row(
210 213
     editable: bool,
211 214
     edit: Option<&AppDetailEdit>,
212 215
     cx: &mut Context<SettingsWindow>,
216
+) -> gpui::Div {
217
+    app_detail_row_inner(field, app, editable, edit, None, cx)
218
+}
219
+
220
+fn app_detail_row_with_display_value(
221
+    field: AppMetadataField,
222
+    app: &AppOption,
223
+    editable: bool,
224
+    edit: Option<&AppDetailEdit>,
225
+    display_value: String,
226
+    cx: &mut Context<SettingsWindow>,
227
+) -> gpui::Div {
228
+    app_detail_row_inner(field, app, editable, edit, Some(display_value), cx)
229
+}
230
+
231
+fn app_detail_row_inner(
232
+    field: AppMetadataField,
233
+    app: &AppOption,
234
+    editable: bool,
235
+    edit: Option<&AppDetailEdit>,
236
+    display_value: Option<String>,
237
+    cx: &mut Context<SettingsWindow>,
213 238
 ) -> gpui::Div {
214 239
     let is_editing = edit.is_some_and(|edit| edit.field() == field);
215 240
     let changed = app.field_is_overridden(field);
241
+    let effective_changed = display_value
242
+        .as_deref()
243
+        .is_some_and(|value| value != app.field_display_value(field));
216 244
 
217 245
     div()
218 246
         .min_w_0()
... ...
@@ -241,9 +269,13 @@ fn app_detail_row(
241 269
                     .min_w_0()
242 270
                     .flex_1()
243 271
                     .text_sm()
244
-                    .text_color(rgb(if changed { 0x173f45 } else { 0x405057 }))
272
+                    .text_color(rgb(if changed || effective_changed {
273
+                        0x173f45
274
+                    } else {
275
+                        0x405057
276
+                    }))
245 277
                     .text_ellipsis()
246
-                    .child(app.field_display_value(field))
278
+                    .child(display_value.unwrap_or_else(|| app.field_display_value(field)))
247 279
             },
248 280
         )
249 281
         .when(editable, |this| {
... ...
@@ -14,6 +14,10 @@ pub(super) struct DeveloperViewProps<'a> {
14 14
     pub(super) app_id_picker_open: bool,
15 15
     pub(super) app_id_add_form: Option<&'a AppIdAddForm>,
16 16
     pub(super) app_id_edit_form: Option<&'a AppIdEditForm>,
17
+    pub(super) developer_devices: &'a [DeveloperDeviceOption],
18
+    pub(super) developer_devices_loading: bool,
19
+    pub(super) developer_device_add_form: Option<&'a DeveloperDeviceAddForm>,
20
+    pub(super) developer_devices_error: Option<SharedString>,
17 21
     pub(super) team_refreshing: bool,
18 22
     pub(super) team_refresh_error: Option<SharedString>,
19 23
     pub(super) certificate_error: Option<SharedString>,
... ...
@@ -34,6 +38,10 @@ pub(super) fn render(props: DeveloperViewProps<'_>, cx: &mut Context<SettingsWin
34 38
         app_id_picker_open,
35 39
         app_id_add_form,
36 40
         app_id_edit_form,
41
+        developer_devices,
42
+        developer_devices_loading,
43
+        developer_device_add_form,
44
+        developer_devices_error,
37 45
         team_refreshing,
38 46
         team_refresh_error,
39 47
         certificate_error,
... ...
@@ -69,6 +77,17 @@ pub(super) fn render(props: DeveloperViewProps<'_>, cx: &mut Context<SettingsWin
69 77
         },
70 78
         cx,
71 79
     ));
80
+    content = content.child(developer_device_section(
81
+        DeveloperDeviceSectionProps {
82
+            team,
83
+            devices: developer_devices,
84
+            loading: developer_devices_loading,
85
+            team_refreshing,
86
+            add_form: developer_device_add_form,
87
+            operation_error: developer_devices_error,
88
+        },
89
+        cx,
90
+    ));
72 91
     content = content.child(developer_logout_button(cx));
73 92
 
74 93
     settings_window_shell()
... ...
@@ -493,14 +512,11 @@ fn missing_private_key_warning(
493 512
                         .text_xs()
494 513
                         .font_weight(FontWeight::SEMIBOLD)
495 514
                         .text_color(rgb(0x7a5613))
496
-                        .child("Private key missing"),
515
+                        .child("Managed private key missing"),
497 516
                 )
498
-                .child(
499
-                    div()
500
-                        .text_xs()
501
-                        .text_color(rgb(0x7a6a50))
502
-                        .child("Import the matching private key to use this certificate."),
503
-                ),
517
+                .child(div().text_xs().text_color(rgb(0x7a6a50)).child(
518
+                    "Import the matching PEM key for Super Sideloader to use this certificate.",
519
+                )),
504 520
         )
505 521
         .child(
506 522
             surface_button("import-certificate-key")
... ...
@@ -1117,6 +1133,257 @@ fn app_id_icon_button(
1117 1133
         .child(icon_button_surface(icon, 0xebf1f0, 0xdfe8e6, 0x53666d))
1118 1134
 }
1119 1135
 
1136
+struct DeveloperDeviceSectionProps<'a> {
1137
+    team: Option<&'a TeamOption>,
1138
+    devices: &'a [DeveloperDeviceOption],
1139
+    loading: bool,
1140
+    team_refreshing: bool,
1141
+    add_form: Option<&'a DeveloperDeviceAddForm>,
1142
+    operation_error: Option<SharedString>,
1143
+}
1144
+
1145
+fn developer_device_section(
1146
+    props: DeveloperDeviceSectionProps<'_>,
1147
+    cx: &mut Context<SettingsWindow>,
1148
+) -> gpui::Div {
1149
+    let DeveloperDeviceSectionProps {
1150
+        team,
1151
+        devices,
1152
+        loading,
1153
+        team_refreshing,
1154
+        add_form,
1155
+        operation_error,
1156
+    } = props;
1157
+    let disabled = team.is_none() || loading || team_refreshing;
1158
+
1159
+    developer_settings_section(
1160
+        "Devices",
1161
+        "Registered development devices available to provisioning profiles.",
1162
+        div()
1163
+            .flex()
1164
+            .flex_col()
1165
+            .gap_3()
1166
+            .child(
1167
+                div()
1168
+                    .flex()
1169
+                    .items_center()
1170
+                    .justify_between()
1171
+                    .gap_3()
1172
+                    .child(
1173
+                        div()
1174
+                            .text_xs()
1175
+                            .text_color(rgb(0x6a7a81))
1176
+                            .child(match devices.len() {
1177
+                                0 => "No devices".to_string(),
1178
+                                1 => "1 registered device".to_string(),
1179
+                                count => format!("{count} registered devices"),
1180
+                            }),
1181
+                    )
1182
+                    .child(developer_device_add_button(disabled, add_form, cx)),
1183
+            )
1184
+            .when(loading, |this| {
1185
+                this.child(
1186
+                    div()
1187
+                        .rounded_md()
1188
+                        .border_1()
1189
+                        .border_color(rgb(0xd8e0df))
1190
+                        .bg(rgb(0xf6f9f9))
1191
+                        .p_3()
1192
+                        .text_xs()
1193
+                        .text_color(rgb(0x6a7a81))
1194
+                        .child("Loading registered devices..."),
1195
+                )
1196
+            })
1197
+            .when(!loading && team.is_some() && devices.is_empty(), |this| {
1198
+                this.child(empty_state(
1199
+                    "No development devices are registered for this team.",
1200
+                ))
1201
+            })
1202
+            .when(!loading && team.is_some(), |this| {
1203
+                this.children(
1204
+                    devices
1205
+                        .iter()
1206
+                        .enumerate()
1207
+                        .map(|(index, device)| developer_device_row(device, index, disabled, cx)),
1208
+                )
1209
+            })
1210
+            .when(team.is_none(), |this| {
1211
+                this.child(disabled_select_placeholder(
1212
+                    "No developer team",
1213
+                    "Load a developer team first.",
1214
+                    "Device management unavailable",
1215
+                ))
1216
+            })
1217
+            .when_some(operation_error, |this, error| {
1218
+                this.child(refresh_error(error))
1219
+            }),
1220
+    )
1221
+}
1222
+
1223
+fn developer_device_add_button(
1224
+    disabled: bool,
1225
+    form: Option<&DeveloperDeviceAddForm>,
1226
+    cx: &mut Context<SettingsWindow>,
1227
+) -> AnyElement {
1228
+    let trigger = surface_button("begin-developer-device-add")
1229
+        .h_7()
1230
+        .child(action_button_surface(
1231
+            "Add Device",
1232
+            0xebf1f0,
1233
+            0xdfe8e6,
1234
+            0x53666d,
1235
+            Some(lucide_icon_tinted("icons/plus.svg", 0x53666d)),
1236
+        ));
1237
+
1238
+    if disabled {
1239
+        return trigger.opacity(0.45).tab_stop(false).into_any_element();
1240
+    }
1241
+
1242
+    floating_select_popover(
1243
+        "developer-device-add-popover-scroll",
1244
+        trigger.cursor_pointer(),
1245
+        form.is_some(),
1246
+        cx.listener(|this, open: &bool, window, cx| {
1247
+            this.set_developer_device_add_popover(*open, window, cx);
1248
+        }),
1249
+        FloatingPopoverLayout {
1250
+            width: 420.,
1251
+            max_height: 260.,
1252
+            offset_y: 36.,
1253
+        },
1254
+        developer_device_add_popover(form, cx),
1255
+    )
1256
+    .into_any_element()
1257
+}
1258
+
1259
+fn developer_device_add_popover(
1260
+    form: Option<&DeveloperDeviceAddForm>,
1261
+    cx: &mut Context<SettingsWindow>,
1262
+) -> impl IntoElement {
1263
+    let Some(form) = form else {
1264
+        return div()
1265
+            .p_3()
1266
+            .text_xs()
1267
+            .text_color(rgb(0x6a7a81))
1268
+            .child("Preparing device...");
1269
+    };
1270
+
1271
+    div()
1272
+        .p_3()
1273
+        .flex()
1274
+        .flex_col()
1275
+        .gap_3()
1276
+        .child(
1277
+            div()
1278
+                .flex()
1279
+                .flex_col()
1280
+                .gap_1()
1281
+                .child(settings_label("Name"))
1282
+                .child(Input::new(&form.name).small()),
1283
+        )
1284
+        .child(
1285
+            div()
1286
+                .flex()
1287
+                .flex_col()
1288
+                .gap_1()
1289
+                .child(settings_label("UDID"))
1290
+                .child(Input::new(&form.udid).small()),
1291
+        )
1292
+        .child(
1293
+            div()
1294
+                .flex()
1295
+                .items_center()
1296
+                .justify_end()
1297
+                .gap_2()
1298
+                .child(
1299
+                    surface_button("cancel-developer-device-add")
1300
+                        .h_7()
1301
+                        .cursor_pointer()
1302
+                        .on_click(cx.listener(SettingsWindow::cancel_developer_device_add))
1303
+                        .child(action_button_surface(
1304
+                            "Cancel",
1305
+                            0xebf1f0,
1306
+                            0xdfe8e6,
1307
+                            0x53666d,
1308
+                            None::<gpui::Div>,
1309
+                        )),
1310
+                )
1311
+                .child(
1312
+                    surface_button("confirm-developer-device-add")
1313
+                        .h_7()
1314
+                        .cursor_pointer()
1315
+                        .on_click(cx.listener(SettingsWindow::submit_developer_device_add))
1316
+                        .child(primary_action_button_surface(
1317
+                            "Register",
1318
+                            Some(lucide_icon_tinted("icons/plus.svg", 0xffffff)),
1319
+                        )),
1320
+                ),
1321
+        )
1322
+}
1323
+
1324
+fn developer_device_row(
1325
+    device: &DeveloperDeviceOption,
1326
+    index: usize,
1327
+    disabled: bool,
1328
+    cx: &mut Context<SettingsWindow>,
1329
+) -> impl IntoElement {
1330
+    let device_id = device.id.clone();
1331
+    div()
1332
+        .min_w_0()
1333
+        .p_3()
1334
+        .rounded_md()
1335
+        .border_1()
1336
+        .border_color(rgb(0xd8e0df))
1337
+        .bg(rgb(0xffffff))
1338
+        .flex()
1339
+        .items_center()
1340
+        .justify_between()
1341
+        .gap_3()
1342
+        .child(
1343
+            div()
1344
+                .min_w_0()
1345
+                .flex_1()
1346
+                .flex()
1347
+                .flex_col()
1348
+                .gap_0p5()
1349
+                .child(
1350
+                    div()
1351
+                        .text_xs()
1352
+                        .font_weight(FontWeight::SEMIBOLD)
1353
+                        .text_color(rgb(0x24333a))
1354
+                        .whitespace_normal()
1355
+                        .child(device.name.clone()),
1356
+                )
1357
+                .child(
1358
+                    div()
1359
+                        .text_xs()
1360
+                        .text_color(rgb(0x6a7a81))
1361
+                        .whitespace_normal()
1362
+                        .child(device.udid.clone()),
1363
+                ),
1364
+        )
1365
+        .child(
1366
+            surface_button(("remove-developer-device", index))
1367
+                .flex_none()
1368
+                .w_7()
1369
+                .h_7()
1370
+                .when(disabled, |this| this.opacity(0.45).tab_stop(false))
1371
+                .when(!disabled, |this| {
1372
+                    this.cursor_pointer().on_click(cx.listener(
1373
+                        move |settings, event, window, cx| {
1374
+                            settings.remove_developer_device(device_id.clone(), event, window, cx)
1375
+                        },
1376
+                    ))
1377
+                })
1378
+                .child(icon_button_surface(
1379
+                    "icons/trash-2.svg",
1380
+                    0xf4e9e7,
1381
+                    0xfff7f6,
1382
+                    0x7d3430,
1383
+                )),
1384
+        )
1385
+}
1386
+
1120 1387
 fn settings_app_id_row(app_id: &AppIdOption, index: usize, selected: bool) -> Button {
1121 1388
     select_option_button(
1122 1389
         ("app-id-row", index),
1123 1390